feat(site): ask for analytics consent instead of measuring nothing - #219
Conversation
The site loaded gtag on every page and then held it in Consent Mode's denied state. That is the worst of both halves: Google's tag shipped to every reader, and no `_ga` cookie to make its numbers mean anything. Every pageview arrived as a new user in a new session, so users, sessions, bounce rate and returning visitors were all noise, and no amount of reading the reports would have said so. Nothing is loaded now until measurement is allowed, and when it loads the consent it was denied is granted, so the identifier exists and the sessions are real. Whether permission must be asked for is a question about the reader's location rather than about this site. The EEA, the UK and the Crown Dependencies require it; elsewhere measurement may run with a way to turn it off. Cloudflare already knows which applies and says so at `/cdn-cgi/trace`, so most readers are measured properly and never see a banner, and the readers who are owed the question get it. A failed lookup asks — the safe direction, and what a build outside Cloudflare gets. Do Not Track and Global Privacy Control sit above all of it, including above the site's own controls: a reader who has answered this in their browser is not asked again and is not measured either way. Withdrawal is the half that is easy to leave out, so `/privacy` carries a standing control that states what is currently happening before offering the buttons, and declining actually revokes: it sets the `ga-disable-` flag, pushes consent back to denied, and deletes the cookies acceptance set. Permission that cannot be withdrawn as readily as it was given was never permission. Two things are cut down before they leave the browser. Query strings are dropped from the recorded address, because a link can put anything after the `?` and the path is the whole of what this site wants to know. The referrer is reduced to its origin, so the site a reader came from is recorded and their search terms are not. The preference lives in localStorage rather than a cookie, so declining stores nothing that travels with a request, and it is mirrored across tabs. The banner renders on every page rather than only where the site footer does: a reader arriving from a search result lands on a documentation page, and a prompt confined to the landing page asks the question of the wrong half of the audience. `privacy` joins ORDER in both llms.txt generators, which otherwise fail the build rather than let a section go silently unindexed. Claude-Session: https://claude.ai/code/session_0188fRgp2DyMXzUcJgdMZSyu
There was a problem hiding this comment.
🟡 Changes recommended
In-page toggling from “denied” back to “granted” can fail because start() returns early once started is true, leaving ga-disable-* stuck enabled until reload.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR updates the site’s Google Analytics behavior to be consent-gated (instead of loading gtag everywhere in “denied” mode), adds an explicit privacy page with an ongoing analytics preference control, and ensures the privacy docs are included in llms.txt outputs.
Changes:
- Gate GA loading behind per-visitor consent/requirement (via
/cdn-cgi/trace), honoring DNT/GPC and reducing URL/referrer detail. - Add
/privacydocumentation and UI to view/change analytics preference after the initial prompt. - Link the new privacy page from the site footer and ensure llms.txt generators include it.
File summaries
| File | Description |
|---|---|
| site/src/pages/llms.txt.ts | Adds privacy to the explicit documentation ordering and group titles. |
| site/src/pages/llms-full.txt.ts | Adds privacy to the explicit documentation ordering for the full llms output. |
| site/src/content/docs/privacy.mdx | New privacy page describing analytics behavior and providing the preference control. |
| site/src/components/SiteFooter.astro | Adds a footer link to /privacy and updates footer rationale comments. |
| site/src/components/PageFrame.astro | Renders the analytics consent banner markup on all pages. |
| site/src/components/AnalyticsPreference.astro | New “standing” control to inspect/change analytics preference on /privacy. |
| site/src/components/Analytics.astro | Refactors analytics to load only when allowed; adds consent requirement detection and preference management. |
Review details
- Files reviewed: 8/8 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
`start()` returned early whenever `started` was true, which was also the state left behind after `stop()`. So the pair of buttons was one-way for the life of the page: declining set the `ga-disable-` flag and pushed consent back to denied, and allowing again cleared neither, because the one function that clears them had already decided it had nothing to do. The early return was guarding two different things — configuring the tag twice, which must not happen, and re-enabling measurement, which must. Clearing the disable flag now happens before the guard, and a tag that is already loaded takes a branch that re-grants consent, which is what the port dropped: the component this was ported from has the same branch. Claude-Session: https://claude.ai/code/session_0188fRgp2DyMXzUcJgdMZSyu
`--sl-color-white` names a role rather than a colour: under the light theme it resolves to near-black, because it is the token that contrasts with the page. Setting it as the label on an accent fill therefore drew dark text on mid-blue in daylight, and only looked right in the dark. Both buttons now use the shape the landing page's primary action already uses — the contrasting token as the fill, the page token as the label — which inverts correctly in both themes and gives consent the same visual weight as the other primary action on the site. Claude-Session: https://claude.ai/code/session_0188fRgp2DyMXzUcJgdMZSyu
There was a problem hiding this comment.
🔵 Needs a closer look
The new /privacy page text currently contradicts the “no measurement id” behavior and the PR’s own verification note, so the documentation can be misleading in builds without analytics configured.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
site/src/content/docs/privacy.mdx:23
- This section states unconditionally that “The site uses Google Analytics 4…”, but AnalyticsPreference can render a “no analytics configured, so nothing is measured” message when
PUBLIC_GA_MEASUREMENT_IDis unset or the build isn’t production. That makes the privacy page self-contradictory in those builds and also conflicts with the PR description’s “build with no measurement id” verification note. Consider wording this paragraph to make it conditional on analytics being configured for the current build.
- Files reviewed: 8/8 changed files
- Comments generated: 0 new
- Review effort level: Lite
The page opened by stating that the site uses Google Analytics, and then rendered a control that, in a build with no measurement id, says nothing is measured and there is nothing to turn off. Both sentences shipped together, so the page contradicted itself for anyone building the site without analytics configured — a fork, or a local preview. The claim now belongs to the component that already knows the answer, beside the control it describes. A build with analytics states what it does; a build without one says so and frames the rest of the section as describing the other case. Neither can drift from the other, because they are the two halves of one branch. Claude-Session: https://claude.ai/code/session_0188fRgp2DyMXzUcJgdMZSyu
There was a problem hiding this comment.
🔵 Needs a closer look
The injected GA script should explicitly set a restrictive referrer policy to ensure query strings can’t leak via the Referer header on cross-origin requests.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
site/src/components/Analytics.astro:191
- The dynamically injected gtag
<script>doesn’t set a referrer policy. On older/default referrer policies, cross-origin requests can include the full page URL (including query string) in theRefererheader, which undermines the stated goal of not letting query strings leave the browser. SetreferrerPolicyon the injected script to ensure only the origin is sent.
- Files reviewed: 8/8 changed files
- Comments generated: 0 new
- Review effort level: Lite
The file promises that query strings do not leave the browser, and then loaded gtag from a script element whose referrer behaviour was whatever the reader's browser defaulted to. Every current browser defaults to strict-origin-when-cross-origin and so already sends only the origin, but a promise that holds because of someone else's default is not the same as one the code makes. Claude-Session: https://claude.ai/code/session_0188fRgp2DyMXzUcJgdMZSyu
There was a problem hiding this comment.
🔵 Needs a closer look
The opt-out control is described as the mechanism for “not_required” locations, but /privacy is not discoverably linked from documentation pages in those locations (no banner + splash-only footer), so the stated behavior isn’t reliably reachable in-site.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
site/src/components/SiteFooter.astro:27
- The new /privacy page is the only opt-out path for readers in "not_required" locations (no banner shown), but /privacy is currently only linked from the splash-page-only SiteFooter and from the consent banner (which won’t show in those locations). That means a reader landing directly on documentation pages in those locations may have no in-site navigation to reach the opt-out control.
- Files reviewed: 8/8 changed files
- Comments generated: 0 new
- Review effort level: Lite
Outside the regions that require a consent prompt no banner is ever shown, which makes the control on /privacy the only way to turn analytics off. The two links to that page were the banner, which those readers never see, and SiteFooter, which renders on splash pages alone. A reader who arrives from a search result lands on a documentation page and had no route to the control at all — the opt-out existed and was unreachable from where most readers actually are. One text link in the row that already ends a documentation page, beside the edit link and the date. Not the whole footer band: the objection to that here was duplicated navigation and social icons landing under the fixed right sidebar, and neither applies to a single word. Claude-Session: https://claude.ai/code/session_0188fRgp2DyMXzUcJgdMZSyu
|
All four Copilot findings are addressed. Three arrived as suppressed comments with no thread to reply on, so recording them here.
Finding 1 was a genuine bug and the fix is verified in the browser on the exact sequence described. Finding 5 was the other substantive one: outside the prompt regions the banner never appears, so the control on Findings 2 and 4 were consistency and hardening rather than live defects. On 4 specifically: every current browser defaults to Not re-requesting further reviews. Reviews 2, 4 and 5 were each labelled "Previously missed — in code that has not changed since the last review", so the pass surfaces a new observation from the same unchanged files each time rather than converging. |
What changed
The site loaded gtag on every page and then held it in Consent Mode's denied state — the worst of both halves. Google's tag shipped to every reader, and without an
_gacookie every pageview arrived as a new user in a new session, so users, sessions, bounce rate and returning visitors were all noise. Nothing in the reports said so.Nothing loads now until measurement is allowed, and when it loads the consent it was previously denied is granted, so the identifier exists and the numbers mean something.
Whether to ask is a question about the reader's location, not about this site. The EEA, the UK and the Crown Dependencies require permission; elsewhere measurement may run with a way to turn it off. Cloudflare already reports the country at
/cdn-cgi/trace, so most readers are measured properly and never see a banner, and the readers who are owed the question get it. A failed lookup asks.Do Not Track and Global Privacy Control sit above all of it, including above the site's own controls.
Withdrawal is the half that is easy to leave out, so a new
/privacypage carries a standing control that states what is currently happening before offering the buttons. Declining actually revokes: it sets thega-disable-flag, pushes consent back to denied, and deletes the cookies acceptance set.Two things are cut down before they leave the browser. Query strings are dropped from the recorded address — a link can put anything after the
?, and the path is the whole of what this site wants to know. The referrer is reduced to its origin, so where a reader came from is recorded and what they searched for is not.The preference lives in localStorage rather than a cookie, so declining stores nothing that travels with a request.
Verification
Built with a real measurement id and driven in Chrome against
astro preview, with/cdn-cgi/tracestubbed to fake locations:loc=US, no stored preferenceloc=DE, no stored preferenceconsent update {analytics_storage: granted},ads_data_redactionset, Google Signals offga-disable-set,_gacookies deleted?utm_source=test&secret=leakypage_locationreports/start/install; no query string, nothing leakedNo console errors on any path.
just checkjust test-racewhen auth, API, ingest, query, MCP, or agent paths changed — no Go paths touched; this issite/only/privacyis the documentation for this behavior, andPUBLIC_GA_MEASUREMENT_IDis unchanged in meaning and valueprivacyis registered inORDERin both llms.txt generators, which otherwise fail the build rather than let a documentation section go silently unindexed.https://claude.ai/code/session_0188fRgp2DyMXzUcJgdMZSyu