diff --git a/.changeset/browser-page-url.md b/.changeset/browser-page-url.md new file mode 100644 index 0000000..1ee81b3 --- /dev/null +++ b/.changeset/browser-page-url.md @@ -0,0 +1,11 @@ +--- +"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()`. + +`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 3c8a2e7..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, @@ -327,11 +337,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 +681,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; @@ -965,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 || @@ -988,6 +997,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..40eb713 100644 --- a/test/client-analytics.test.ts +++ b/test/client-analytics.test.ts @@ -399,6 +399,114 @@ 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("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"); 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();