From d8be51bb9b885a46bade3adf55f34e9e8914ddde Mon Sep 17 00:00:00 2001 From: Amin Hakem Date: Wed, 15 Jul 2026 08:31:55 -0700 Subject: [PATCH 1/2] feat(user-journey): include browser referrer in page-view events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures document.referrer at page-load time and sends it as data.payload.referrer in every page_view event — both initial load and SPA route changes — so organic/referral attribution is recorded even when a visitor never interacts with a form. Centralises page-view creation in createPageViewEvent(), adds SSR guards for document/window/navigator/localStorage, and regenerates the bundle. Adds focused unit tests (test/unit/) and a test:unit npm script. Co-Authored-By: Claude Sonnet 4.6 --- package.json | 1 + src/store/user-journey.ts | 76 +++++++------ src/utils/cookies.ts | 6 ++ surface_embed_v1.js | 66 ++++++------ surface_tag.js | 66 ++++++------ test/unit/user-journey.test.js | 192 +++++++++++++++++++++++++++++++++ 6 files changed, 314 insertions(+), 93 deletions(-) create mode 100644 test/unit/user-journey.test.js diff --git a/package.json b/package.json index 2ea7b54..3fab836 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "build": "esbuild src/index.ts --bundle --format=iife --outfile=surface_tag.js --target=es2020 && pnpm copyTagToEmbed", "copyTagToEmbed": "cp surface_tag.js surface_embed_v1.js", "dev": "esbuild src/index.ts --bundle --format=iife --outfile=surface_tag.js --target=es2020 --watch --sourcemap", + "test:unit": "node --test test/unit/*.test.js", "typecheck": "tsc --noEmit", "postbuild": "pnpm copyTagToEmbed" }, diff --git a/src/store/user-journey.ts b/src/store/user-journey.ts index ae77c2e..174210b 100644 --- a/src/store/user-journey.ts +++ b/src/store/user-journey.ts @@ -11,7 +11,42 @@ import { refreshJourneyCookie, getExistingJourneyId, } from "./journey-cookies"; -import type { Logger, JourneyTrackEvent } from "../types"; +import type { LeadData, Logger, JourneyTrackEvent } from "../types"; + +function getBrowserReferrer(): string { + return typeof document === "undefined" ? "" : document.referrer || ""; +} + +function getSurfaceLeadDataSafely(): LeadData | null { + try { + if (typeof localStorage === "undefined") return null; + + return getLeadDataWithTTL(); + } catch { + return null; + } +} + +function createPageViewEvent( + url: string, + environmentId: string | null +): JourneyTrackEvent { + return { + data: { + type: "page_view", + payload: { + url, + timestamp: new Date().toISOString(), + // Preserve the browser's native value (including an empty direct visit). + referrer: getBrowserReferrer(), + }, + }, + metadata: { + ...(environmentId ? { environmentId } : {}), + ...(getSurfaceLeadDataSafely() ?? {}), + }, + }; +} export function initializeUserJourneyTracking( environmentId: string | null, @@ -20,6 +55,8 @@ export function initializeUserJourneyTracking( setJourneyId: (id: string | null) => void ): void { try { + if (typeof window === "undefined") return; + const existingId = getExistingJourneyId(); setJourneyId(existingId); log.info({ message: "Existing journey ID", response: { id: existingId || "none" } }); @@ -32,23 +69,8 @@ export function initializeUserJourneyTracking( return; } - const surfaceLeadData = getLeadDataWithTTL(); - trackToRedis( - { - data: { - type: "page_view", - payload: { - url: currentUrl, - timestamp: new Date().toISOString(), - referrer: document.referrer || "", - }, - }, - metadata: { - ...(environmentId ? { environmentId } : {}), - ...(surfaceLeadData ?? {}), - }, - }, + createPageViewEvent(currentUrl, environmentId), log, getJourneyId, setJourneyId @@ -79,7 +101,7 @@ export async function trackToRedis( log.info({ message: "Tracking to Redis", response: payload }); - if (journeyId && navigator.sendBeacon) { + if (journeyId && typeof navigator !== "undefined" && navigator.sendBeacon) { const blob = new Blob([JSON.stringify(payload)], { type: "application/json", }); @@ -126,6 +148,8 @@ export function updateUserJourneyOnRouteChange( setJourneyId: (id: string | null) => void ): void { try { + if (typeof window === "undefined") return; + const currentUrl = newUrl || window.location.href; const recentVisit = getCookie(SURFACE_USER_JOURNEY_RECENT_VISIT_COOKIE_NAME); @@ -134,22 +158,8 @@ export function updateUserJourneyOnRouteChange( return; } - const surfaceLeadData = getLeadDataWithTTL(); - trackToRedis( - { - data: { - type: "page_view", - payload: { - url: currentUrl, - timestamp: new Date().toISOString(), - }, - }, - metadata: { - ...(environmentId ? { environmentId } : {}), - ...(surfaceLeadData ?? {}), - }, - }, + createPageViewEvent(currentUrl, environmentId), log, getJourneyId, setJourneyId diff --git a/src/utils/cookies.ts b/src/utils/cookies.ts index b3387e4..77db493 100644 --- a/src/utils/cookies.ts +++ b/src/utils/cookies.ts @@ -3,6 +3,8 @@ import type { CookieOptions } from "../types"; export function parseCookies(): Record { const cookies: Record = {}; + if (typeof document === "undefined") return cookies; + document.cookie.split(";").forEach((cookie) => { const trimmed = cookie.trim(); const eqIndex = trimmed.indexOf("="); @@ -27,6 +29,8 @@ export function setCookie( value: string, options: CookieOptions = {} ): void { + if (typeof document === "undefined") return; + const encoded = encodeURIComponent(value); const path = options.path || "/"; const maxAge = options.maxAge ?? 604800; @@ -44,6 +48,8 @@ export function deleteCookie( name: string, options: Pick = {} ): void { + if (typeof document === "undefined") return; + const domainAttr = options.domain ? `; domain=${options.domain}` : ""; document.cookie = `${name}=; path=/; max-age=0; samesite=lax${domainAttr}`; } diff --git a/surface_embed_v1.js b/surface_embed_v1.js index 0b29114..5af2a95 100644 --- a/surface_embed_v1.js +++ b/surface_embed_v1.js @@ -202,6 +202,7 @@ // src/utils/cookies.ts function parseCookies() { const cookies = {}; + if (typeof document === "undefined") return cookies; document.cookie.split(";").forEach((cookie) => { const trimmed = cookie.trim(); const eqIndex = trimmed.indexOf("="); @@ -218,6 +219,7 @@ return cookies; } function setCookie(name, value, options = {}) { + if (typeof document === "undefined") return; const encoded = encodeURIComponent(value); const path = options.path || "/"; const maxAge = options.maxAge ?? 604800; @@ -230,6 +232,7 @@ return cookies[name] || null; } function deleteCookie(name, options = {}) { + if (typeof document === "undefined") return; const domainAttr = options.domain ? `; domain=${options.domain}` : ""; document.cookie = `${name}=; path=/; max-age=0; samesite=lax${domainAttr}`; } @@ -326,8 +329,37 @@ } // src/store/user-journey.ts + function getBrowserReferrer() { + return typeof document === "undefined" ? "" : document.referrer || ""; + } + function getSurfaceLeadDataSafely() { + try { + if (typeof localStorage === "undefined") return null; + return getLeadDataWithTTL(); + } catch { + return null; + } + } + function createPageViewEvent(url, environmentId3) { + return { + data: { + type: "page_view", + payload: { + url, + timestamp: (/* @__PURE__ */ new Date()).toISOString(), + // Preserve the browser's native value (including an empty direct visit). + referrer: getBrowserReferrer() + } + }, + metadata: { + ...environmentId3 ? { environmentId: environmentId3 } : {}, + ...getSurfaceLeadDataSafely() ?? {} + } + }; + } function initializeUserJourneyTracking(environmentId3, log2, getJourneyId, setJourneyId) { try { + if (typeof window === "undefined") return; const existingId = getExistingJourneyId(); setJourneyId(existingId); log2.info({ message: "Existing journey ID", response: { id: existingId || "none" } }); @@ -337,22 +369,8 @@ log2.info({ message: "Skipping duplicate page view (same as recent visit)" }); return; } - const surfaceLeadData = getLeadDataWithTTL(); trackToRedis( - { - data: { - type: "page_view", - payload: { - url: currentUrl2, - timestamp: (/* @__PURE__ */ new Date()).toISOString(), - referrer: document.referrer || "" - } - }, - metadata: { - ...environmentId3 ? { environmentId: environmentId3 } : {}, - ...surfaceLeadData ?? {} - } - }, + createPageViewEvent(currentUrl2, environmentId3), log2, getJourneyId, setJourneyId @@ -373,7 +391,7 @@ const payload = { ...event }; if (journeyId) payload.id = journeyId; log2.info({ message: "Tracking to Redis", response: payload }); - if (journeyId && navigator.sendBeacon) { + if (journeyId && typeof navigator !== "undefined" && navigator.sendBeacon) { const blob = new Blob([JSON.stringify(payload)], { type: "application/json" }); @@ -408,27 +426,15 @@ } function updateUserJourneyOnRouteChange(environmentId3, newUrl, log2, getJourneyId, setJourneyId) { try { + if (typeof window === "undefined") return; const currentUrl2 = newUrl || window.location.href; const recentVisit = getCookie(SURFACE_USER_JOURNEY_RECENT_VISIT_COOKIE_NAME); if (recentVisit === currentUrl2) { log2.info({ message: "Skipping duplicate page view on route change" }); return; } - const surfaceLeadData = getLeadDataWithTTL(); trackToRedis( - { - data: { - type: "page_view", - payload: { - url: currentUrl2, - timestamp: (/* @__PURE__ */ new Date()).toISOString() - } - }, - metadata: { - ...environmentId3 ? { environmentId: environmentId3 } : {}, - ...surfaceLeadData ?? {} - } - }, + createPageViewEvent(currentUrl2, environmentId3), log2, getJourneyId, setJourneyId diff --git a/surface_tag.js b/surface_tag.js index 0b29114..5af2a95 100644 --- a/surface_tag.js +++ b/surface_tag.js @@ -202,6 +202,7 @@ // src/utils/cookies.ts function parseCookies() { const cookies = {}; + if (typeof document === "undefined") return cookies; document.cookie.split(";").forEach((cookie) => { const trimmed = cookie.trim(); const eqIndex = trimmed.indexOf("="); @@ -218,6 +219,7 @@ return cookies; } function setCookie(name, value, options = {}) { + if (typeof document === "undefined") return; const encoded = encodeURIComponent(value); const path = options.path || "/"; const maxAge = options.maxAge ?? 604800; @@ -230,6 +232,7 @@ return cookies[name] || null; } function deleteCookie(name, options = {}) { + if (typeof document === "undefined") return; const domainAttr = options.domain ? `; domain=${options.domain}` : ""; document.cookie = `${name}=; path=/; max-age=0; samesite=lax${domainAttr}`; } @@ -326,8 +329,37 @@ } // src/store/user-journey.ts + function getBrowserReferrer() { + return typeof document === "undefined" ? "" : document.referrer || ""; + } + function getSurfaceLeadDataSafely() { + try { + if (typeof localStorage === "undefined") return null; + return getLeadDataWithTTL(); + } catch { + return null; + } + } + function createPageViewEvent(url, environmentId3) { + return { + data: { + type: "page_view", + payload: { + url, + timestamp: (/* @__PURE__ */ new Date()).toISOString(), + // Preserve the browser's native value (including an empty direct visit). + referrer: getBrowserReferrer() + } + }, + metadata: { + ...environmentId3 ? { environmentId: environmentId3 } : {}, + ...getSurfaceLeadDataSafely() ?? {} + } + }; + } function initializeUserJourneyTracking(environmentId3, log2, getJourneyId, setJourneyId) { try { + if (typeof window === "undefined") return; const existingId = getExistingJourneyId(); setJourneyId(existingId); log2.info({ message: "Existing journey ID", response: { id: existingId || "none" } }); @@ -337,22 +369,8 @@ log2.info({ message: "Skipping duplicate page view (same as recent visit)" }); return; } - const surfaceLeadData = getLeadDataWithTTL(); trackToRedis( - { - data: { - type: "page_view", - payload: { - url: currentUrl2, - timestamp: (/* @__PURE__ */ new Date()).toISOString(), - referrer: document.referrer || "" - } - }, - metadata: { - ...environmentId3 ? { environmentId: environmentId3 } : {}, - ...surfaceLeadData ?? {} - } - }, + createPageViewEvent(currentUrl2, environmentId3), log2, getJourneyId, setJourneyId @@ -373,7 +391,7 @@ const payload = { ...event }; if (journeyId) payload.id = journeyId; log2.info({ message: "Tracking to Redis", response: payload }); - if (journeyId && navigator.sendBeacon) { + if (journeyId && typeof navigator !== "undefined" && navigator.sendBeacon) { const blob = new Blob([JSON.stringify(payload)], { type: "application/json" }); @@ -408,27 +426,15 @@ } function updateUserJourneyOnRouteChange(environmentId3, newUrl, log2, getJourneyId, setJourneyId) { try { + if (typeof window === "undefined") return; const currentUrl2 = newUrl || window.location.href; const recentVisit = getCookie(SURFACE_USER_JOURNEY_RECENT_VISIT_COOKIE_NAME); if (recentVisit === currentUrl2) { log2.info({ message: "Skipping duplicate page view on route change" }); return; } - const surfaceLeadData = getLeadDataWithTTL(); trackToRedis( - { - data: { - type: "page_view", - payload: { - url: currentUrl2, - timestamp: (/* @__PURE__ */ new Date()).toISOString() - } - }, - metadata: { - ...environmentId3 ? { environmentId: environmentId3 } : {}, - ...surfaceLeadData ?? {} - } - }, + createPageViewEvent(currentUrl2, environmentId3), log2, getJourneyId, setJourneyId diff --git a/test/unit/user-journey.test.js b/test/unit/user-journey.test.js new file mode 100644 index 0000000..95e89bb --- /dev/null +++ b/test/unit/user-journey.test.js @@ -0,0 +1,192 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); +const esbuild = require("esbuild"); + +let userJourney; + +async function loadUserJourney() { + if (userJourney) return userJourney; + + const result = await esbuild.build({ + entryPoints: ["src/store/user-journey.ts"], + bundle: true, + format: "cjs", + platform: "browser", + target: "es2020", + write: false, + }); + const module = { exports: {} }; + new Function("module", "exports", result.outputFiles[0].text)(module, module.exports); + userJourney = module.exports; + return userJourney; +} + +function installBrowserGlobals({ + url, + referrer, + documentAvailable = true, + navigatorValue = undefined, +}) { + const originals = new Map(); + const setGlobal = (name, value) => { + originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name)); + Object.defineProperty(globalThis, name, { + configurable: true, + writable: true, + value, + }); + }; + let cookie = ""; + + setGlobal("window", { location: new URL(url) }); + if (documentAvailable) { + const document = { referrer }; + Object.defineProperty(document, "cookie", { + get: () => cookie, + set: (value) => { cookie = value; }, + }); + setGlobal("document", document); + } else { + setGlobal("document", undefined); + } + setGlobal("localStorage", { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + }); + setGlobal("navigator", navigatorValue); + + return () => { + for (const [name, descriptor] of originals) { + if (descriptor) Object.defineProperty(globalThis, name, descriptor); + else delete globalThis[name]; + } + }; +} + +function createLogger() { + return { info: () => {}, warn: () => {}, error: () => {} }; +} + +async function captureInitialPageView(options) { + const restore = installBrowserGlobals(options); + const originalFetch = globalThis.fetch; + const events = []; + globalThis.fetch = async (_url, init) => { + events.push(JSON.parse(init.body)); + return { ok: true, json: async () => ({ data: {} }) }; + }; + + try { + const { initializeUserJourneyTracking } = await loadUserJourney(); + initializeUserJourneyTracking("env_123", createLogger(), () => null, () => {}); + await new Promise((resolve) => setImmediate(resolve)); + return events[0]; + } finally { + globalThis.fetch = originalFetch; + restore(); + } +} + +test("initial page view sends the browser referrer and retains UTM/click-ID URL data", async () => { + const url = "https://example.com/landing?utm_source=google&utm_medium=organic&gclid=click-id"; + const event = await captureInitialPageView({ + url, + referrer: "https://www.google.com/", + }); + + assert.equal(event.data.type, "page_view"); + assert.deepEqual(event.data.payload.url, url); + assert.equal(event.data.payload.referrer, "https://www.google.com/"); + assert.equal(event.metadata.environmentId, "env_123"); +}); + +test("direct visits retain an empty referrer without fabricating attribution", async () => { + const event = await captureInitialPageView({ + url: "https://example.com/landing", + referrer: "", + }); + + assert.equal(event.data.payload.referrer, ""); + assert.equal("channel" in event.data.payload, false); + assert.equal("source" in event.data.payload, false); +}); + +test("page-view tracking remains safe when document is unavailable", async () => { + const event = await captureInitialPageView({ + url: "https://example.com/landing", + documentAvailable: false, + }); + + assert.equal(event.data.payload.referrer, ""); +}); + +test("sendBeacon serializes a page-view referrer unchanged", async () => { + let serializedPayload; + const restore = installBrowserGlobals({ + url: "https://example.com/landing", + referrer: "https://www.google.com/", + navigatorValue: { + sendBeacon: (_url, body) => { + serializedPayload = body.text(); + return true; + }, + }, + }); + + try { + const { trackToRedis } = await loadUserJourney(); + await trackToRedis( + { + data: { + type: "page_view", + payload: { + url: "https://example.com/landing", + timestamp: "2026-07-13T00:00:00.000Z", + referrer: "https://www.google.com/", + }, + }, + metadata: {}, + }, + createLogger(), + () => "journey_123", + () => {} + ); + + const event = JSON.parse(await serializedPayload); + assert.equal(event.data.payload.referrer, "https://www.google.com/"); + } finally { + restore(); + } +}); + +test("SPA page views include the browser referrer", async () => { + const restore = installBrowserGlobals({ + url: "https://example.com/next?utm_campaign=summer", + referrer: "https://www.google.com/", + }); + const originalFetch = globalThis.fetch; + const events = []; + globalThis.fetch = async (_url, init) => { + events.push(JSON.parse(init.body)); + return { ok: true, json: async () => ({ data: {} }) }; + }; + + try { + const { updateUserJourneyOnRouteChange } = await loadUserJourney(); + updateUserJourneyOnRouteChange( + "env_123", + "https://example.com/next?utm_campaign=summer", + createLogger(), + () => null, + () => {} + ); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(events[0].data.payload.referrer, "https://www.google.com/"); + assert.equal(events[0].data.payload.url, "https://example.com/next?utm_campaign=summer"); + } finally { + globalThis.fetch = originalFetch; + restore(); + } +}); From 4121f8e42c112d0ac715eb968a68ecbc20d60b5a Mon Sep 17 00:00:00 2001 From: Amin Hakem Date: Wed, 15 Jul 2026 10:24:47 -0700 Subject: [PATCH 2/2] test(user-journey): harden beacon coverage --- src/store/user-journey.ts | 2 +- surface_embed_v1.js | 2 +- surface_tag.js | 2 +- test/unit/user-journey.test.js | 5 +++++ 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/store/user-journey.ts b/src/store/user-journey.ts index 174210b..874c206 100644 --- a/src/store/user-journey.ts +++ b/src/store/user-journey.ts @@ -37,7 +37,7 @@ function createPageViewEvent( payload: { url, timestamp: new Date().toISOString(), - // Preserve the browser's native value (including an empty direct visit). + // Native referrer stays fixed to the hard-navigation source across SPA routes. referrer: getBrowserReferrer(), }, }, diff --git a/surface_embed_v1.js b/surface_embed_v1.js index 5af2a95..7115753 100644 --- a/surface_embed_v1.js +++ b/surface_embed_v1.js @@ -347,7 +347,7 @@ payload: { url, timestamp: (/* @__PURE__ */ new Date()).toISOString(), - // Preserve the browser's native value (including an empty direct visit). + // Native referrer stays fixed to the hard-navigation source across SPA routes. referrer: getBrowserReferrer() } }, diff --git a/surface_tag.js b/surface_tag.js index 5af2a95..7115753 100644 --- a/surface_tag.js +++ b/surface_tag.js @@ -347,7 +347,7 @@ payload: { url, timestamp: (/* @__PURE__ */ new Date()).toISOString(), - // Preserve the browser's native value (including an empty direct visit). + // Native referrer stays fixed to the hard-navigation source across SPA routes. referrer: getBrowserReferrer() } }, diff --git a/test/unit/user-journey.test.js b/test/unit/user-journey.test.js index 95e89bb..17b5790 100644 --- a/test/unit/user-journey.test.js +++ b/test/unit/user-journey.test.js @@ -133,6 +133,10 @@ test("sendBeacon serializes a page-view referrer unchanged", async () => { }, }, }); + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + assert.fail("sendBeacon path should have been taken"); + }; try { const { trackToRedis } = await loadUserJourney(); @@ -156,6 +160,7 @@ test("sendBeacon serializes a page-view referrer unchanged", async () => { const event = JSON.parse(await serializedPayload); assert.equal(event.data.payload.referrer, "https://www.google.com/"); } finally { + globalThis.fetch = originalFetch; restore(); } });