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
11 changes: 11 additions & 0 deletions .changeset/browser-page-url.md
Original file line number Diff line number Diff line change
@@ -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.
52 changes: 42 additions & 10 deletions src/adapters/client/browser-analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends object>(value: T): Partial<T> {
return Object.fromEntries(
Object.entries(value).filter(([, field]) => field !== undefined),
) as Partial<T>;
}

export class BrowserAnalytics<
TRegistry extends EventRegistry<EventDefinitions>,
TUserTraits extends object = Record<string, unknown>,
Expand Down Expand Up @@ -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(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the new page fields during initialization

When initialization passes this snapshot to updateContext, that method reconstructs page using only path, title, and referrer, silently discarding url, search, host, and protocol. Consequently, subsequent track() and pageLeave() calls still receive no campaign-bearing URL; only the immediate pageView() provider call works because it separately substitutes the local page snapshot. Merge every declared page field into the stored context instead.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in d153f99 — thanks, this was a real gap.

updateContext rebuilt page from path, title and referrer only, so the four new fields were discarded the moment they were stored. pageView() masked it by substituting its own local snapshot into the provider call, which is exactly why the adapter test passed while the bug was live; track() and pageLeave() read this.context and still saw no URL.

It now merges the supplied fields over the stored ones. Two details worth noting:

  • Only fields the caller actually supplied may overwrite, so a partial update such as updateContext({ page: { path } }) no longer erases a stored url. A plain spread would have let an undefined wipe it.
  • search is kept as a real value when empty. A truthy fallback would have made a URL with no query string inherit the previous page's parameters.

Two regression tests cover it: track() after pageView() carries page.url, and partial updates neither erase nor go stale. Both fail against the previous updateContext — verified by reverting just that hunk.

device: {
type: this.getDeviceType(),
os: this.getOS(),
Expand Down Expand Up @@ -675,11 +681,7 @@ export class BrowserAnalytics<
pageView(properties?: Record<string, unknown>): 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;
Expand Down Expand Up @@ -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 ||
Expand All @@ -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<EventContext<TUserTraits>["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)) {
Expand Down
108 changes: 108 additions & 0 deletions test/client-analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
20 changes: 20 additions & 0 deletions test/openpanel-client-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading