feat(tracking): CMS-configurable analytics and cookie consent - #149
feat(tracking): CMS-configurable analytics and cookie consent#149arnespremberg wants to merge 11 commits into
Conversation
…egration Add siteAnalyticsSettings singleton for Google Analytics, Matomo, Microsoft Clarity, PostHog, and Plausible with per-provider cookie-free modes and cookie-banner labels. Integrate with the existing vanilla-cookieconsent banner: cookie-based trackers wait for analytics consent; cookie-free trackers load immediately when respecting the banner.
✅ Deploy Preview for bef-next-sanity-starter ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
There was a problem hiding this comment.
Pull request overview
Adds CMS-configurable analytics providers, consent gating, disclosure, cache revalidation, and deployment CSP support.
Changes:
- Adds Studio schemas and singleton configuration for five analytics providers.
- Loads trackers according to CMS settings and cookie consent.
- Integrates analytics settings with the global layout, cookie dialog, and revalidation.
Reviewed changes
Copilot reviewed 28 out of 28 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
web/src/components/tracking/SiteTrackingShell.tsx |
Fetches tracking configuration. |
web/src/components/tracking/SiteTracking.tsx |
Applies consent-based loading. |
web/src/components/tracking/lib/trackingRuntime.ts |
Manages tracker runtime state. |
web/src/components/tracking/lib/trackingConfig.ts |
Filters and classifies trackers. |
web/src/components/tracking/lib/loadTrackers.ts |
Loads provider scripts. |
web/src/components/tracking/lib/defaultCookieSections.ts |
Defines fallback consent sections. |
web/src/components/tracking/lib/cookieConsentConfig.ts |
Adds trackers to consent disclosures. |
web/src/components/cookies/cookieConsentApi.ts |
Publishes consent changes. |
web/src/components/cookies/CookieConsent.tsx |
Connects trackers to the banner. |
web/src/app/api/revalidate/route.ts |
Revalidates analytics settings. |
web/src/app/[locale]/layout.tsx |
Mounts tracking globally. |
web/sanity/types/siteAnalyticsSettings.ts |
Defines analytics data types. |
web/sanity/types/index.ts |
Exports the settings type. |
web/sanity/queries/snippets/settings.ts |
Queries analytics configuration. |
web/sanity/queries/index.ts |
Exports the analytics query. |
web/sanity/fetchSanityData.ts |
Fetches cached analytics settings. |
web/sanity/cachedSanityQuery.ts |
Adds the analytics cache tag. |
studio/schemas/settings/siteAnalyticsSettings.ts |
Defines the analytics singleton. |
studio/schemas/objects/analytics/trackerPostHog.ts |
Defines PostHog settings. |
studio/schemas/objects/analytics/trackerPlausible.ts |
Defines Plausible settings. |
studio/schemas/objects/analytics/trackerMicrosoftClarity.ts |
Defines Clarity settings. |
studio/schemas/objects/analytics/trackerMatomo.ts |
Defines Matomo settings. |
studio/schemas/objects/analytics/trackerGoogleAnalytics.ts |
Defines GA4 settings. |
studio/schemas/index.ts |
Registers analytics schemas. |
studio/config/structure/items/siteAnalyticsSettings.ts |
Adds the Studio structure item. |
studio/config/structure/index.ts |
Registers the structure item. |
studio/config/singletons.ts |
Registers singleton behavior. |
netlify.toml |
Expands analytics CSP origins. |
Suppressed comments (1)
web/src/components/tracking/SiteTracking.tsx:54
- The consent-change callback is used for both grants and revocations, but
loadAllowed(false)only computes an empty pending set; it never unloads trackers that were loaded after a prior grant. Revoking analytics therefore leaves GA, Matomo, Clarity, and PostHog running. Handle the false transition by unloading every consent-required tracker and clearing its loaded key, with loader-specific re-enable behavior for a later grant.
const loadAllowed = (analyticsAccepted: boolean) => {
const allowed = trackersForConsent(trackers, config, analyticsAccepted);
| config.cookieBanner?.useCookieBanner === true && | ||
| config.analytics?.loadMode === "respectCookieBanner"; | ||
|
|
||
| useEffect(() => { |
| const raw = doc.preferencesModal?.sections; | ||
| if (typeof raw === "string" && raw.trim().length > 0) { | ||
| try { | ||
| JSON.parse(raw); | ||
| return buildCookieSections(doc, trackers); |
| const scriptHost = apiHost.replace(/\/$/, ""); | ||
| const script = appendScript(`${scriptHost}/static/array.js`); |
| }; | ||
| } | ||
| ).posthog; | ||
| if (posthog?.init) { |
| return sections.map((section) => { | ||
| const sectionId = (section as Section & { id?: string }).id; | ||
| if (section.linkedCategory !== "analytics" && sectionId !== "analytics") { |
| export const siteAnalyticsSettings = defineType({ | ||
| name: "siteAnalyticsSettings", | ||
| type: "document", |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 4 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for all 4 issues found in the latest run.
- ✅ Fixed: Consent revoke leaves trackers running
- SiteTracking now calls unloadTrackers on consent revoke and removes keys from consentLoaded so trackers can be re-loaded on re-accept.
- ✅ Fixed: SPA navigations skip pageviews
- Added a pathname/searchParams effect that calls trackPageView on client-side route changes after the initial load.
- ✅ Fixed: CMS sections never reach builder
- parseSections now uses the exported sectionsJsonFromSanity helper to read both string and Sanity code-object section fields.
- ✅ Fixed: PostHog init can run twice
- tryInit now returns early when isTrackerLoaded is true, preventing duplicate posthog.init calls from overlapping retries.
Or push these changes by commenting:
@cursor push 217275a946
Preview (217275a946)
diff --git a/web/src/components/cookies/CookieConsent.tsx b/web/src/components/cookies/CookieConsent.tsx
--- a/web/src/components/cookies/CookieConsent.tsx
+++ b/web/src/components/cookies/CookieConsent.tsx
@@ -5,7 +5,10 @@
import type { AnalyticsTracker } from "@/sanity/types/siteAnalyticsSettings";
import type { SiteCookieBannerDocument } from "@/sanity/types/siteCookieBanner";
-import { buildCookieSections } from "@/src/components/tracking/lib/cookieConsentConfig";
+import {
+ buildCookieSections,
+ sectionsJsonFromSanity,
+} from "@/src/components/tracking/lib/cookieConsentConfig";
import { notifyAnalyticsConsentChange } from "./cookieConsentApi";
import { defaultSectionsFor } from "./defaultSections";
@@ -20,10 +23,10 @@
locale: string,
trackers: AnalyticsTracker[],
): CookieConsent.Section[] {
- const raw = doc.preferencesModal?.sections;
- if (typeof raw === "string" && raw.trim().length > 0) {
+ const sectionsJson = sectionsJsonFromSanity(doc.preferencesModal?.sections);
+ if (sectionsJson) {
try {
- JSON.parse(raw);
+ JSON.parse(sectionsJson);
return buildCookieSections(doc, trackers);
} catch (err) {
console.warn("[CookieConsent] Failed to parse `sections` JSON:", err);
diff --git a/web/src/components/tracking/SiteTracking.tsx b/web/src/components/tracking/SiteTracking.tsx
--- a/web/src/components/tracking/SiteTracking.tsx
+++ b/web/src/components/tracking/SiteTracking.tsx
@@ -1,5 +1,6 @@
"use client";
+import { usePathname, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useRef } from "react";
import type { AnalyticsTracker } from "@/sanity/types/siteAnalyticsSettings";
@@ -13,6 +14,10 @@
type TrackingConfig,
trackerRequiresConsent,
} from "@/src/components/tracking/lib/trackingConfig";
+import {
+ trackPageView,
+ unloadTrackers,
+} from "@/src/components/tracking/lib/trackingRuntime";
type Props = {
config: TrackingConfig;
@@ -36,6 +41,9 @@
export function SiteTracking({ config }: Props) {
const initialized = useRef(false);
const consentLoaded = useRef(new Set<string>());
+ const skipNextPageView = useRef(true);
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
const trackers = useMemo(
() => getEnabledTrackers(config.analytics),
@@ -50,8 +58,22 @@
if (initialized.current || trackers.length === 0) return;
initialized.current = true;
- const loadAllowed = (analyticsAccepted: boolean) => {
+ const syncTrackers = (analyticsAccepted: boolean) => {
const allowed = trackersForConsent(trackers, config, analyticsAccepted);
+ const allowedKeys = new Set(allowed.map((tracker) => tracker._key));
+
+ const toUnload = trackers.filter(
+ (tracker) =>
+ consentLoaded.current.has(tracker._key) &&
+ !allowedKeys.has(tracker._key),
+ );
+ if (toUnload.length > 0) {
+ unloadTrackers(toUnload);
+ for (const tracker of toUnload) {
+ consentLoaded.current.delete(tracker._key);
+ }
+ }
+
const pending = allowed.filter(
(tracker) => !consentLoaded.current.has(tracker._key),
);
@@ -63,12 +85,21 @@
};
if (showBanner) {
- loadAllowed(hasConsent("analytics"));
- return subscribeAnalyticsConsent(loadAllowed);
+ syncTrackers(hasConsent("analytics"));
+ return subscribeAnalyticsConsent(syncTrackers);
}
- loadAllowed(true);
+ syncTrackers(true);
}, [config, showBanner, trackers]);
+ useEffect(() => {
+ if (skipNextPageView.current) {
+ skipNextPageView.current = false;
+ return;
+ }
+ const search = searchParams.toString();
+ trackPageView(pathname, search ? `?${search}` : "");
+ }, [pathname, searchParams]);
+
return null;
}
diff --git a/web/src/components/tracking/lib/cookieConsentConfig.ts b/web/src/components/tracking/lib/cookieConsentConfig.ts
--- a/web/src/components/tracking/lib/cookieConsentConfig.ts
+++ b/web/src/components/tracking/lib/cookieConsentConfig.ts
@@ -9,7 +9,7 @@
const DEFAULT_CONSENT_DESCRIPTION =
"Our website uses essential cookies for basic operation. Cookie-based analytics run only after you accept them; privacy-friendly analytics without cookies may load earlier.";
-function sectionsJsonFromSanity(
+export function sectionsJsonFromSanity(
sections: string | { code?: string | null } | null | undefined,
): string | null {
if (typeof sections === "string") return sections;
diff --git a/web/src/components/tracking/lib/loadTrackers.ts b/web/src/components/tracking/lib/loadTrackers.ts
--- a/web/src/components/tracking/lib/loadTrackers.ts
+++ b/web/src/components/tracking/lib/loadTrackers.ts
@@ -97,6 +97,7 @@
let attempts = 0;
const tryInit = () => {
+ if (isTrackerLoaded(tracker._key)) return;
attempts += 1;
const posthog = (
window as Window & {You can send follow-ups to the cloud agent here.
`studio/schema.json` and `studio/sanity.types.gen.ts` are tracked and CI gates them with `git diff --exit-code` after running typegen. The previous commit added the analytics document and five tracker objects without regenerating either, so the schema-typegen job failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An editor choosing providers in the Studio cannot see which of them sends personal data before the visitor answers the banner, which needs an agreement signed elsewhere, or which one records what visitors type. Put that at the point of the decision instead of in a document nobody opens. Field-level notes: - Each tracker gains a type description covering its specific risk — Clarity's session recordings and the DPIA question, PostHog's replay being configured outside the CMS, GA's US transfer, Matomo being self-hosted and lowest-risk, Plausible's server-side IP hashing. - "Cookie-free" is explained as not meaning consent-free: it removes the ePrivacy cookie trigger, not the GDPR basis for the data still sent. - GA's cookie-free note records that `anonymize_ip` is a no-op in GA4 (a Universal Analytics setting) so the flag is not read as a safeguard. - Clarity's cookie-free toggle explains why it is locked off, and its `enabled` field documents that consent withdrawal does not tear down the already-loaded script until the visitor reloads. - The generated cookie table's limits are noted on the sections field: provider domains rather than the real cookie host, and no durations. Warnings where the unsafe state is invisible from one document: - Selecting "Load on page load" with trackers enabled. - Enabling trackers while the cookie banner is off, and the same warning from the banner side — the two halves live in separate singletons, so either document alone looks fine. - A consent description with no link to a privacy policy. The reject-button labels become required: vanilla-cookieconsent only renders that button when the label is non-empty, so clearing the field silently leaves Accept as the only option. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Companion to the studio typegen fix: `web/sanity/sanity.types.gen.ts` is tracked and gated the same way, and `siteAnalyticsSettingsQuery` was added without regenerating it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 33 out of 33 changed files in this pull request and generated 2 comments.
Suppressed comments (3)
web/src/components/tracking/lib/cookieConsentConfig.ts:86
- Tracker rows are appended only when the editor-provided JSON already contains an analytics section. Because
sectionsis freely editable, removing or renaming that section means enabled providers are not disclosed and noanalyticscategory is created for users to accept, so cookie-based trackers can never load. Add the fallback analytics section whenever trackers exist and the configured sections omit it.
return sections.map((section) => {
const sectionId = (section as Section & { id?: string }).id;
if (section.linkedCategory !== "analytics" && sectionId !== "analytics") {
return section;
web/src/components/tracking/lib/trackingRuntime.ts:21
- This is the only definition of
trackPageView; it is never invoked, so Next.js client-side route changes do not emit pageviews for these providers despite the PR's SPA-navigation claim. Wire it to pathname/search changes in the client tracking component, taking care not to duplicate the initial pageview already emitted by provider initialization.
web/sanity/queries/snippets/settings.ts:134 cookieBannerLabelandcookieBannerDescriptionare visitor-facing copy, but they are projected as scalar strings for every provider. Every locale route therefore displays the same disclosure, unlike localized visitor-facing labels such asstudio/schemas/objects/link.ts:35. Model and project these fields as internationalized arrays, then resolve them with the active locale and site fallback before building the cookie rows.
cookieBannerLabel,
cookieBannerDescription,
| if (showBanner) { | ||
| loadAllowed(hasConsent("analytics")); | ||
| return subscribeAnalyticsConsent(loadAllowed); |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 33 out of 33 changed files in this pull request and generated no new comments.
Suppressed comments (5)
web/src/components/tracking/SiteTracking.tsx:54
- Revoking analytics consent only calls this loader with
false; it never unloads trackers that were loaded after a previous acceptance. Those providers therefore continue collecting after withdrawal, and the newunloadTrackerspath is never called anywhere. On rejection, unload every consent-required tracker, clear its loaded key so re-consent can work, and cancel any pending asynchronous initialization.
const loadAllowed = (analyticsAccepted: boolean) => {
const allowed = trackersForConsent(trackers, config, analyticsAccepted);
web/src/components/tracking/SiteTracking.tsx:50
- This effect only reacts to configuration, so it cannot report App Router navigations.
trackPageViewhas no caller anywhere in the repository, which contradicts the PR's claim that SPA navigations are tracked and leaves at least Matomo client-side route changes unreported. Add a pathname/search-aware effect that invokes the runtime after navigation without duplicating the providers' initial page view.
useEffect(() => {
if (initialized.current || trackers.length === 0) return;
web/src/components/cookies/CookieConsent.tsx:24
- Sanity
codefields arrive as aCodeobject containingcode(as shown by the generated query type), not as a raw string. This check therefore rejects every CMS-authored sections value and replaces it with defaults, even thoughbuildCookieSectionsalready knows how to unwrap the object. Update the hand-maintained banner type and validate the nestedcodestring so configured sections are retained.
const raw = doc.preferencesModal?.sections;
if (typeof raw === "string" && raw.trim().length > 0) {
web/src/components/tracking/lib/loadTrackers.ts:100
tryInitruns both from the polling timer and the script'sloadevent. In the normal asynchronous-load path, the load handler initializes PostHog, then the already-scheduled timer initializes it again, potentially installing duplicate capture handlers. Stop immediately once this tracker has been registered.
web/src/components/tracking/lib/cookieConsentConfig.ts:37- These CMS-authored labels and descriptions are rendered directly as scalar strings, so every locale receives the same provider disclosure. Visitor-facing Sanity content elsewhere uses localized arrays and render-time resolution (for example,
studio/schemas/settings/error.ts:21andweb/src/components/modules/ModuleCarousel.tsx:40). Project localized values and resolve them with the current locale/site-locale configuration before building rows.
function trackerCookieRow(tracker: AnalyticsTracker): CookieTableRow {
const label = tracker.cookieBannerLabel?.trim() || tracker._type;
const baseDescription =
tracker.cookieBannerDescription?.trim() ||
"Analytics provider configured in Sanity.";
…tHog Consent withdrawal did not stop collection. The consent callback only ever computed which trackers to *add*, so revoking analytics left GA, Matomo, Clarity, and PostHog running until a reload — consent has to be as easy to withdraw as to give. `syncConsent` now unloads anything running that consent no longer covers and clears its key so a later grant reloads it. Dropped the `initialized` one-shot ref. The effect owns the consent subscription, so returning early on re-run (Strict Mode remount, or any `config` change) unsubscribed permanently and consent never reached the loaders again. `consentLoaded` already makes loads idempotent. `trackPageView` was dead code — nothing observed route changes, so App Router navigations emitted no pageviews. Wired to `pathname`, skipping the first run because providers emit their own view on init. PostHog: the SDK is served from PostHog Cloud's assets host, not the ingest host, so the default EU config never loaded `/static/array.js`. Rewrites the two known cloud hosts and leaves self-hosted URLs alone. Also guards `tryInit` with the runtime registry — it ran immediately, on script load, and on a retry timer, so a pending timer could re-init after the load handler had already succeeded. Cookie sections: the query returned `preferencesModal` whole, so `sections` arrived as the Studio `code` object while the type claimed `string` — every CMS-authored section list fell through to the locale defaults. Projects `sections.code` and normalises in `parseSections` as well. Separately, rows were only appended when the authored JSON already contained an analytics section; if an editor renamed or removed it there was no disclosure *and* no `analytics` category to grant, so gated trackers could never load. One is now synthesised when trackers exist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first pass read like privacy consulting. Editors need the risk named, not the reasoning: cut the how and why, keep what they cannot see from the CMS — what loads before consent, what leaves the EEA, what needs signing elsewhere. Validation messages shortened to one clause each. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 33 out of 33 changed files in this pull request and generated 3 comments.
Suppressed comments (4)
web/src/components/tracking/SiteTracking.tsx:106
- This effect misses client-side navigations that change only the query string because
usePathname()remains unchanged. Although the call readswindow.location.search, the effect never reruns, so those SPA page views are not forwarded. Subscribe touseSearchParams()(or an equivalent route-change signal) and include the serialized search value in the dependency list.
trackPageView(pathname, window.location.search);
}, [pathname]);
web/src/components/tracking/SiteTracking.tsx:73
- Deleting only the local key allows the loader to run again after re-consent, but provider opt-out state remains. In particular, Google is left with
ga-disable-<id> = trueand Matomo is left opted out, while neither loader clears those states, so accepting analytics again does not resume collection. Add provider-specific re-enable logic (ga-disablereset, MatomoforgetUserOptOut, and corresponding opt-in handling for other providers) before marking them loaded again.
if (revoked.length > 0) {
unloadTrackers(revoked);
for (const tracker of revoked) {
consentLoaded.current.delete(tracker._key);
}
studio/schemas/settings/siteAnalyticsSettings.ts:100
- The schema says “one entry per provider,” but this array permits duplicate
_typevalues. Several loaders share one provider-global (_paq,window.posthog,window.clarity), so duplicate entries can overwrite configuration or double-count page views. Reject duplicate provider types in array validation.
of: [
defineArrayMember({ type: "trackerGoogleAnalytics" }),
defineArrayMember({ type: "trackerMatomo" }),
defineArrayMember({ type: "trackerMicrosoftClarity" }),
defineArrayMember({ type: "trackerPostHog" }),
defineArrayMember({ type: "trackerPlausible" }),
web/sanity/types/siteAnalyticsSettings.ts:16
- These values are rendered in the locale-specific cookie preferences UI, but the shared contract stores only one plain string, so every locale receives identical provider copy. Model the fields with the repository's internationalized-array types, project the full arrays, and resolve them with the locale/site-locale utilities before building tracker rows.
cookieBannerLabel?: string | null;
cookieBannerDescription?: string | null;
| const showBanner = | ||
| config.cookieBanner?.useCookieBanner === true && | ||
| config.analytics?.loadMode === "respectCookieBanner"; |
| if (attempts < POSTHOG_INIT_MAX_ATTEMPTS) { | ||
| window.setTimeout(tryInit, 50); |
| if (clarity) { | ||
| clarity.q = []; | ||
| ( | ||
| window as Window & { clarity?: (...args: unknown[]) => void } | ||
| ).clarity = () => {}; |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Consent re-grant fails after unload
- Added reEnableTracker to clear GA/Matomo/PostHog opt-out flags and updated loaders to call it when the SDK is already present instead of appending duplicate scripts.
- ✅ Fixed: PostHog can reload after revoke
- Unload now bumps a per-tracker load generation that in-flight PostHog tryInit callbacks check before initializing, aborting stale retries after consent revocation.
Or push these changes by commenting:
@cursor push 4ae368c5cd
Preview (4ae368c5cd)
diff --git a/web/src/components/tracking/lib/loadTrackers.ts b/web/src/components/tracking/lib/loadTrackers.ts
--- a/web/src/components/tracking/lib/loadTrackers.ts
+++ b/web/src/components/tracking/lib/loadTrackers.ts
@@ -1,6 +1,8 @@
import type { AnalyticsTracker } from "@/sanity/types/siteAnalyticsSettings";
import {
+ getTrackerLoadGeneration,
isTrackerLoaded,
+ reEnableTracker,
registerLoadedTracker,
} from "@/src/components/tracking/lib/trackingRuntime";
@@ -25,6 +27,11 @@
const id = tracker.measurementId?.trim();
if (!id || isTrackerLoaded(tracker._key)) return;
+ if ((window as Window & { gtag?: (...args: unknown[]) => void }).gtag) {
+ reEnableTracker(tracker);
+ return;
+ }
+
appendScript(
`https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}`,
);
@@ -47,6 +54,11 @@
const siteId = tracker.siteId?.trim();
if (!baseUrl || !siteId || isTrackerLoaded(tracker._key)) return;
+ if ((window as Window & { _paq?: unknown[][] })._paq) {
+ reEnableTracker(tracker);
+ return;
+ }
+
const normalizedUrl = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
const cookieFree = tracker.cookieFree === true;
@@ -106,11 +118,23 @@
const apiHost = tracker.apiHost?.trim();
if (!apiKey || !apiHost || isTrackerLoaded(tracker._key)) return;
+ const existingPostHog = (
+ window as Window & {
+ posthog?: { init?: (...args: unknown[]) => void };
+ }
+ ).posthog;
+ if (existingPostHog?.init) {
+ reEnableTracker(tracker);
+ return;
+ }
+
const cookieFree = tracker.cookieFree === true;
+ const generation = getTrackerLoadGeneration(tracker._key);
const script = appendScript(`${posthogAssetHost(apiHost)}/static/array.js`);
let attempts = 0;
const tryInit = () => {
+ if (getTrackerLoadGeneration(tracker._key) !== generation) return;
// tryInit runs immediately, on script load, and on a retry timer — without
// this the pending timer re-initialises after the load handler succeeded.
if (isTrackerLoaded(tracker._key)) return;
@@ -151,6 +175,13 @@
const scriptUrl = tracker.scriptUrl?.trim();
if (!domain || !scriptUrl || isTrackerLoaded(tracker._key)) return;
+ if (
+ (window as Window & { plausible?: (...args: unknown[]) => void }).plausible
+ ) {
+ reEnableTracker(tracker);
+ return;
+ }
+
appendScript(scriptUrl, {
defer: "",
"data-domain": domain,
diff --git a/web/src/components/tracking/lib/trackingRuntime.ts b/web/src/components/tracking/lib/trackingRuntime.ts
--- a/web/src/components/tracking/lib/trackingRuntime.ts
+++ b/web/src/components/tracking/lib/trackingRuntime.ts
@@ -5,11 +5,20 @@
};
const loadedTrackers = new Map<string, LoadedTracker>();
+const trackerLoadGenerations = new Map<string, number>();
export function isTrackerLoaded(key: string): boolean {
return loadedTrackers.has(key);
}
+export function getTrackerLoadGeneration(key: string): number {
+ return trackerLoadGenerations.get(key) ?? 0;
+}
+
+export function invalidateTrackerLoad(key: string): void {
+ trackerLoadGenerations.set(key, (trackerLoadGenerations.get(key) ?? 0) + 1);
+}
+
export function registerLoadedTracker(tracker: AnalyticsTracker): void {
loadedTrackers.set(tracker._key, { tracker });
}
@@ -71,7 +80,44 @@
}
}
+export function reEnableTracker(tracker: AnalyticsTracker): void {
+ switch (tracker._type) {
+ case "trackerGoogleAnalytics": {
+ const id = tracker.measurementId?.trim();
+ if (id) {
+ (window as unknown as Record<string, boolean | undefined>)[
+ `ga-disable-${id}`
+ ] = false;
+ }
+ break;
+ }
+ case "trackerMatomo": {
+ const _paq = (
+ window as Window & {
+ _paq?: unknown[][];
+ }
+ )._paq;
+ _paq?.push(["forgetUserOptOut"]);
+ break;
+ }
+ case "trackerPostHog": {
+ const posthog = (
+ window as Window & {
+ posthog?: { opt_in_capturing?: () => void };
+ }
+ ).posthog;
+ posthog?.opt_in_capturing?.();
+ break;
+ }
+ default:
+ break;
+ }
+
+ registerLoadedTracker(tracker);
+}
+
export function unloadTracker(tracker: AnalyticsTracker): void {
+ invalidateTrackerLoad(tracker._key);
switch (tracker._type) {
case "trackerGoogleAnalytics": {
const id = tracker.measurementId?.trim();You can send follow-ups to the cloud agent here.
Four fail-open paths, all of which loaded or kept running a tracker the visitor had not agreed to: - Clarity could load before consent. `isCookieFreeTracker` trusted the stored `cookieFree` flag, and Studio `readOnly` is a UI affordance rather than a data invariant — an imported or API-written document could set it true. Clarity is now hard-coded as never cookie-free. - A document missing `loadMode` failed open. `showBanner` compared for equality with "respectCookieBanner" while `shouldRespectCookieBanner` treats everything except "onPageLoad" as gated, so legacy or API-written content skipped the banner and loaded cookie trackers. Both now derive from the same helper. - Withdrawal during PostHog's async init did nothing. `unloadTracker` found no instance yet, then the pending timer initialised it anyway. Loads now carry a generation that `unregisterLoadedTracker` invalidates. - Re-granting consent produced a dead tracker. `ga-disable-<id>`, `optUserOut`, and `opt_out_capturing` all outlive the script, so a reloaded provider stayed silently opted out. Each loader now clears its own opt-out. Also: withdrawal deletes the first-party cookies each provider leaves behind (GA `_ga*`/`_gac_*`, Clarity `_clck`/`_clsk`, PostHog `ph_*`), and revoking Clarity reloads the page — its recorder holds its own listeners, so replacing the global never stopped collection, only new API calls. No loop risk: consent is already withdrawn when the page comes back. Schema copy notes the CSP entry self-hosted PostHog and Plausible need, and Clarity's field now describes the reload instead of the old limitation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 33 out of 33 changed files in this pull request and generated 2 comments.
Suppressed comments (1)
web/src/components/tracking/SiteTracking.tsx:112
- Search-only App Router navigations are not tracked because this effect depends only on
pathname. For example, navigating from/search?q=ato/search?q=bleavespathnameunchanged, so no provider receives the new page view. Subscribe withuseSearchParams()and include the serialized search string in the effect dependencies.
trackPageView(pathname, window.location.search);
}, [pathname]);
| const showBanner = bannerGatesLoading(config); | ||
|
|
||
| useEffect(() => { | ||
| if (trackers.length === 0) return; |
| const { hostname } = window.location; | ||
| const scopes = ["", `; domain=${hostname}`, `; domain=.${hostname}`]; |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Cookie deletion skips parent domains
- Added a cookieDomainScopes helper that deletes cookies across the host and all plausible parent domain suffixes, not just the current hostname.
- ✅ Fixed: Re-grant reinjects tracker scripts
- GA, Matomo, and PostHog loaders now detect existing vendor globals after unload and only clear opt-out state instead of re-appending scripts.
Or push these changes by commenting:
@cursor push 0781ba1a3a
Preview (0781ba1a3a)
diff --git a/web/src/components/tracking/lib/loadTrackers.ts b/web/src/components/tracking/lib/loadTrackers.ts
--- a/web/src/components/tracking/lib/loadTrackers.ts
+++ b/web/src/components/tracking/lib/loadTrackers.ts
@@ -33,6 +33,16 @@
`ga-disable-${id}`
] = false;
+ const gtag = (
+ window as Window & {
+ gtag?: (...args: unknown[]) => void;
+ }
+ ).gtag;
+ if (gtag) {
+ registerLoadedTracker(tracker);
+ return;
+ }
+
appendScript(
`https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}`,
);
@@ -58,6 +68,18 @@
const normalizedUrl = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
const cookieFree = tracker.cookieFree === true;
+ const _paq = (
+ window as Window & {
+ _paq?: unknown[][];
+ }
+ )._paq;
+ if (_paq) {
+ _paq.push(["forgetUserOptOut"]);
+ if (cookieFree) _paq.push(["disableCookies"]);
+ registerLoadedTracker(tracker);
+ return;
+ }
+
const inline = document.createElement("script");
inline.textContent = `
var _paq = window._paq = window._paq || [];
@@ -116,6 +138,21 @@
if (!apiKey || !apiHost || isTrackerLoaded(tracker._key)) return;
const cookieFree = tracker.cookieFree === true;
+
+ const existingPostHog = (
+ window as Window & {
+ posthog?: {
+ capture?: (event: string) => void;
+ opt_in_capturing?: () => void;
+ };
+ }
+ ).posthog;
+ if (existingPostHog?.capture) {
+ existingPostHog.opt_in_capturing?.();
+ registerLoadedTracker(tracker);
+ return;
+ }
+
const generation = beginTrackerLoad(tracker._key);
const script = appendScript(`${posthogAssetHost(apiHost)}/static/array.js`);
diff --git a/web/src/components/tracking/lib/trackingRuntime.ts b/web/src/components/tracking/lib/trackingRuntime.ts
--- a/web/src/components/tracking/lib/trackingRuntime.ts
+++ b/web/src/components/tracking/lib/trackingRuntime.ts
@@ -95,14 +95,31 @@
}
}
+function cookieDomainScopes(hostname: string): string[] {
+ const scopes = new Set(["", `; domain=${hostname}`, `; domain=.${hostname}`]);
+ if (
+ hostname !== "localhost" &&
+ !hostname.includes(":") &&
+ !/^\d+\.\d+\.\d+\.\d+$/.test(hostname)
+ ) {
+ const parts = hostname.split(".");
+ for (let index = 1; index < parts.length; index += 1) {
+ const parent = parts.slice(index).join(".");
+ if (!parent.includes(".")) continue;
+ scopes.add(`; domain=${parent}`);
+ scopes.add(`; domain=.${parent}`);
+ }
+ }
+ return [...scopes];
+}
+
/**
* Best-effort cookie removal. The same name can exist on both the host and the
* dot-prefixed domain, and we cannot know which the provider used, so clear
* every plausible scope.
*/
function deleteCookies(matches: (name: string) => boolean): void {
- const { hostname } = window.location;
- const scopes = ["", `; domain=${hostname}`, `; domain=.${hostname}`];
+ const scopes = cookieDomainScopes(window.location.hostname);
for (const entry of document.cookie.split(";")) {
const name = entry.split("=")[0]?.trim();
if (!name || !matches(name)) continue;You can send follow-ups to the cloud agent here.
The schema seeded "Tracking is only activated after consent", but cookie-free providers load before the visitor answers — and the code's own DEFAULT_CONSENT_DESCRIPTION already said so. A banner that overstates the gating is worse than no banner, so the two now match. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 33 out of 33 changed files in this pull request and generated 2 comments.
Suppressed comments (4)
web/src/components/tracking/lib/trackingRuntime.ts:105
- On a subdomain such as
www.example.com, GA commonly writes cookies for.example.com. These scopes only targetwww.example.com, so consent withdrawal leaves the parent-domain GA, Clarity, and PostHog cookies intact. Generate deletion scopes for each parent domain as well.
studio/schemas/settings/siteAnalyticsSettings.ts:100 - The array permits multiple entries with the same
_type, despite the field contract saying one entry per provider. Because loading is keyed by each Sanity_key, duplicate GA/Matomo/etc. entries inject the provider more than once and can double-count traffic. Add array validation that rejects duplicate provider_typevalues (and handle any existing duplicates).
of: [
defineArrayMember({ type: "trackerGoogleAnalytics" }),
defineArrayMember({ type: "trackerMatomo" }),
defineArrayMember({ type: "trackerMicrosoftClarity" }),
defineArrayMember({ type: "trackerPostHog" }),
defineArrayMember({ type: "trackerPlausible" }),
],
web/sanity/queries/snippets/settings.ts:140
- These labels and descriptions are visitor-facing content, but they are projected as scalar strings and rendered unchanged for every locale. The repository's localized settings pattern uses
internationalizedArrayString(for example,studio/schemas/settings/error.ts:21,35) and resolves at render time. Store/project the full localized arrays and resolve them with the requested locale and site locale before building the cookie rows.
cookieBannerLabel,
cookieBannerDescription,
web/src/components/tracking/lib/trackingRuntime.ts:89
- Plausible's manual
pageviewoverride expects a complete page URL; this relative path is sent as itsuvalue on SPA navigation and may be rejected or attributed incorrectly. Resolve it against the current origin before dispatching.
| const withRows = sections.map((section) => | ||
| isAnalyticsSection(section) ? withTrackerRows(section, trackers) : section, | ||
| ); |
| export function isTrackerEnabled(tracker: AnalyticsTracker): boolean { | ||
| if (tracker.enabled === false) return false; | ||
|
|
||
| switch (tracker._type) { | ||
| case "trackerGoogleAnalytics": | ||
| return Boolean(tracker.measurementId?.trim()); | ||
| case "trackerMatomo": | ||
| return Boolean(tracker.url?.trim() && tracker.siteId?.trim()); | ||
| case "trackerMicrosoftClarity": | ||
| return Boolean(tracker.projectId?.trim()); | ||
| case "trackerPostHog": | ||
| return Boolean(tracker.apiKey?.trim() && tracker.apiHost?.trim()); | ||
| case "trackerPlausible": | ||
| return Boolean(tracker.domain?.trim() && tracker.scriptUrl?.trim()); | ||
| default: | ||
| return false; | ||
| } | ||
| } |
…truction Round three of review. Five issues, all reachable from ordinary CMS edits: - Disabling or deleting a tracker in Sanity left it collecting. The effect compared consent against the *current* document, so a tracker that had vanished from it was never considered for teardown — and an empty list returned before doing anything at all. Now reconciles against what is running, and compares the value too, so editing a tracker in place (same `_key`) reloads it instead of silently keeping the old config. - Cookie deletion missed the domain that actually set the cookie. On `www.example.com` GA writes `_ga` for `.example.com`, which was never in the attempted scopes, so it survived withdrawal. Walks the hostname suffixes down to two labels. - Re-granting consent stacked duplicate script tags: unload cleared the registry but left the DOM alone. Injected tags are tracked per key and removed on unload; Matomo's and Clarity's snippets no longer inject their own script so those are tracked too. - A malformed or relative URL in the CMS threw from `new URL` while building the banner, and the throw happens before `CookieConsent.run` returns a promise — so the catch never fired and the banner never rendered, leaving no way to give or withdraw consent. Hostname extraction now falls back. - A section authored with `id: "analytics"` but no `linkedCategory` matched as the analytics section, skipping the synthesised fallback, while `buildConfig` derives categories from `linkedCategory` alone — so there was no category to grant. Matched sections get the link applied. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Label edits force Clarity reload
- Stale tracker detection now compares only runtime injection fields via trackerRuntimeConfigEqual, so cookie banner label/description edits no longer trigger Clarity reloads.
Or push these changes by commenting:
@cursor push 8e798c1a71
Preview (8e798c1a71)
diff --git a/web/src/components/tracking/SiteTracking.tsx b/web/src/components/tracking/SiteTracking.tsx
--- a/web/src/components/tracking/SiteTracking.tsx
+++ b/web/src/components/tracking/SiteTracking.tsx
@@ -14,6 +14,7 @@
getEnabledTrackers,
type TrackingConfig,
trackerRequiresConsent,
+ trackerRuntimeConfigEqual,
} from "@/src/components/tracking/lib/trackingConfig";
import {
trackPageView,
@@ -64,7 +65,7 @@
// edited in place keeps its `_key`, so compare the value too.
const stale = Array.from(loaded.current.values()).filter((tracker) => {
const next = allowedByKey.get(tracker._key);
- return !next || JSON.stringify(next) !== JSON.stringify(tracker);
+ return !next || !trackerRuntimeConfigEqual(next, tracker);
});
if (stale.length > 0) {
const needsReload = unloadTrackers(stale);
diff --git a/web/src/components/tracking/lib/trackingConfig.ts b/web/src/components/tracking/lib/trackingConfig.ts
--- a/web/src/components/tracking/lib/trackingConfig.ts
+++ b/web/src/components/tracking/lib/trackingConfig.ts
@@ -95,3 +95,28 @@
if (!shouldRespectCookieBanner(loadMode, cookieBanner)) return false;
return !isCookieFreeTracker(tracker);
}
+
+/** Whether two tracker configs would load the same runtime integration. */
+export function trackerRuntimeConfigEqual(
+ a: AnalyticsTracker,
+ b: AnalyticsTracker,
+): boolean {
+ return trackerRuntimeFingerprint(a) === trackerRuntimeFingerprint(b);
+}
+
+function trackerRuntimeFingerprint(tracker: AnalyticsTracker): string {
+ const cookieFree = tracker.cookieFree === true;
+
+ switch (tracker._type) {
+ case "trackerGoogleAnalytics":
+ return `${tracker._type}|${cookieFree}|${tracker.measurementId?.trim() ?? ""}`;
+ case "trackerMatomo":
+ return `${tracker._type}|${cookieFree}|${tracker.url?.trim() ?? ""}|${tracker.siteId?.trim() ?? ""}`;
+ case "trackerMicrosoftClarity":
+ return `${tracker._type}|${cookieFree}|${tracker.projectId?.trim() ?? ""}`;
+ case "trackerPostHog":
+ return `${tracker._type}|${cookieFree}|${tracker.apiKey?.trim() ?? ""}|${tracker.apiHost?.trim() ?? ""}`;
+ case "trackerPlausible":
+ return `${tracker._type}|${cookieFree}|${tracker.domain?.trim() ?? ""}|${tracker.scriptUrl?.trim() ?? ""}`;
+ }
+}You can send follow-ups to the cloud agent here.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 33 out of 33 changed files in this pull request and generated 1 comment.
Suppressed comments (4)
web/src/components/tracking/lib/trackingRuntime.ts:1
- Plausible's SPA pageview API (
plausible('pageview', { u: ... })) is documented to take a full URL. Hereurlis onlypathname + search(e.g./foo?bar), which can lead to incorrect/ambiguous reporting. Prefer passing an absolute URL (e.g.window.location.origin + urlorwindow.location.href) for Plausible.
web/src/components/tracking/lib/loadTrackers.ts:1 appendScriptalways setsscript.async = true, but some loaders also setdefervia attributes (e.g. Plausible). In HTML, having bothasyncanddefermeansasynctakes precedence, so the intended defer behavior is not achieved. Consider letting callers controlasync/deferexplicitly (e.g. an options parameter) or automatically disablingasyncwhen adeferattribute is requested.
web/src/app/[locale]/layout.tsx:60analytics(andcookieBanner) are already fetched in the layout, but<SiteTrackingShell />fetches them again, causing redundant server requests/work per render. Consider passing the already-fetchedanalytics/cookieBannerintoSiteTrackingShell(or directly rendering the client<SiteTracking />with the config) to avoid duplicate fetching.
const [siteLocale, siteNav, cookieBanner, siteBrand, analytics] =
await Promise.all([
fetchSiteLanguageSettings(),
fetchSiteNavMenus(),
fetchSiteCookieBanner(),
fetchSiteSettingsTitle(),
fetchSiteAnalyticsSettings(),
]);
web/src/app/[locale]/layout.tsx:96
analytics(andcookieBanner) are already fetched in the layout, but<SiteTrackingShell />fetches them again, causing redundant server requests/work per render. Consider passing the already-fetchedanalytics/cookieBannerintoSiteTrackingShell(or directly rendering the client<SiteTracking />with the config) to avoid duplicate fetching.
<SiteTrackingShell />
| return buildCookieSections( | ||
| { | ||
| ...doc, | ||
| preferencesModal: { | ||
| ...doc.preferencesModal, | ||
| sections: JSON.stringify(defaultSectionsFor(locale)), | ||
| }, | ||
| }, | ||
| trackers, | ||
| ); |
…outs
Round four. Two of these are regressions from my own earlier fixes:
- Stored XSS. The consent description is assigned with `innerHTML` by
vanilla-cookieconsent, and the schema help text I added actively told
editors to put an `<a>` tag in it — with `unsafe-inline` still in the CSP
that is script execution for anyone with Studio write access. The
description is now escaped and rendered as text; the policy link comes from
dedicated `privacyPolicyUrl`/`privacyPolicyLabel` fields and is built in
trusted code behind an http(s)-or-relative protocol allowlist.
- Overriding the visitor's own opt-out. `forgetUserOptOut` and
`opt_in_capturing` ran on every load, not only after a banner-driven
re-grant, so someone who had opted out through Matomo's or PostHog's own
mechanism was quietly re-enabled. Withdrawal now records that the opt-out
was ours, and only that is reversed.
- A copy edit forced a page reload. Staleness compared whole documents, so
editing a display-only field like `cookieBannerLabel` counted as a config
change — and for Clarity a teardown means `location.reload()`. Compares a
load signature of the fields that actually affect loading.
- The `open-cookie-preferences` link can sit in navigation while the banner
is disabled, where `showPreferences()` has nothing to show. Guarded.
Not changed, for the record: Bugbot re-reported the parent-domain cookie
scopes, script re-injection, and re-grant opt-out as open on the commit that
fixed them. Copilot's `...doc.preferencesModal` warning is also wrong —
object spread of null yields `{}`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Opt-out reverse lost on reload
- Persisted the self-opt-out marker in sessionStorage so it survives full page reloads and consumeSelfOptOut can still reverse Matomo/PostHog opt-outs when consent is re-granted.
Or push these changes by commenting:
@cursor push 62e16f36da
Preview (62e16f36da)
diff --git a/web/src/components/tracking/lib/trackingRuntime.ts b/web/src/components/tracking/lib/trackingRuntime.ts
--- a/web/src/components/tracking/lib/trackingRuntime.ts
+++ b/web/src/components/tracking/lib/trackingRuntime.ts
@@ -26,16 +26,51 @@
* Matomo's and PostHog's opt-outs persist in storage and can also be set by the
* visitor through the provider's own mechanism. Clearing them unconditionally on
* load would silently re-enable tracking for someone who opted out elsewhere.
+ *
+ * Persisted in sessionStorage so a Clarity-driven reload (or any full reload
+ * before consent is re-granted) does not lose the marker while the provider
+ * opt-out remains in cookies/storage.
*/
-const selfOptedOut = new Set<string>();
+const SELF_OPTED_OUT_STORAGE_KEY = "tracking:self-opted-out";
+function readPersistedSelfOptedOut(): Set<string> {
+ if (typeof sessionStorage === "undefined") return new Set();
+ try {
+ const raw = sessionStorage.getItem(SELF_OPTED_OUT_STORAGE_KEY);
+ if (!raw) return new Set();
+ const parsed: unknown = JSON.parse(raw);
+ return new Set(
+ Array.isArray(parsed)
+ ? parsed.filter((key): key is string => typeof key === "string")
+ : [],
+ );
+ } catch {
+ return new Set();
+ }
+}
+
+function writePersistedSelfOptedOut(keys: Set<string>): void {
+ if (typeof sessionStorage === "undefined") return;
+ if (keys.size === 0) sessionStorage.removeItem(SELF_OPTED_OUT_STORAGE_KEY);
+ else
+ sessionStorage.setItem(
+ SELF_OPTED_OUT_STORAGE_KEY,
+ JSON.stringify([...keys]),
+ );
+}
+
export function markSelfOptedOut(key: string): void {
- selfOptedOut.add(key);
+ const keys = readPersistedSelfOptedOut();
+ keys.add(key);
+ writePersistedSelfOptedOut(keys);
}
/** True once, if this integration was what opted the visitor out. */
export function consumeSelfOptOut(key: string): boolean {
- return selfOptedOut.delete(key);
+ const keys = readPersistedSelfOptedOut();
+ if (!keys.delete(key)) return false;
+ writePersistedSelfOptedOut(keys);
+ return true;
}
export function isTrackerLoaded(key: string): boolean {You can send follow-ups to the cloud agent here.
Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit d4887bf. Configure here.
Regression from the previous commit. Gating `forgetUserOptOut` and `opt_in_capturing` on "did we opt this visitor out" was right, but the marker lived in a module-level Set while the provider opt-outs it mirrors live in cookies and localStorage. Any reload cleared it — including the reload that withdrawal triggers for Clarity — so a later consent grant found no marker and left Matomo and PostHog opted out for good. Stored in localStorage now, which matches the durability of what it tracks. Recording a consent decision is strictly necessary processing, so it needs no consent itself, and a storage failure degrades to "not ours to reverse". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>


Adds a CMS-configurable analytics layer wired into the existing
vanilla-cookieconsentbanner. Ported from the implementation inbackendforth/mammalsandcomputers(PR #38), adapted to starter paths and conventions.What this adds
A
siteAnalyticsSettingssingleton with per-provider configuration for:Each provider has a cookie-free mode and its own cookie-banner labels. Cookie-based trackers wait for analytics consent; cookie-free trackers load immediately when the provider is set to respect the banner.
How it wires up
siteAnalyticsSettingssingleton, onetracker*object per provider, structure item,open-cookie-preferenceslink functionSiteTracking/SiteTrackingShellmounted in[locale]/layout.tsx, with the loader and runtime undercomponents/tracking/lib/api/revalidateVerification
pnpm run format,pnpm run typecheck, andpnpm run check:wiringall pass locally (Node 24, pnpm 10.21). No dependency changes — the starter already shipsvanilla-cookieconsent@^3.1.0.Notes for review
netlify.tomldocuments which.Note
Medium Risk
Third-party scripts and consent logic affect every page load and privacy behavior; CSP broadening and Clarity’s reload-on-revoke are operational edge cases, but defaults favor respecting the banner.
Overview
Introduces CMS-configurable analytics via a new
siteAnalyticsSettingssingleton (five tracker types, load mode, cookie-free flags, banner copy) and wires it into the locale layout throughSiteTrackingShell/SiteTracking, which load or unload scripts based on consent, config changes, and SPA navigations.The cookie banner is tightened for compliance: required privacy policy URL and reject buttons, escaped consent copy with a safe policy link, preference sections built from enabled trackers (with a synthetic analytics section if missing), and cross-document Studio warnings when the banner is off or load mode bypasses consent.
Netlify CSP expands
script-src,connect-src, andimg-srcfor Google Tag Manager/Analytics, Clarity, PostHog, and Plausible. Revalidation addssite-analytics-settings(and existing cookie-banner tag usage) on Sanity webhooks.Reviewed by Cursor Bugbot for commit 76a27e4. Bugbot is set up for automated code reviews on this repo. Configure here.