From 4fdee62bd37c013013808a6371c2174b986b3800 Mon Sep 17 00:00:00 2001 From: Amit Shil Date: Thu, 22 Oct 2026 13:05:02 +0300 Subject: [PATCH] feat(analytics): follow the app owner's choice and the visitor's consent The server marks published pages with : off never tracks or stores base44_analytics_session_id; consent waits for Google Consent Mode analytics_storage via window.__b44Consent and removes the id on a later denial; on tracks until a denial. No meta: unchanged. Co-Authored-By: Claude Opus 5.5 --- packages/sdk/src/modules/analytics.ts | 154 ++++++++++++++-- packages/sdk/src/modules/app-logs.ts | 3 + .../sdk/tests/unit/analytics-consent.test.ts | 168 ++++++++++++++++++ 3 files changed, 309 insertions(+), 16 deletions(-) create mode 100644 packages/sdk/tests/unit/analytics-consent.test.ts diff --git a/packages/sdk/src/modules/analytics.ts b/packages/sdk/src/modules/analytics.ts index 5a91b17d..3eac543c 100644 --- a/packages/sdk/src/modules/analytics.ts +++ b/packages/sdk/src/modules/analytics.ts @@ -21,6 +21,27 @@ export const ANALYTICS_CONFIG_ENABLE_URL_PARAM_KEY = "analytics-enable"; export const ANALYTICS_SESSION_ID_LOCAL_STORAGE_KEY = "base44_analytics_session_id"; +// Set by the Base44 server from the app owner's choice: "off" never tracks, +// "consent" waits for the page's Google Consent Mode `analytics_storage` grant, +// "on" tracks until that consent is denied. Absent: track as before. +export const ANALYTICS_MODE_META_NAME = "base44-analytics"; + +type AnalyticsMode = "off" | "consent" | "on"; + +function readAnalyticsMode(): AnalyticsMode | null { + if (typeof document === "undefined") return null; + try { + const content = document + .querySelector(`meta[name="${ANALYTICS_MODE_META_NAME}"]`) + ?.getAttribute("content"); + return content === "off" || content === "consent" || content === "on" + ? content + : null; + } catch { + return null; + } +} + const defaultConfiguration: AnalyticsModuleOptions = { // default to enabled // enabled: true, @@ -55,6 +76,73 @@ const analyticsSharedState = getSharedInstance( }) ); +/////////////////////////////////////////////// +//// visitor consent //// +/////////////////////////////////////////////// + +// Its own shared instance: an older SDK copy on the page may own "analytics". +const consentSharedState = getSharedInstance("analytics-consent", () => { + const analyticsMode = readAnalyticsMode(); + return { + analyticsMode, + // Whether the visitor may be tracked and the session id stored. + consentGranted: analyticsMode !== "consent", + isWatchingConsent: false, + listeners: [] as Array<(granted: boolean) => void>, + }; +}); + +/** Whether analytics may run and store the session id on this page. */ +export function isAnalyticsAllowed(): boolean { + const { analyticsMode, consentGranted } = consentSharedState; + return analyticsMode !== "off" && consentGranted; +} + +function setConsentGranted(granted: boolean) { + if (consentSharedState.consentGranted === granted) return; + consentSharedState.consentGranted = granted; + if (!granted) { + try { + const sessionId = localStorage.getItem( + ANALYTICS_SESSION_ID_LOCAL_STORAGE_KEY + ); + // Same id for the rest of the tab, so an anonymous agent chat keeps its owner. + if (sessionId) analyticsSharedState.fallbackSessionId ??= sessionId; + localStorage.removeItem(ANALYTICS_SESSION_ID_LOCAL_STORAGE_KEY); + } catch { + // storage unavailable: nothing was stored + } + } + for (const listener of [...consentSharedState.listeners]) { + listener(granted); + } +} + +// Subscribes once to the consent listener Base44 injects next to the meta tag. +// Without it (e.g. a CSP blocked the inline script) "consent" never grants. +function watchAnalyticsConsent() { + const state = consentSharedState; + if (state.isWatchingConsent) return; + state.isWatchingConsent = true; + if (state.analyticsMode !== "consent" && state.analyticsMode !== "on") return; + const b44Consent = + typeof window !== "undefined" ? (window as any).__b44Consent : undefined; + b44Consent?.on( + "analytics_storage", + () => setConsentGranted(true), + () => setConsentGranted(false) + ); +} + +function onConsentChange(listener: (granted: boolean) => void) { + consentSharedState.listeners.push(listener); + return () => { + const listeners = consentSharedState.listeners; + const index = listeners.indexOf(listener); + if (index >= 0) listeners.splice(index, 1); + }; +} + /////////////////////////////////////////////// export interface AnalyticsModuleArgs { @@ -74,12 +162,18 @@ export const createAnalyticsModule = ({ }: AnalyticsModuleArgs) => { // prevent overflow of events // const { maxQueueSize, throttleTime, batchSize } = analyticsSharedState.config; + watchAnalyticsConsent(); // Disable analytics on React Native. It defines `window` but not `document`, // so the per-callsite `typeof window` guards below aren't enough to keep it // from touching `document` (e.g. `document.referrer` on init). Node/SSR is // still handled by those `window` guards, so this doesn't affect it. - if (!enabled || !analyticsSharedState.config?.enabled || isReactNative) { + if ( + !enabled || + !analyticsSharedState.config?.enabled || + isReactNative || + consentSharedState.analyticsMode === "off" + ) { return { track: () => {}, cleanup: () => {}, @@ -116,7 +210,8 @@ export const createAnalyticsModule = ({ eventsData: TrackEventData[], options: { isBeacon?: boolean } = {} ) => { - if (eventsData.length === 0) return; + // Consent may have been withdrawn while these events waited. + if (eventsData.length === 0 || !isAnalyticsAllowed()) return; const sessionContext_ = await getSessionContext(userAuthModule); const events = eventsData.map( @@ -140,7 +235,10 @@ export const createAnalyticsModule = ({ }; const track = (params: TrackEventParams) => { - if (analyticsSharedState.requestsQueue.length >= maxQueueSize) { + if ( + !isAnalyticsAllowed() || + analyticsSharedState.requestsQueue.length >= maxQueueSize + ) { return; } const intrinsicData = getEventIntrinsicData(); @@ -179,7 +277,25 @@ export const createAnalyticsModule = ({ } }; - const cleanup = () => { + let isStarted = false; + + const start = () => { + if (isStarted) return; + isStarted = true; + // start the flusing process /// + startProcessing(); + // start the heart beat processor // + clearHeartBeatProcessor = startHeartBeatProcessor(track); + // track the referrer event // + trackInitializationEvent(track); + // start the visibility change listener // + if (typeof window !== "undefined") { + window.addEventListener("visibilitychange", onVisibilityChange); + } + }; + + const stop = () => { + isStarted = false; stopAnalyticsProcessor(); clearHeartBeatProcessor?.(); if (typeof window !== "undefined") { @@ -187,16 +303,21 @@ export const createAnalyticsModule = ({ } }; - // start the flusing process /// - startProcessing(); - // start the heart beat processor // - clearHeartBeatProcessor = startHeartBeatProcessor(track); - // track the referrer event // - trackInitializationEvent(track); - // start the visibility change listener // - if (typeof window !== "undefined") { - window.addEventListener("visibilitychange", onVisibilityChange); - } + const stopWatchingConsent = onConsentChange((granted) => { + if (granted) { + start(); + } else { + stop(); + analyticsSharedState.requestsQueue.splice(0); + } + }); + + const cleanup = () => { + stopWatchingConsent(); + stop(); + }; + + if (isAnalyticsAllowed()) start(); return { track, @@ -432,7 +553,7 @@ function getFallbackSessionId(): string { } export function getAnalyticsSessionId(): string { - if (typeof window === "undefined") { + if (typeof window === "undefined" || !isAnalyticsAllowed()) { return getFallbackSessionId(); } try { @@ -440,7 +561,8 @@ export function getAnalyticsSessionId(): string { ANALYTICS_SESSION_ID_LOCAL_STORAGE_KEY ); if (!sessionId) { - const newSessionId = generateUuid(); + // An id used before consent was granted carries on, so it is stored now. + const newSessionId = analyticsSharedState.fallbackSessionId ?? generateUuid(); localStorage.setItem( ANALYTICS_SESSION_ID_LOCAL_STORAGE_KEY, newSessionId diff --git a/packages/sdk/src/modules/app-logs.ts b/packages/sdk/src/modules/app-logs.ts index ec072f8a..138c35b6 100644 --- a/packages/sdk/src/modules/app-logs.ts +++ b/packages/sdk/src/modules/app-logs.ts @@ -1,5 +1,6 @@ import { AxiosInstance } from "axios"; import { AppLogsModule } from "./app-logs.types"; +import { isAnalyticsAllowed } from "./analytics.js"; /** * Creates the app logs module for the Base44 SDK. @@ -18,6 +19,8 @@ export function createAppLogsModule( return { // Log user activity in the app async logUserInApp(pageName: string): Promise { + // A page visit feeds App Analytics, so it follows the same consent. + if (!isAnalyticsAllowed()) return; await axios.post(`${baseURL}/log-user-in-app/${pageName}`); }, diff --git a/packages/sdk/tests/unit/analytics-consent.test.ts b/packages/sdk/tests/unit/analytics-consent.test.ts new file mode 100644 index 00000000..10fb6a6a --- /dev/null +++ b/packages/sdk/tests/unit/analytics-consent.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { createClient } from "../../src/index.ts"; +import { getSharedInstance } from "../../src/utils/sharedInstance.ts"; +import { getAnalyticsSessionId } from "../../src/modules/analytics.ts"; + +const SESSION_ID_KEY = "base44_analytics_session_id"; + +const requests: string[] = []; + +vi.mock("../../src/utils/axios-client.ts", () => ({ + createAxiosClient: vi.fn().mockImplementation(() => ({ + defaults: { headers: { common: {} } }, + request: vi.fn(async (config: { url: string }) => { + requests.push(config.url); + return {}; + }), + post: vi.fn(async (url: string) => { + requests.push(url); + return {}; + }), + })), +})); + +type ConsentState = { + analyticsMode: string | null; + consentGranted: boolean; + isWatchingConsent: boolean; + listeners: unknown[]; +}; +const consentState = getSharedInstance( + "analytics-consent", + () => ({} as ConsentState) +); +const analyticsState = getSharedInstance("analytics", () => ({})) as { + requestsQueue: unknown[]; + isProcessing: boolean; + wasInitializationTracked: boolean; + fallbackSessionId: string | null; + sessionContext: unknown; + config: Record; +}; + +let storage: Map; +let consentListener: { grant: () => void; revoke: () => void } | null; +let client: ReturnType; + +// What the page looks like: the server's meta tag and Base44's consent listener. +function loadPage(mode: string | null) { + consentState.analyticsMode = mode; + consentState.consentGranted = mode !== "consent"; + consentState.isWatchingConsent = false; + consentState.listeners = []; + client = createClient({ serverUrl: "https://api", appId: "app-1" }); +} + +const analyticsRequests = () => requests.filter((url) => url.includes("/analytics/")); + +beforeEach(() => { + vi.useFakeTimers(); + requests.length = 0; + storage = new Map(); + consentListener = null; + const localStorage = { + getItem: (k: string) => storage.get(k) ?? null, + setItem: (k: string, v: string) => void storage.set(k, v), + removeItem: (k: string) => void storage.delete(k), + }; + vi.stubGlobal("localStorage", localStorage); + vi.stubGlobal("document", { referrer: "", visibilityState: "visible" }); + vi.stubGlobal("window", { + localStorage, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + location: { origin: "https://app.example", pathname: "/", search: "", href: "https://app.example/" }, + __b44Consent: { + on: (category: string, grant: () => void, revoke: () => void) => { + if (category === "analytics_storage") consentListener = { grant, revoke }; + }, + }, + }); + Object.assign(analyticsState, { + requestsQueue: [], + isProcessing: false, + wasInitializationTracked: false, + fallbackSessionId: null, + sessionContext: null, + }); + analyticsState.config = { ...analyticsState.config, enabled: true, heartBeatInterval: undefined }; +}); + +afterEach(() => { + client.cleanup(); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("analytics follows the owner's choice and the visitor's consent", () => { + test("consent: nothing is stored or sent before a grant", async () => { + loadPage("consent"); + client.analytics.track({ eventName: "click" }); + await client.appLogs.logUserInApp("home"); + getAnalyticsSessionId(); + await vi.advanceTimersByTimeAsync(5000); + + expect(storage.has(SESSION_ID_KEY)).toBe(false); + expect(requests).toEqual([]); + }); + + test("consent: a grant starts tracking, a revoke stops it and removes the id", async () => { + loadPage("consent"); + const idBeforeGrant = getAnalyticsSessionId(); + + consentListener!.grant(); + await vi.advanceTimersByTimeAsync(0); + expect(analyticsRequests()).toHaveLength(1); + expect(storage.get(SESSION_ID_KEY)).toBe(idBeforeGrant); + + consentListener!.revoke(); + requests.length = 0; + client.analytics.track({ eventName: "after-revoke" }); + await client.appLogs.logUserInApp("home"); + await vi.advanceTimersByTimeAsync(5000); + + expect(storage.has(SESSION_ID_KEY)).toBe(false); + expect(requests).toEqual([]); + // The tab keeps one id, so an anonymous agent chat keeps its owner. + expect(getAnalyticsSessionId()).toBe(idBeforeGrant); + expect(storage.has(SESSION_ID_KEY)).toBe(false); + }); + + test("off: nothing is stored or sent, whatever the consent", async () => { + loadPage("off"); + consentListener?.grant(); + client.analytics.track({ eventName: "click" }); + await client.appLogs.logUserInApp("home"); + getAnalyticsSessionId(); + await vi.advanceTimersByTimeAsync(5000); + + expect(storage.has(SESSION_ID_KEY)).toBe(false); + expect(requests).toEqual([]); + }); + + test("on: tracks from load until the visitor declines", async () => { + loadPage("on"); + await vi.advanceTimersByTimeAsync(0); + expect(analyticsRequests()).toHaveLength(1); + expect(storage.has(SESSION_ID_KEY)).toBe(true); + + consentListener!.revoke(); + requests.length = 0; + client.analytics.track({ eventName: "after-decline" }); + await vi.advanceTimersByTimeAsync(5000); + + expect(storage.has(SESSION_ID_KEY)).toBe(false); + expect(requests).toEqual([]); + }); + + test("no meta tag: tracks as before and ignores consent updates", async () => { + loadPage(null); + await client.appLogs.logUserInApp("home"); + await vi.advanceTimersByTimeAsync(0); + + expect(consentListener).toBeNull(); + expect(analyticsRequests()).toHaveLength(1); + expect(requests).toContain("/app-logs/app-1/log-user-in-app/home"); + expect(storage.has(SESSION_ID_KEY)).toBe(true); + }); +});