From 0a1e14a74d01f88f0ff2e4c16364a86ece13cdcd Mon Sep 17 00:00:00 2001 From: Ankur Datta <64993082+ankur-arch@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:44:41 +0200 Subject: [PATCH] fix(consent): gate PostHog analytics on CookieYes consent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PostHog initialised with capture_pageview and no consent gate, so it set cookies and fired a pageview on load before the visitor interacted with the CookieYes banner — analytics tracking before consent (GDPR/ePrivacy issue). Add a shared CookieYes consent helper (packages/ui/src/lib/consent.ts) that reads the same signals as the GTM consent bridge (cookieyes_consent_update / cookieyes_banner_load events + getCkyConsent()), and use it in all three apps (docs, site, blog) to init PostHog opted-out, opting in only once analytics consent is granted and reacting to live consent changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/blog/src/instrumentation-client.ts | 12 +++++ apps/docs/src/instrumentation-client.ts | 12 +++++ apps/site/src/instrumentation-client.ts | 12 +++++ packages/ui/src/lib/consent.ts | 61 +++++++++++++++++++++++++ 4 files changed, 97 insertions(+) create mode 100644 packages/ui/src/lib/consent.ts diff --git a/apps/blog/src/instrumentation-client.ts b/apps/blog/src/instrumentation-client.ts index be223868f3..0fd1318f7d 100644 --- a/apps/blog/src/instrumentation-client.ts +++ b/apps/blog/src/instrumentation-client.ts @@ -1,13 +1,25 @@ import posthog from "posthog-js"; +import { hasAnalyticsConsent, onAnalyticsConsentChange } from "@prisma-docs/ui/lib/consent"; posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, { api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST, capture_pageview: "history_change", defaults: "2025-11-30", + // GDPR/ePrivacy: do not set cookies or capture anything until the visitor + // grants analytics consent via CookieYes. Opt-in is handled below. + opt_out_capturing_by_default: true, loaded: (posthog) => { posthog.register({ site_name: "mono-blog", environment: "production", }); + // Returning visitor whose stored consent is already available at init. + if (hasAnalyticsConsent()) posthog.opt_in_capturing(); }, }); + +// React to live banner interactions and to CookieYes restoring stored consent. +onAnalyticsConsentChange((granted) => { + if (granted) posthog.opt_in_capturing(); + else posthog.opt_out_capturing(); +}); diff --git a/apps/docs/src/instrumentation-client.ts b/apps/docs/src/instrumentation-client.ts index 63958b191b..961c59623e 100644 --- a/apps/docs/src/instrumentation-client.ts +++ b/apps/docs/src/instrumentation-client.ts @@ -1,18 +1,30 @@ import posthog from "posthog-js"; import * as Sentry from "@sentry/nextjs"; +import { hasAnalyticsConsent, onAnalyticsConsentChange } from "@prisma-docs/ui/lib/consent"; posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, { api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST, capture_pageview: "history_change", defaults: "2025-11-30", + // GDPR/ePrivacy: do not set cookies or capture anything until the visitor + // grants analytics consent via CookieYes. Opt-in is handled below. + opt_out_capturing_by_default: true, loaded: (posthog) => { posthog.register({ site_name: "mono-docs", environment: "production", }); + // Returning visitor whose stored consent is already available at init. + if (hasAnalyticsConsent()) posthog.opt_in_capturing(); }, }); +// React to live banner interactions and to CookieYes restoring stored consent. +onAnalyticsConsentChange((granted) => { + if (granted) posthog.opt_in_capturing(); + else posthog.opt_out_capturing(); +}); + Sentry.init({ dsn: "https://e83ce4699e59051fdeaa330bf4a0dfb9@o4510879743737856.ingest.us.sentry.io/4510879744000000", diff --git a/apps/site/src/instrumentation-client.ts b/apps/site/src/instrumentation-client.ts index 4f59745388..183d314ede 100644 --- a/apps/site/src/instrumentation-client.ts +++ b/apps/site/src/instrumentation-client.ts @@ -1,13 +1,25 @@ import posthog from "posthog-js"; +import { hasAnalyticsConsent, onAnalyticsConsentChange } from "@prisma-docs/ui/lib/consent"; posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, { api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST, capture_pageview: "history_change", defaults: "2025-11-30", + // GDPR/ePrivacy: do not set cookies or capture anything until the visitor + // grants analytics consent via CookieYes. Opt-in is handled below. + opt_out_capturing_by_default: true, loaded: (posthog) => { posthog.register({ site_name: "mono-site", environment: "production", }); + // Returning visitor whose stored consent is already available at init. + if (hasAnalyticsConsent()) posthog.opt_in_capturing(); }, }); + +// React to live banner interactions and to CookieYes restoring stored consent. +onAnalyticsConsentChange((granted) => { + if (granted) posthog.opt_in_capturing(); + else posthog.opt_out_capturing(); +}); diff --git a/packages/ui/src/lib/consent.ts b/packages/ui/src/lib/consent.ts new file mode 100644 index 0000000000..a92e3a4168 --- /dev/null +++ b/packages/ui/src/lib/consent.ts @@ -0,0 +1,61 @@ +/** + * CookieYes analytics-consent helpers. + * + * These read the exact same signals the GTM consent bridge already uses + * (see `components/google-tag-manager.tsx`): the `cookieyes_consent_update` + * and `cookieyes_banner_load` events, and the `getCkyConsent()` global. + * Using one source of truth keeps every analytics SDK gated consistently. + * + * GDPR/ePrivacy note: analytics SDKs must not set cookies or send data until + * the visitor grants analytics consent. Callers should start opted-out and + * only opt in from these helpers. + */ + +/** CookieYes category key for analytics cookies. */ +const ANALYTICS_CATEGORY = "analytics"; + +type CkyConsent = { categories?: Record }; + +declare global { + interface Window { + getCkyConsent?: () => CkyConsent; + } +} + +/** + * True when CookieYes has a stored decision granting analytics consent. + * + * Returns false during SSR, before CookieYes has loaded, or when the visitor + * has not (yet) accepted analytics — i.e. the safe default is "no consent". + */ +export function hasAnalyticsConsent(): boolean { + if (typeof window === "undefined") return false; + try { + return Boolean(window.getCkyConsent?.().categories?.[ANALYTICS_CATEGORY]); + } catch { + return false; + } +} + +/** + * Invokes `onChange(granted)` whenever analytics consent changes. + * + * - Fires on `cookieyes_consent_update` when the visitor accepts/rejects from + * the banner. + * - Fires on `cookieyes_banner_load` so returning visitors who previously + * consented are opted in once CookieYes restores their stored decision. + * + * Safe no-op during SSR. + */ +export function onAnalyticsConsentChange(onChange: (granted: boolean) => void): void { + if (typeof document === "undefined") return; + + document.addEventListener("cookieyes_consent_update", (event) => { + const accepted = (event as CustomEvent<{ accepted?: string[] }>).detail?.accepted ?? []; + onChange(accepted.includes(ANALYTICS_CATEGORY)); + }); + + document.addEventListener("cookieyes_banner_load", () => { + onChange(hasAnalyticsConsent()); + }); +}