Add PostHog consent integration and extract integration plugin system

Add PostHog opt-in/opt-out consent support mirroring the existing Google
Consent Mode integration: database column, GraphQL field, console UI
toggle, and client-side posthog-js calls.

Extract both GCM and PostHog logic from CookieBannerClient into a
ConsentIntegration plugin interface so future integrations can be added
without modifying the client core.

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-04-24 15:51:40 +04:00
parent c6f548b8ff
commit 7f1dffad80
14 changed files with 281 additions and 54 deletions

View File

@@ -60,6 +60,7 @@ export const categorySectionFragment = graphql`
description
kind
gcmConsentTypes
posthogConsent
cookies(first: 100, orderBy: { field: CREATED_AT, direction: ASC })
@connection(key: "CategorySection_cookies", filters: [])
@required(action: THROW) {
@@ -99,6 +100,7 @@ const updateCategoryMutation = graphql`
description
rank
gcmConsentTypes
posthogConsent
updatedAt
}
cookieBanner {
@@ -239,7 +241,10 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
const cookies = category.cookies.edges.map(e => e.node);
const isMutating = isUpdating || isCreating || isUpdatingCookie;
const handleSaveCategory = (name: string, slug: string, description: string, gcmConsentTypes: string[]) => {
const handleSaveCategory = (
name: string, slug: string, description: string,
gcmConsentTypes: string[], posthogConsent: boolean,
) => {
updateCategory({
variables: {
input: {
@@ -248,6 +253,7 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
slug,
description,
gcmConsentTypes,
posthogConsent,
},
},
onCompleted(_response, errors) {
@@ -485,6 +491,7 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
slug={category.slug}
description={category.description}
gcmConsentTypes={[...category.gcmConsentTypes]}
posthogConsent={category.posthogConsent}
isUpdating={isUpdating}
onSave={handleSaveCategory}
onCancel={() => setIsEditingCategory(false)}
@@ -541,6 +548,16 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
))}
</div>
)}
{category.posthogConsent && (
<div className="mt-2 flex items-center gap-1.5">
<span className="text-xs text-txt-secondary/70">
{__("PostHog:")}
</span>
<Badge variant="neutral">
{__("Tracking consent")}
</Badge>
</div>
)}
</>
)}
</div>

View File

@@ -31,8 +31,9 @@ interface EditCategoryFormProps {
slug: string;
description: string;
gcmConsentTypes: string[];
posthogConsent: boolean;
isUpdating: boolean;
onSave: (name: string, slug: string, description: string, gcmConsentTypes: string[]) => void;
onSave: (name: string, slug: string, description: string, gcmConsentTypes: string[], posthogConsent: boolean) => void;
onCancel: () => void;
}
@@ -41,6 +42,7 @@ export function EditCategoryForm({
slug,
description,
gcmConsentTypes,
posthogConsent,
isUpdating,
onSave,
onCancel,
@@ -50,6 +52,7 @@ export function EditCategoryForm({
const [editSlug, setEditSlug] = useState(slug);
const [editDescription, setEditDescription] = useState(description);
const [editGcmTypes, setEditGcmTypes] = useState<string[]>(gcmConsentTypes);
const [editPosthogConsent, setEditPosthogConsent] = useState(posthogConsent);
const toggleGcmType = (type: string) => {
setEditGcmTypes(prev =>
@@ -102,11 +105,28 @@ export function EditCategoryForm({
))}
</div>
</div>
<div>
<label className="text-sm font-medium">
{__("PostHog")}
</label>
<p className="text-xs text-muted-foreground mb-2">
{__("Control PostHog tracking consent based on this category.")}
</p>
<label className="flex items-center gap-1.5 text-xs cursor-pointer">
<input
type="checkbox"
checked={editPosthogConsent}
onChange={() => setEditPosthogConsent(prev => !prev)}
className="rounded"
/>
<span>{__("Opt in/out of PostHog tracking")}</span>
</label>
</div>
<div className="flex items-center gap-2">
<Button
onClick={() => {
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(editSlug)) return;
onSave(editName, editSlug, editDescription, editGcmTypes);
onSave(editName, editSlug, editDescription, editGcmTypes, editPosthogConsent);
}}
disabled={isUpdating}
>

View File

@@ -24,6 +24,8 @@ import { NotFoundError } from "./errors";
import { fetchJSON } from "./http";
import type { BannerTexts } from "./i18n";
import { detectLanguage } from "./i18n";
import type { ConsentIntegration } from "./integrations";
import { createDefaultIntegrations } from "./integrations";
import { enqueue, flush } from "./queue";
import { getOrCreateVisitorId } from "./visitor";
@@ -40,6 +42,7 @@ export interface Category {
kind: string;
cookies: CookieItem[];
gcm_consent_types: string[];
posthog_consent: boolean;
}
export interface BannerConfig {
@@ -84,6 +87,8 @@ export class CookieBannerClient {
private readonly visitorId: string;
private readonly lang: string;
private readonly integrations: ConsentIntegration[];
private bannerConfig: BannerConfig | null = null;
private consent: VisitorConsent | null = null;
private observer: MutationObserver | null = null;
@@ -99,6 +104,7 @@ export class CookieBannerClient {
this.bannerId = config.bannerId;
this.visitorId = getOrCreateVisitorId(config.bannerId);
this.lang = detectLanguage(config.lang);
this.integrations = createDefaultIntegrations();
}
async load(): Promise<void> {
@@ -109,7 +115,9 @@ export class CookieBannerClient {
const config = await fetchJSON<BannerConfig>(configUrl);
this.bannerConfig = config;
this.setGoogleConsentDefaults();
for (const integration of this.integrations) {
integration.setDefaults(config.categories);
}
this.startDetector(config);
const cookie = getConsentCookie();
@@ -274,7 +282,9 @@ export class CookieBannerClient {
}
private activate(consentData: Record<string, boolean>): void {
this.updateGoogleConsentMode(consentData);
for (const integration of this.integrations) {
integration.update(this.config.categories, consentData);
}
const categoryCookies: Record<string, string[]> = {};
const categoryLabels: Record<string, string> = {};
@@ -293,55 +303,6 @@ export class CookieBannerClient {
this.observer = observeAndActivate(consentData, categoryLabels, texts);
}
private hasGCMMapping(): boolean {
return this.config.categories.some(
(cat) => cat.gcm_consent_types && cat.gcm_consent_types.length > 0,
);
}
private setGoogleConsentDefaults(): void {
if (!this.hasGCMMapping()) return;
const w = window as unknown as Record<string, unknown>;
const gtag = w.gtag as ((...args: unknown[]) => void) | undefined;
if (typeof gtag !== "function") return;
const defaults: Record<string, string> = {};
for (const cat of this.config.categories) {
if (!cat.gcm_consent_types) continue;
for (const gcmType of cat.gcm_consent_types) {
defaults[gcmType] = "denied";
}
}
if (Object.keys(defaults).length > 0) {
gtag("consent", "default", defaults);
}
}
private updateGoogleConsentMode(
consentData: Record<string, boolean>,
): void {
if (!this.hasGCMMapping()) return;
const w = window as unknown as Record<string, unknown>;
const gtag = w.gtag as ((...args: unknown[]) => void) | undefined;
if (typeof gtag !== "function") return;
const update: Record<string, string> = {};
for (const cat of this.config.categories) {
if (!cat.gcm_consent_types) continue;
const granted = !!consentData[cat.slug];
for (const gcmType of cat.gcm_consent_types) {
update[gcmType] = granted ? "granted" : "denied";
}
}
if (Object.keys(update).length > 0) {
gtag("consent", "update", update);
}
}
private startDetector(config: BannerConfig): void {
const knownNames = new Set<string>();
knownNames.add(COOKIE_NAME);

View File

@@ -0,0 +1,73 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import type { Category } from "../client";
import type { ConsentIntegration } from "./integration";
export class GoogleConsentModeIntegration implements ConsentIntegration {
private hasMapping(categories: Category[]): boolean {
return categories.some(
(cat) => cat.gcm_consent_types && cat.gcm_consent_types.length > 0,
);
}
private getGtag(): ((...args: unknown[]) => void) | null {
const w = window as unknown as Record<string, unknown>;
const gtag = w.gtag as ((...args: unknown[]) => void) | undefined;
if (typeof gtag !== "function") return null;
return gtag;
}
setDefaults(categories: Category[]): void {
if (!this.hasMapping(categories)) return;
const gtag = this.getGtag();
if (!gtag) return;
const defaults: Record<string, string> = {};
for (const cat of categories) {
if (!cat.gcm_consent_types) continue;
for (const gcmType of cat.gcm_consent_types) {
defaults[gcmType] = "denied";
}
}
if (Object.keys(defaults).length > 0) {
gtag("consent", "default", defaults);
}
}
update(
categories: Category[],
consentData: Record<string, boolean>,
): void {
if (!this.hasMapping(categories)) return;
const gtag = this.getGtag();
if (!gtag) return;
const update: Record<string, string> = {};
for (const cat of categories) {
if (!cat.gcm_consent_types) continue;
const granted = !!consentData[cat.slug];
for (const gcmType of cat.gcm_consent_types) {
update[gcmType] = granted ? "granted" : "denied";
}
}
if (Object.keys(update).length > 0) {
gtag("consent", "update", update);
}
}
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
export type { ConsentIntegration } from "./integration";
export { GoogleConsentModeIntegration } from "./gcm";
export { PostHogIntegration } from "./posthog";
import type { ConsentIntegration } from "./integration";
import { GoogleConsentModeIntegration } from "./gcm";
import { PostHogIntegration } from "./posthog";
export function createDefaultIntegrations(): ConsentIntegration[] {
return [
new GoogleConsentModeIntegration(),
new PostHogIntegration(),
];
}

View File

@@ -0,0 +1,23 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import type { Category } from "../client";
export interface ConsentIntegration {
/** Called once after config is loaded, before any consent is applied. */
setDefaults(categories: Category[]): void;
/** Called whenever consent changes (accept, reject, customize, GPC). */
update(categories: Category[], consentData: Record<string, boolean>): void;
}

View File

@@ -0,0 +1,69 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import type { Category } from "../client";
import type { ConsentIntegration } from "./integration";
interface PostHogInstance {
opt_in_capturing: () => void;
opt_out_capturing: () => void;
}
export class PostHogIntegration implements ConsentIntegration {
private hasMapping(categories: Category[]): boolean {
return categories.some((cat) => cat.posthog_consent);
}
private getPostHog(): PostHogInstance | null {
const w = window as unknown as Record<string, unknown>;
const posthog = w.posthog as (PostHogInstance & Record<string, unknown>) | undefined;
if (
!posthog ||
typeof posthog.opt_in_capturing !== "function" ||
typeof posthog.opt_out_capturing !== "function"
) {
return null;
}
return posthog;
}
setDefaults(categories: Category[]): void {
if (!this.hasMapping(categories)) return;
const posthog = this.getPostHog();
if (!posthog) return;
posthog.opt_out_capturing();
}
update(
categories: Category[],
consentData: Record<string, boolean>,
): void {
if (!this.hasMapping(categories)) return;
const posthog = this.getPostHog();
if (!posthog) return;
const granted = categories
.filter((cat) => cat.posthog_consent)
.every((cat) => !!consentData[cat.slug]);
if (granted) {
posthog.opt_in_capturing();
} else {
posthog.opt_out_capturing();
}
}
}

View File

@@ -74,6 +74,7 @@ type (
Slug *string
Description *string
GCMConsentTypes *[]string
PostHogConsent *bool
}
CreateCookieRequest struct {
@@ -360,6 +361,7 @@ func buildSnapshot(
Kind: c.Kind,
Cookies: cookies,
GCMConsentTypes: gcmConsentTypes,
PostHogConsent: c.PostHogConsent,
}
}
@@ -1347,6 +1349,9 @@ func (s *Service) UpdateCookieCategory(
if req.GCMConsentTypes != nil {
category.GCMConsentTypes = *req.GCMConsentTypes
}
if req.PostHogConsent != nil {
category.PostHogConsent = *req.PostHogConsent
}
category.UpdatedAt = time.Now()

View File

@@ -55,6 +55,7 @@ type (
Kind CookieCategoryKind `json:"kind"`
Cookies CookieItems `json:"cookies"`
GCMConsentTypes []string `json:"gcm_consent_types"`
PostHogConsent bool `json:"posthog_consent"`
}
CookieBannerVersion struct {

View File

@@ -48,6 +48,7 @@ type (
Kind CookieCategoryKind `db:"kind"`
Rank int `db:"rank"`
GCMConsentTypes []string `db:"gcm_consent_types"`
PostHogConsent bool `db:"posthog_consent"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
@@ -111,6 +112,7 @@ SELECT
kind,
rank,
gcm_consent_types,
posthog_consent,
created_at,
updated_at
FROM
@@ -162,6 +164,7 @@ SELECT
kind,
rank,
gcm_consent_types,
posthog_consent,
created_at,
updated_at
FROM
@@ -241,6 +244,7 @@ SELECT
kind,
rank,
gcm_consent_types,
posthog_consent,
created_at,
updated_at
FROM
@@ -289,6 +293,7 @@ INSERT INTO cookie_categories (
kind,
rank,
gcm_consent_types,
posthog_consent,
created_at,
updated_at
) VALUES (
@@ -302,6 +307,7 @@ INSERT INTO cookie_categories (
@kind,
@rank,
@gcm_consent_types,
@posthog_consent,
@created_at,
@updated_at
)
@@ -318,6 +324,7 @@ INSERT INTO cookie_categories (
"kind": c.Kind,
"rank": c.Rank,
"gcm_consent_types": c.GCMConsentTypes,
"posthog_consent": c.PostHogConsent,
"created_at": c.CreatedAt,
"updated_at": c.UpdatedAt,
}
@@ -347,6 +354,7 @@ SET
slug = @slug,
description = @description,
gcm_consent_types = @gcm_consent_types,
posthog_consent = @posthog_consent,
updated_at = @updated_at
WHERE
%s
@@ -361,6 +369,7 @@ WHERE
"slug": c.Slug,
"description": c.Description,
"gcm_consent_types": c.GCMConsentTypes,
"posthog_consent": c.PostHogConsent,
"updated_at": c.UpdatedAt,
}
maps.Copy(args, scope.SQLArguments())
@@ -477,6 +486,7 @@ SELECT
kind,
rank,
gcm_consent_types,
posthog_consent,
created_at,
updated_at
FROM

View File

@@ -0,0 +1,16 @@
-- Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
--
-- Permission to use, copy, modify, and/or distribute this software for any
-- purpose with or without fee is hereby granted, provided that the above
-- copyright notice and this permission notice appear in all copies.
--
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
-- PERFORMANCE OF THIS SOFTWARE.
ALTER TABLE cookie_categories ADD COLUMN posthog_consent BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE cookie_categories ALTER COLUMN posthog_consent DROP DEFAULT;

View File

@@ -472,6 +472,7 @@ func (r *mutationResolver) UpdateCookieCategory(ctx context.Context, input types
Slug: input.Slug,
Description: input.Description,
GCMConsentTypes: gcmConsentTypes,
PostHogConsent: input.PosthogConsent,
},
)
if err != nil {

View File

@@ -124,6 +124,7 @@ type CookieCategory implements Node {
kind: CookieCategoryKind!
rank: Int!
gcmConsentTypes: [String!]!
posthogConsent: Boolean!
cookies(
first: Int
@@ -310,6 +311,7 @@ input UpdateCookieCategoryInput {
slug: String
description: String
gcmConsentTypes: [String!]
posthogConsent: Boolean
}
input DeleteCookieCategoryInput {

View File

@@ -76,6 +76,7 @@ func NewCookieCategory(c *coredata.CookieCategory) *CookieCategory {
Kind: c.Kind,
Rank: c.Rank,
GcmConsentTypes: gcmConsentTypes,
PosthogConsent: c.PostHogConsent,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}