From 584b930c88f30de46bc11a5a726e8d54b267b0df Mon Sep 17 00:00:00 2001 From: Chris Jayden Date: Tue, 15 Sep 2026 09:56:52 +0200 Subject: [PATCH 1/3] fix: keep campaign parameters in browser page context BrowserAnalytics built its page context from window.location.pathname alone, so the query string never left the browser. Providers that report a URL read context.page.url and fall back to context.page.path, and that fallback was always taken, which dropped every utm_* parameter before delivery and made campaign traffic arrive as direct. Populate the url, search, host and protocol fields that EventContext["page"] already declared, from one getPageContext() snapshot shared by initialize() and pageView(). OpenPanel's screenView() now receives the full URL, matching what its own SDK sends when it tracks screen views itself. --- .changeset/browser-page-url.md | 7 +++++ src/adapters/client/browser-analytics.ts | 35 +++++++++++++++++------- test/client-analytics.test.ts | 35 ++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 10 deletions(-) create mode 100644 .changeset/browser-page-url.md diff --git a/.changeset/browser-page-url.md b/.changeset/browser-page-url.md new file mode 100644 index 0000000..5238ede --- /dev/null +++ b/.changeset/browser-page-url.md @@ -0,0 +1,7 @@ +--- +"trakoo": minor +--- + +Report the full page URL from the browser adapter so campaign parameters survive. `BrowserAnalytics` built its page context from `window.location.pathname` alone, so the query string never left the browser and every `utm_source`/`utm_medium`/`utm_campaign` value was dropped before delivery — campaign traffic arrived in the dashboard as direct. The adapter now also populates the `url`, `search`, `host` and `protocol` fields that `EventContext["page"]` already declared, from a single `getPageContext()` snapshot shared by `initialize()` and `pageView()`. + +This reaches any provider that reports a URL. OpenPanel, Bento, EmitKit, Pirsch and the proxy all read `context.page.url` and fall back to `context.page.path`; until now that fallback was always taken. OpenPanel's `screenView()` consequently receives the full URL, which is what its own SDK sends when it tracks screen views itself, so its `__path` changes from a bare pathname to an absolute URL and its dashboard resolves the path and domain server-side. Nothing new is collected — the query string was always present in the browser — but a site that puts sensitive values in query parameters now sends them to its analytics provider, so exclude those before they reach the URL. diff --git a/src/adapters/client/browser-analytics.ts b/src/adapters/client/browser-analytics.ts index 3c8a2e7..fdd5648 100644 --- a/src/adapters/client/browser-analytics.ts +++ b/src/adapters/client/browser-analytics.ts @@ -327,11 +327,7 @@ export class BrowserAnalytics< // Set browser context this.updateContext({ - page: { - path: window.location.pathname, - title: document.title, - referrer: document.referrer, - }, + page: this.getPageContext(), device: { type: this.getDeviceType(), os: this.getOS(), @@ -675,11 +671,7 @@ export class BrowserAnalytics< pageView(properties?: Record): void { if (!this.enabled) return; - const page = { - path: window.location.pathname, - title: document.title, - referrer: document.referrer, - }; + const page = this.getPageContext(); this.updateContext({ page }); const propertiesSnapshot = properties; @@ -988,6 +980,29 @@ export class BrowserAnalytics< return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; } + /** + * Snapshot the current page for event context. + * + * `url` carries the full `location.href`, query string included, because + * that is where campaign parameters live. Providers that report a URL + * (OpenPanel, Bento, EmitKit, the proxy) read `page.url` and fall back to + * `page.path`; sending only the pathname silently dropped every `utm_*` + * parameter before it left the browser, so campaign traffic arrived + * unattributed. OpenPanel's own SDK defaults `screenView()` to + * `location.href` for the same reason. + */ + private getPageContext(): NonNullable["page"]> { + return { + path: window.location.pathname, + url: window.location.href, + search: window.location.search, + host: window.location.host, + protocol: window.location.protocol, + title: document.title, + referrer: document.referrer, + }; + } + private getDeviceType(): string { const userAgent = navigator.userAgent; if (/tablet|ipad|playbook|silk/i.test(userAgent)) { diff --git a/test/client-analytics.test.ts b/test/client-analytics.test.ts index 6348f50..c7c9a63 100644 --- a/test/client-analytics.test.ts +++ b/test/client-analytics.test.ts @@ -399,6 +399,41 @@ describe("Client Analytics", () => { ); }); + it("keeps campaign parameters in the page URL it reports", async () => { + const original = window.location; + Object.defineProperty(window, "location", { + value: { + pathname: "/product", + search: "?utm_source=landing.gallery&utm_medium=ad", + href: "https://example.com/product?utm_source=landing.gallery&utm_medium=ad", + host: "example.com", + protocol: "https:", + }, + writable: true, + }); + + try { + analytics.pageView(); + await vi.waitFor(() => { + expect(mockProvider.calls.pageView).toHaveLength(1); + }); + + const page = mockProvider.calls.pageView[0].context?.page; + // Providers report `page.url` and fall back to `page.path`, so the + // query string has to survive or every utm_* parameter is lost. + expect(page?.url).toBe( + "https://example.com/product?utm_source=landing.gallery&utm_medium=ad", + ); + expect(page?.search).toBe("?utm_source=landing.gallery&utm_medium=ad"); + expect(page?.path).toBe("/product"); + } finally { + Object.defineProperty(window, "location", { + value: original, + writable: true, + }); + } + }); + it("resets the session and clears user context", async () => { analytics.identify("user-123", { email: "test@example.com" }); await analytics.track("before_reset"); From a9f315a49c100175b798b2ddad084210107cb30c Mon Sep 17 00:00:00 2001 From: Chris Jayden Date: Tue, 15 Sep 2026 09:58:54 +0200 Subject: [PATCH 2/3] test: assert OpenPanel receives the full page URL The adapter test proves page.url is emitted; this covers the other half, that the OpenPanel provider prefers it over page.path and forwards the query string to screenView. Also note in the changeset that counts move on routes that call pageView() when query parameters change. --- .changeset/browser-page-url.md | 4 +++- test/openpanel-client-provider.test.ts | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/.changeset/browser-page-url.md b/.changeset/browser-page-url.md index 5238ede..c48a5ff 100644 --- a/.changeset/browser-page-url.md +++ b/.changeset/browser-page-url.md @@ -4,4 +4,6 @@ Report the full page URL from the browser adapter so campaign parameters survive. `BrowserAnalytics` built its page context from `window.location.pathname` alone, so the query string never left the browser and every `utm_source`/`utm_medium`/`utm_campaign` value was dropped before delivery — campaign traffic arrived in the dashboard as direct. The adapter now also populates the `url`, `search`, `host` and `protocol` fields that `EventContext["page"]` already declared, from a single `getPageContext()` snapshot shared by `initialize()` and `pageView()`. -This reaches any provider that reports a URL. OpenPanel, Bento, EmitKit, Pirsch and the proxy all read `context.page.url` and fall back to `context.page.path`; until now that fallback was always taken. OpenPanel's `screenView()` consequently receives the full URL, which is what its own SDK sends when it tracks screen views itself, so its `__path` changes from a bare pathname to an absolute URL and its dashboard resolves the path and domain server-side. Nothing new is collected — the query string was always present in the browser — but a site that puts sensitive values in query parameters now sends them to its analytics provider, so exclude those before they reach the URL. +This reaches any provider that reports a URL. OpenPanel, Bento, EmitKit, Pirsch and the proxy all read `context.page.url` and fall back to `context.page.path`; until now that fallback was always taken. OpenPanel's `screenView()` consequently receives the full URL, which is what its own SDK sends when it tracks screen views itself, so its `__path` changes from a bare pathname to an absolute URL and its dashboard resolves the path and domain server-side. OpenPanel's `screenView()` also dedupes on the value it is handed, so two visits to one pathname under different query strings are now distinct: an app that calls `pageView()` when query parameters change — filters, pagination, tabs — emits one `screen_view` per change where it previously emitted one in total. Expect page-view counts on those routes to rise. + +Nothing new is collected — the query string was always present in the browser — but a site that puts sensitive values in query parameters now sends them to its analytics provider, so exclude those before they reach the URL. diff --git a/test/openpanel-client-provider.test.ts b/test/openpanel-client-provider.test.ts index 2b28def..905e4e1 100644 --- a/test/openpanel-client-provider.test.ts +++ b/test/openpanel-client-provider.test.ts @@ -172,6 +172,26 @@ describe("OpenPanelClientProvider", () => { }); }); + it("sends the full page URL so campaign parameters reach OpenPanel", async () => { + const provider = new OpenPanelClientProvider({ clientId: "client-id" }); + await provider.initialize(); + + const url = "https://example.com/product?utm_source=landing.gallery"; + provider.pageView(undefined, { + page: { path: "/product", url, title: "Product" }, + }); + + await vi.waitFor(() => { + expect(sdk.screenView).toHaveBeenCalledOnce(); + }); + // `page.url` wins over `page.path`: OpenPanel resolves the path and + // domain server-side, and only the full URL carries the utm_* values. + expect(sdk.screenView).toHaveBeenCalledWith( + url, + expect.objectContaining({ __path: url }), + ); + }); + it("tracks events with OpenPanel and Trakoo context", async () => { const provider = new OpenPanelClientProvider({ clientId: "client-id" }); await provider.initialize(); From d153f99bf28ae466a5bdda3bcc2cbb10ff613354 Mon Sep 17 00:00:00 2001 From: Chris Jayden Date: Tue, 15 Sep 2026 10:13:46 +0200 Subject: [PATCH 3/3] fix: merge page snapshot in updateContext updateContext rebuilt page from path, title and referrer only, so the url, search, host and protocol fields were discarded as soon as they were stored. pageView() substitutes its own local snapshot and so kept working, but track() and pageLeave() read the stored context and still received no campaign-bearing URL. Merge the supplied fields over the stored ones instead. Only fields the caller actually provided may overwrite, so a partial update no longer erases what it omits, and search is treated as a real value when empty rather than falling back to the previous page's query string. Reported by Codex review on #37. --- .changeset/browser-page-url.md | 2 + src/adapters/client/browser-analytics.ts | 17 ++++++ test/client-analytics.test.ts | 73 ++++++++++++++++++++++++ 3 files changed, 92 insertions(+) diff --git a/.changeset/browser-page-url.md b/.changeset/browser-page-url.md index c48a5ff..1ee81b3 100644 --- a/.changeset/browser-page-url.md +++ b/.changeset/browser-page-url.md @@ -4,6 +4,8 @@ Report the full page URL from the browser adapter so campaign parameters survive. `BrowserAnalytics` built its page context from `window.location.pathname` alone, so the query string never left the browser and every `utm_source`/`utm_medium`/`utm_campaign` value was dropped before delivery — campaign traffic arrived in the dashboard as direct. The adapter now also populates the `url`, `search`, `host` and `protocol` fields that `EventContext["page"]` already declared, from a single `getPageContext()` snapshot shared by `initialize()` and `pageView()`. +`updateContext()` also merges the page snapshot instead of rebuilding it from `path`, `title` and `referrer`, which had silently discarded every other declared field. Without that, only the immediate `pageView()` call carried a URL — `track()` and `pageLeave()` read the stored context and still saw none. Partial page updates now merge rather than erase, and an empty `search` is kept as a real value so a URL with no query string cannot inherit the previous page's parameters. + This reaches any provider that reports a URL. OpenPanel, Bento, EmitKit, Pirsch and the proxy all read `context.page.url` and fall back to `context.page.path`; until now that fallback was always taken. OpenPanel's `screenView()` consequently receives the full URL, which is what its own SDK sends when it tracks screen views itself, so its `__path` changes from a bare pathname to an absolute URL and its dashboard resolves the path and domain server-side. OpenPanel's `screenView()` also dedupes on the value it is handed, so two visits to one pathname under different query strings are now distinct: an app that calls `pageView()` when query parameters change — filters, pagination, tabs — emits one `screen_view` per change where it previously emitted one in total. Expect page-view counts on those routes to rise. Nothing new is collected — the query string was always present in the browser — but a site that puts sensitive values in query parameters now sends them to its analytics provider, so exclude those before they reach the URL. diff --git a/src/adapters/client/browser-analytics.ts b/src/adapters/client/browser-analytics.ts index fdd5648..f99e64c 100644 --- a/src/adapters/client/browser-analytics.ts +++ b/src/adapters/client/browser-analytics.ts @@ -43,6 +43,16 @@ interface NormalizedProviderConfig { eventPatterns?: RegExp[]; } +/** + * Drop keys whose value is `undefined` so a partial update merges into the + * stored context instead of erasing fields it never mentioned. + */ +function definedFields(value: T): Partial { + return Object.fromEntries( + Object.entries(value).filter(([, field]) => field !== undefined), + ) as Partial; +} + export class BrowserAnalytics< TRegistry extends EventRegistry, TUserTraits extends object = Record, @@ -957,6 +967,13 @@ export class BrowserAnalytics< ...context, page: context.page ? { + ...this.context.page, + // Only fields the caller actually supplied may overwrite the + // stored snapshot. Spreading raw would let an `undefined` in a + // partial update erase a field, and `search` is legitimately + // "" on a URL with no query string, so it cannot use a truthy + // fallback without resurrecting the previous page's params. + ...definedFields(context.page), path: context.page.path || this.context.page?.path || diff --git a/test/client-analytics.test.ts b/test/client-analytics.test.ts index c7c9a63..40eb713 100644 --- a/test/client-analytics.test.ts +++ b/test/client-analytics.test.ts @@ -434,6 +434,79 @@ describe("Client Analytics", () => { } }); + it("keeps the page URL in stored context for later events", async () => { + const original = window.location; + Object.defineProperty(window, "location", { + value: { + pathname: "/product", + search: "?utm_source=landing.gallery", + href: "https://example.com/product?utm_source=landing.gallery", + host: "example.com", + protocol: "https:", + }, + writable: true, + }); + + try { + analytics.pageView(); + analytics.track("button_clicked", { + buttonId: "cta", + label: "Start", + }); + await vi.waitFor(() => { + expect(mockProvider.calls.track).toHaveLength(1); + }); + + // pageView() substitutes its own local snapshot, so it would pass even + // if updateContext dropped the field. track() and pageLeave() read the + // stored context, which is the half that actually regressed. + expect(mockProvider.calls.track[0].context?.page?.url).toBe( + "https://example.com/product?utm_source=landing.gallery", + ); + } finally { + Object.defineProperty(window, "location", { + value: original, + writable: true, + }); + } + }); + + it("merges partial page updates without erasing or staling fields", async () => { + analytics.updateContext({ + page: { + path: "/a", + url: "https://example.com/a?ref=x", + search: "?ref=x", + title: "A", + }, + }); + + // A partial update must not erase url just by omitting it. + analytics.updateContext({ page: { path: "/a", title: "A renamed" } }); + analytics.track("test_event", { test: true }); + await vi.waitFor(() => { + expect(mockProvider.calls.track).toHaveLength(1); + }); + expect(mockProvider.calls.track[0].context?.page?.url).toBe( + "https://example.com/a?ref=x", + ); + expect(mockProvider.calls.track[0].context?.page?.title).toBe("A renamed"); + + // An empty search is a real value, not a missing one: navigating to a URL + // with no query string must not keep the previous page's parameters. + analytics.updateContext({ + page: { path: "/b", url: "https://example.com/b", search: "" }, + }); + analytics.track("test_event", { test: true }); + await vi.waitFor(() => { + expect(mockProvider.calls.track).toHaveLength(2); + }); + expect(mockProvider.calls.track[1].context?.page?.search).toBe(""); + expect(mockProvider.calls.track[1].context?.page?.url).toBe( + "https://example.com/b", + ); + }); + it("resets the session and clears user context", async () => { analytics.identify("user-123", { email: "test@example.com" }); await analytics.track("before_reset");