Fix UTM tracking when canonical link present - #260
Conversation
UTM params must come from the visited URL, not the canonical one. fixes benvinegar#259 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WCVeuQZukeTcQn42dHemB8
stordahl
left a comment
There was a problem hiding this comment.
Thanks for the report and the fix — the diagnosis is correct and the new integration test is a good reproduction of #259. Requesting changes for the readability of the call site: the behavior is right, but the code reads ambiguously.
The change
track.ts:91-95 now has two adjacent fallback chains with different second legs:
const url = opts.url || location.pathname + location.search || "/"; // canonical-aware
...
const utmParams = getUtmParamsFromBrowserUrl(
opts.url || window.location.search, // canonical-agnostic
);Concretely:
-
A reader sees
opts.url ||twice and assumes the second arg is justurl. When they notice the fallback differs, they can't tell intentional from typo — nothing signals that canonical hrefs typically strip the query string, so UTMs must deliberately bypass the canonical preference. A comment is needed. -
The
locationalias (lines 75-76) exists specifically to route everything through the canonical preference, and this change makes line 95 the one silent exception reaching past it towindow.location. A future cleanup "fixing" it tolocation.searchwould reintroduce #259. -
Name/arg mismatch:
getUtmParamsFromBrowserUrlis now called with either a caller-supplied full URL or a bare?utm=...search string — neither is necessarily "the browser URL". It works only because the helper grabs everything after the first?. utils.ts also already has two near-identical helpers (getUtmParamsFromUrl,getUtmParamsFromBrowserUrl); this call site passing a full URL in one branch deepens the ambiguity of which is canonical.
Suggested change (behavior-identical)
// Canonical hrefs typically omit the query string, so UTM params must come
// from the URL the visitor actually landed on, not the canonical URL.
const utmSource = opts.url || window.location.search;
const utmParams = getUtmParamsFromBrowserUrl(utmSource);Plus:
- Rename the helper (e.g.
extractUtmParams) so the contract reads as "full URL or search string" — it already handles both. Document it in the JSDoc. - Add a unit test in
src/shared/__tests__/utils.test.tsfor the bare search-string shape ("?utm_source=google"), which is the new call contract but is currently only covered by the integration test. - Optionally: a unit test in
track.spec.tsfor canonical-present + UTM-in-location. The suite mocksquerySelectorto returnnulltoday, so the canonical interaction is only covered by the integration test.
Non-blocking FYI
A visit with only utm_source=google will now be recorded both as a referrer (the referrerParams fallback at track.ts:52-59) and as us=google. That's pre-existing semantics, but this fix amplifies it — worth a follow-up thought.
Rename getUtmParamsFromBrowserUrl to extractUtmParams and document that it accepts a full URL, a path, or a bare search string. Add unit tests for the bare search-string shape and for canonical-present + UTM-in-location. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KuQyKBiQGy2qNMSv3pxdAP
Overview
Change
trackPageviewso that the UTM parameters are extracted from the actual browser URL instead of any<link rel="canonical">URL on the page. This way the UTM parameters in the link clicked by the user are not silently lost.Changes by Package
@counterscale/tracker
trackPageviewnow usesopts.url(if present) orwindow.location.urlas the source of UTM parameters, not any<link rel="canonical">URL on the page.Additional Notes
fixes #259