From ded027fef9dd26a04044156ad6ff379558057dac Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Fri, 4 Sep 2026 17:31:26 -0700 Subject: [PATCH 1/6] feat(site): ask for analytics consent instead of measuring nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This file argued that a banner asking permission to do something the site does not need to do would be the failure this product refuses elsewhere, and loaded gtag on every page in Consent Mode's denied state instead. The argument was right about banners and wrong about what it bought. Denied mode still sent a hit for every page view; it sent one with no stable client id, so every view arrived as a new user in a new session. The site shipped Google's tag to every reader and got numbers in which users, sessions, bounce rate and returning readers were all noise — and nothing in the reports said so. Refusing to ask is only principled when the answer is that nothing is collected, and something was. 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. The question is asked only of the readers who are owed it. The EEA, the UK and the Crown Dependencies require permission; 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. A failed lookup asks — the safe direction, and what a build outside Cloudflare gets. Do Not Track and Global Privacy Control keep the position they had, and now sit above the site's own controls too: 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 a new `/privacy` page 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. That page is linked from the row that ends every documentation page as well as from the site footer, because outside the prompt regions no banner appears and the footer renders on splash pages alone — an opt-out only reachable from the landing page is not reachable. The query string was already dropped from the recorded location; the referrer is now reduced to its origin for the same reason, and the injected tag states its own referrer policy rather than inheriting the browser's default. `privacy` joins ORDER in both llms.txt generators, which otherwise fail the build rather than let a section go silently unindexed. Ported from labstack/fanout#219, including the four fixes that landed there after review. Claude-Session: https://claude.ai/code/session_0188fRgp2DyMXzUcJgdMZSyu --- site/src/components/Analytics.astro | 319 +++++++++++++++--- site/src/components/AnalyticsConsent.astro | 148 ++++++++ site/src/components/AnalyticsPreference.astro | 164 +++++++++ site/src/components/Footer.astro | 17 + site/src/components/PageFrame.astro | 2 + site/src/components/SiteFooter.astro | 2 + site/src/content/docs/privacy.mdx | 90 +++++ site/src/pages/llms-full.txt.ts | 2 +- site/src/pages/llms.txt.ts | 3 +- 9 files changed, 700 insertions(+), 47 deletions(-) create mode 100644 site/src/components/AnalyticsConsent.astro create mode 100644 site/src/components/AnalyticsPreference.astro create mode 100644 site/src/content/docs/privacy.mdx diff --git a/site/src/components/Analytics.astro b/site/src/components/Analytics.astro index a6393151..d9b6acb4 100644 --- a/site/src/components/Analytics.astro +++ b/site/src/components/Analytics.astro @@ -2,7 +2,7 @@ // Google Analytics 4, on the terms this product argues for everywhere else. // // A documentation site for a tool whose pitch is "we do not silently claim -// anything" cannot ship tracking that behaves like everyone else's. Four rules +// anything" cannot ship tracking that behaves like everyone else's. Five rules // follow from that, and each is enforced here rather than left to a setting in // a console nobody in this repository can read: // @@ -10,21 +10,42 @@ // That is the state for `npm run dev`, for `just check`, for CI, and for // anyone who builds this site from a fork. Analytics belongs to whoever // runs onebox.run, not to the source. -// 2. Cookieless. `client_storage: "none"` keeps GA4 from writing `_ga`, which -// is what makes a consent banner necessary in the first place. Page views -// still count; returning readers are simply not re-identified. A banner -// asking permission to do something the site does not need to do would be -// the same failure this product refuses elsewhere. +// 2. Nothing loads until measurement is allowed. Not the tag, not a request. // 3. No advertising signals. Google Signals and ad personalisation are off, // so the traffic here is not joined to an advertising profile. // 4. Do Not Track and Global Privacy Control are honoured before the script // loads, not after. A reader who has asked not to be measured generates no -// request to Google at all. +// request to Google at all, and is never asked the question either. +// 5. Withdrawal works. The control on /privacy revokes what acceptance set. // -// The query string is dropped from the recorded location. Documentation URLs -// carry nothing sensitive today, but this repository's whole position on -// secrets is that "nothing sensitive is in there today" is not a property you -// get to assume — verifyURL redacts queries for exactly this reason. +// Rule 2 replaces what this file used to do. It loaded gtag on every page and +// then held it in Consent Mode's denied state, which was described here as +// being cookieless — true, and the reason given was that a banner asking to do +// something the site does not need to do would be the failure this product +// refuses elsewhere. That argument was right about banners and wrong about what +// it bought. Denied mode still sent a hit for every page view; it simply sent +// one with no stable client id, so every view arrived as a new user in a new +// session. The site shipped Google's tag to every reader and got numbers in +// which users, sessions and returning readers were all noise. Refusing to ask +// is only principled when the answer is that nothing is collected, and +// something was. +// +// So the question is asked, but only of the readers who are owed it. The EEA, +// the UK and the Crown Dependencies require permission before an analytics +// identifier is stored; 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. A failed or +// unavailable lookup asks — the safe direction, and what a build outside +// Cloudflare gets. +// +// The query string is dropped from the recorded location, and the referrer is +// reduced to its origin. Documentation URLs carry nothing sensitive today, but +// this repository's whole position on secrets is that "nothing sensitive is in +// there today" is not a property you get to assume — verifyURL redacts queries +// for exactly this reason. +// +// The preference lives in localStorage rather than a cookie, so declining +// stores nothing that travels with a request, and is mirrored across tabs. // // The script body below is written as the element's plain text content, with no // `{...}` expression and no backticks around it. Astro treats script children as @@ -59,49 +80,257 @@ const enabled = Boolean(measurementId) && import.meta.env.PROD; enabled && ( ) diff --git a/site/src/components/AnalyticsConsent.astro b/site/src/components/AnalyticsConsent.astro new file mode 100644 index 00000000..1d2d1b58 --- /dev/null +++ b/site/src/components/AnalyticsConsent.astro @@ -0,0 +1,148 @@ +--- +/* The consent prompt, for readers whose location requires one. + * + * Markup only. Analytics.astro decides whether this is ever shown and handles + * the clicks: the question of whether to ask is answered in the head, before + * anything renders, and putting the logic in two places is how the banner ends + * up shown to someone who already declined. + * + * It renders on every page rather than only where the site footer does, because + * a reader who arrives from a search result lands on a documentation page, and + * a consent prompt that only appears on the landing page asks the question of + * the wrong half of the audience. + * + * `hidden` in the source, not `display: none` in a stylesheet: the prompt is + * absent for the reader whose location does not require it and for the reader + * who has already answered, and neither should see it flash while a stylesheet + * loads. A build without a measurement id never renders it at all. + */ +const enabled = + Boolean(import.meta.env.PUBLIC_GA_MEASUREMENT_ID?.trim()) && import.meta.env.PROD; +--- + +{ + enabled && ( + + ) +} + + diff --git a/site/src/components/AnalyticsPreference.astro b/site/src/components/AnalyticsPreference.astro new file mode 100644 index 00000000..d2f13dae --- /dev/null +++ b/site/src/components/AnalyticsPreference.astro @@ -0,0 +1,164 @@ +--- +/* The standing analytics control, for the privacy page. + * + * The banner asks once. This is where the answer is changed afterwards, which + * is the half of consent that is easy to leave out: permission that cannot be + * withdrawn as readily as it was given was never permission. + * + * It states the current situation before offering the buttons, because "allowed + * or not" is the question a reader arrives with, and in the regions that do not + * require a prompt the honest answer is that measurement is already running. + * + * The status line is scripted rather than rendered, since the answer lives in + * the reader's browser and this site is static: every reader would otherwise be + * served the same cached sentence about a choice that is theirs alone. + */ +const enabled = + Boolean(import.meta.env.PUBLIC_GA_MEASUREMENT_ID?.trim()) && import.meta.env.PROD; +--- + +{ + enabled ? ( + <> +

+ The site uses Google Analytics 4 to count visits and see which pages get read. It runs + only when it is allowed to, and it is configured to learn as little as will still + answer that question. +

+
+

+ Checking whether analytics are running in this browser. +

+
+ + +
+
+ + ) : ( +

+ This build of the site has no analytics configured, so nothing is measured, nothing is + stored, and there is nothing to turn off. The rest of this section describes what a build + that has them does. +

+ ) +} + + + + diff --git a/site/src/components/Footer.astro b/site/src/components/Footer.astro index bcf629d8..a3d41383 100644 --- a/site/src/components/Footer.astro +++ b/site/src/components/Footer.astro @@ -50,6 +50,7 @@ const isSplash = Astro.locals.starlightRoute.entry.data.template === "splash";
+ Privacy
) } @@ -75,4 +76,20 @@ const isSplash = Astro.locals.starlightRoute.entry.data.template === "splash"; margin-inline-start: auto; } + /* Outside the regions that require a consent prompt no banner is ever + shown, so the control on /privacy is the only way to turn analytics + off -- and SiteFooter, which carries the other link to it, renders on + splash pages alone. A reader arriving from a search result lands here + and would otherwise have no route to it. One text link, not the whole + band. */ + .meta a { + color: var(--sl-color-gray-3); + text-decoration: none; + } + + .meta a:hover { + color: var(--sl-color-white); + text-decoration: underline; + } + diff --git a/site/src/components/PageFrame.astro b/site/src/components/PageFrame.astro index 255b3110..86a2c566 100644 --- a/site/src/components/PageFrame.astro +++ b/site/src/components/PageFrame.astro @@ -24,6 +24,7 @@ */ import MobileMenuToggle from "@astrojs/starlight/components/MobileMenuToggle.astro"; import SiteFooter from "./SiteFooter.astro"; +import AnalyticsConsent from "./AnalyticsConsent.astro"; const { hasSidebar } = Astro.locals.starlightRoute; const isSplash = Astro.locals.starlightRoute.entry.data.template === "splash"; @@ -45,6 +46,7 @@ const isSplash = Astro.locals.starlightRoute.entry.data.template === "splash"; }
{isSplash && } +