Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/sdk/src/modules/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> {
try {
const params = new URLSearchParams(window.location.search);
const out: Record<string, string> = {};
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" ||
Expand Down
44 changes: 44 additions & 0 deletions packages/sdk/tests/unit/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });

Expand Down
Loading