Skip to content

fix: keep campaign parameters in browser page context - #37

Merged
multiplehats merged 3 commits into
mainfrom
fix/browser-page-url
Sep 15, 2026
Merged

multiplehats merged 3 commits into
mainfrom
fix/browser-page-url

Conversation

@multiplehats

Copy link
Copy Markdown
Owner

Problem

BrowserAnalytics built its page context from window.location.pathname alone:

const page = {
  path: window.location.pathname,
  title: document.title,
  referrer: document.referrer,
};

Providers that report a URL read context.page.url and fall back to context.page.path — and because the adapter never populated url, that fallback was always taken. The query string never left the browser, so every utm_source / utm_medium / utm_campaign value was dropped before delivery and campaign traffic arrived as direct.

Consuming apps could not work around it: pageView() overwrites the whole page object via updateContext({ page }), clobbering any page.url a caller sets beforehand, and its only parameter is properties, not context.

Evidence

Found while diagnosing a real OpenPanel project where paid referral clicks were landing but showing no source. Both attribution channels were empty for the same traffic:

Panel Value Views Sessions
Referrer name Direct / Not set 87 17
UTM source Direct / Not set 87 17

The referrer half had a separate cause on the sending site (rel="noreferrer"). This PR fixes the UTM half.

Fix

One getPageContext() snapshot, shared by initialize() and pageView(), populating the url, search, host and protocol fields that EventContext["page"] already declared but nothing ever filled.

This reaches every provider that reports a URL — OpenPanel, Bento, EmitKit, Pirsch and the proxy. The proxy's ingestion validator already allowlists all four fields (hasValidClientContext), so they pass through without a schema change.

On the OpenPanel __path shape

screenView() now receives the full URL, so __path changes from a bare pathname to an absolute URL. This is deliberate and matches OpenPanel's own behaviour — its web SDK defaults screenView() to window.location.href when it tracks screen views itself:

typeof t == "string" ? (n = t, r = e) : (n = window.location.href, r = t)

Passing only the pathname was the deviation. The dashboard resolves path and domain server-side (hence its "Show domain" toggle).

Two behavioural notes, both called out in the changeset:

  • screenView() dedupes on the value it is handed, so one pathname under different query strings is now distinct. An app calling pageView() on query-param changes (filters, pagination, tabs) will emit one screen_view per change rather than one in total — expect 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.

Tests

  • test/client-analytics.test.ts — the adapter emits page.url / page.search with the query string intact. Verified this fails with undefined when the fix is reverted.
  • test/openpanel-client-provider.test.ts — the OpenPanel provider prefers page.url over page.path and forwards the full URL to screenView.

373 tests pass; typecheck and biome lint clean.

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.
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.
@vercel

vercel Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
trakoo Ready Ready Preview Sep 15, 2026 8:14am UTC

@multiplehats

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 15, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-15T08:09:31.424922Z a9f315a Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a9f315a49c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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.

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.
@multiplehats
multiplehats merged commit c67fed9 into main Sep 15, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant