Skip to content

feat(site): ask for analytics consent instead of measuring nothing - #219

Merged
vishr merged 6 commits into
mainfrom
site/consent-gated-analytics
Sep 5, 2026
Merged

feat(site): ask for analytics consent instead of measuring nothing#219
vishr merged 6 commits into
mainfrom
site/consent-gated-analytics

Conversation

@vishr

@vishr vishr commented Sep 4, 2026

Copy link
Copy Markdown
Member

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 _ga cookie 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 /privacy page carries a standing control that states what is currently happening before offering the buttons. Declining actually revokes: it sets the ga-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/trace stubbed to fake locations:

Case Result
loc=US, no stored preference measured, no banner, gtag loaded
loc=DE, no stored preference banner shown, nothing loaded
trace unreachable falls back to asking
Allow stored, banner hides, gtag loads, consent update {analytics_storage: granted}, ads_data_redaction set, Google Signals off
Privacy page, Do not allow status flips, ga-disable- set, _ga cookies deleted
?utm_source=test&secret=leaky page_location reports /start/install; no query string, nothing leaked
build with no measurement id banner absent entirely, privacy page says nothing is measured

No console errors on any path.

  • just check
  • just test-race when auth, API, ingest, query, MCP, or agent paths changed — no Go paths touched; this is site/ only
  • User-facing behavior and configuration docs are current — /privacy is the documentation for this behavior, and PUBLIC_GA_MEASUREMENT_ID is unchanged in meaning and value
  • No credentials, private telemetry, host details, or enterprise-only source are included
  • API, migration, ingest, MCP/AG-UI, or release-contract changes are called out — none

privacy is registered in ORDER in both llms.txt generators, which otherwise fail the build rather than let a documentation section go silently unindexed.

https://claude.ai/code/session_0188fRgp2DyMXzUcJgdMZSyu

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

Copilot AI 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.

🟡 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 /privacy documentation 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.

Comment thread site/src/components/Analytics.astro
`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

Copilot AI 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.

🔵 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_ID is 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

Copilot AI 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.

🔵 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 the Referer header, which undermines the stated goal of not letting query strings leave the browser. Set referrerPolicy on 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

Copilot AI 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.

🔵 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
@vishr

vishr commented Sep 4, 2026

Copy link
Copy Markdown
Member Author

All four Copilot findings are addressed. Three arrived as suppressed comments with no thread to reply on, so recording them here.

Review Finding Commit
1 start() returned early on started, so allow-after-deny was one-way until reload 52455ac8
2 privacy.mdx asserted GA unconditionally while the control can say nothing is configured baaf27a7
4 injected gtag script inherited the browser default referrer policy 03443b04
5 /privacy unreachable from documentation pages where no banner shows 3b86e810

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 /privacy is the only opt-out, and it was linked from the banner and from a splash-only footer — meaning a reader arriving on a docs page had no route to it. There is now a link in the row that already ends every documentation page.

Findings 2 and 4 were consistency and hardening rather than live defects. On 4 specifically: every current browser defaults to strict-origin-when-cross-origin, so no query string was reaching Google; the policy is now stated by the code rather than inherited.

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.

@vishr
vishr merged commit a1d783f into main Sep 5, 2026
8 checks passed
@vishr
vishr deleted the site/consent-gated-analytics branch September 5, 2026 00:25
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.

2 participants