From d4aad8991d2776df1a191a00bb6a52c00d2a7084 Mon Sep 17 00:00:00 2001 From: Maoz Smadja Date: Mon, 5 Oct 2026 19:56:03 +0300 Subject: [PATCH] feat(analytics): capture landing attribution params on the init event __initialization_event__ sent only document.referrer, and page_url is location.pathname, so UTMs and ad click ids on the landing URL never reached app_events as fields. Read an allowlist of 15 keys (utm_*, gclid/gbraid/wbraid, gad_*, fbclid, msclkid, ttclid, li_fat_id) from location.search at init, skip empty values, cap each at 500 chars. Co-Authored-By: Claude Opus 5.5 --- packages/sdk/src/modules/analytics.ts | 24 +++++++++++++ packages/sdk/tests/unit/analytics.test.ts | 44 +++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/packages/sdk/src/modules/analytics.ts b/packages/sdk/src/modules/analytics.ts index e9aebfc5..62fcb27c 100644 --- a/packages/sdk/src/modules/analytics.ts +++ b/packages/sdk/src/modules/analytics.ts @@ -270,10 +270,34 @@ function trackInitializationEvent(track: (params: TrackEventParams) => void) { eventName: ANALYTICS_INITIALIZATION_EVENT_NAME, properties: { referrer: document?.referrer, + ...getAttributionParams(), }, }); } +// Allowlist, never the whole query string: landing URLs carry PII +// (e.g. `utm_email`) and tokens. +const ATTRIBUTION_PARAM_KEYS = [ + "utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "utm_id", + "gclid", "gbraid", "wbraid", "gad_source", "gad_campaignid", + "fbclid", "msclkid", "ttclid", "li_fat_id", +]; + +function getAttributionParams(): Record { + try { + const params = new URLSearchParams(window.location.search); + const out: Record = {}; + for (const key of ATTRIBUTION_PARAM_KEYS) { + const value = params.get(key); + if (value) out[key] = value.slice(0, 500); + } + return out; + } catch { + // React Native has no `window.location`. + return {}; + } +} + function setSessionDurationTimerStart() { if ( typeof window === "undefined" || diff --git a/packages/sdk/tests/unit/analytics.test.ts b/packages/sdk/tests/unit/analytics.test.ts index 48e61f60..d851e99c 100644 --- a/packages/sdk/tests/unit/analytics.test.ts +++ b/packages/sdk/tests/unit/analytics.test.ts @@ -122,6 +122,50 @@ describe("Analytics Module", () => { disabled.cleanup(); }); + describe("initialization event attribution", () => { + const trackInitWithSearch = (search: string) => { + vi.stubGlobal("document", { referrer: "https://google.com/", visibilityState: "visible" }); + vi.stubGlobal("window", { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + location: { origin: "https://example.com", pathname: "/landing", search }, + }); + const state = sharedState as unknown as { wasInitializationTracked: boolean }; + state.wasInitializationTracked = false; + // Hold the queue so the init event can be read before it flushes. + sharedState!.isProcessing = true; + const client = createClient({ serverUrl, appId }); + const initEvent = sharedState!.requestsQueue.find( + (e) => e.eventName === "__initialization_event__" + ); + client.cleanup(); + return initEvent?.properties; + }; + + test("captures allowlisted landing params next to referrer", () => { + const properties = trackInitWithSearch( + "?utm_source=test&utm_medium=cpc&gclid=abc&utm_email=a@b.com&token=secret&utm_term=" + ); + + expect(properties).toEqual({ + referrer: "https://google.com/", + utm_source: "test", + utm_medium: "cpc", + gclid: "abc", + }); + }); + + test("caps each value at 500 characters", () => { + const properties = trackInitWithSearch(`?fbclid=${"x".repeat(600)}`); + + expect(properties?.fbclid).toHaveLength(500); + }); + + test("sends only referrer when the landing URL has no params", () => { + expect(trackInitWithSearch("")).toEqual({ referrer: "https://google.com/" }); + }); + }); + test("should clear the memoized session context on reset", () => { expect(sharedState?.sessionContext).toEqual({ user_id: "test-user-id" });