diff --git a/site/src/components/Analytics.astro b/site/src/components/Analytics.astro index 71f6cbdd..58038bb0 100644 --- a/site/src/components/Analytics.astro +++ b/site/src/components/Analytics.astro @@ -1,12 +1,35 @@ --- -/* Privacy-respecting Google Analytics 4. +/* Consent-gated Google Analytics 4. * * The component is absent unless PUBLIC_GA_MEASUREMENT_ID is configured for a - * production build. It denies analytics/ad storage before loading gtag, turns - * off advertising signals, honors DNT and GPC before making a request, and - * drops query strings from recorded locations. That keeps local builds and - * forks silent and avoids asking for consent to store identifiers Fanout does - * not need. + * production build, so local builds and forks stay silent. + * + * Nothing is loaded until the visitor is allowed to be measured. Earlier this + * file loaded gtag on every page and then held it in Consent Mode's denied + * state, which sent a cookieless ping for every view: no `_ga` cookie, no + * stable client id, so every pageview arrived as a new user and a new session. + * That is a report in which sessions, bounce rate and returning visitors are + * all meaningless, bought at the price of shipping Google's tag to everybody. + * The tag now does not exist until it may set the cookie that makes its numbers + * mean something. + * + * Whether consent must be asked for is a question about the reader's location, + * not about this site. The EEA, the UK and the Crown Dependencies require + * permission before the cookie is set; elsewhere measurement may run with a way + * to turn it off. Cloudflare already knows which one 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 or unavailable + * lookup is treated as "ask" — the safe direction, and what local development + * gets, where the whole component is compiled out anyway. + * + * Do Not Track and Global Privacy Control are honoured above all of it. A + * reader who has already answered this question in their browser is not asked + * it again, and is not measured either way. + * + * The preference lives in localStorage rather than a cookie, so declining + * stores nothing that travels with a request, and is mirrored across tabs. The + * control that changes it later is on /privacy; consent that cannot be + * withdrawn as easily as it was given is not consent. */ const measurementId = import.meta.env.PUBLIC_GA_MEASUREMENT_ID?.trim().toUpperCase(); @@ -25,92 +48,322 @@ 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..0164ba40 --- /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..659a07c6 --- /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..8e2cd3dd 100644 --- a/site/src/components/Footer.astro +++ b/site/src/components/Footer.astro @@ -40,6 +40,16 @@ import Pagination from "@astrojs/starlight/components/Pagination.astro"; * The copyright is no longer part of this component. It is chrome, not the end * of an article, so it lives in SiteFooter.astro, which PageFrame puts below * the content column entirely. + * + * Privacy is the exception to that split, and it earns it. 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 who + * arrives from a search result lands here, on a documentation page, and would + * have had no route to the control at all. One text link, not the whole band: + * the objection to a second footer here was duplicated navigation and social + * icons colliding with the fixed right sidebar, and a single word in the row + * that already exists is neither. */ const isSplash = Astro.locals.starlightRoute.entry.data.template === "splash"; --- @@ -50,6 +60,7 @@ const isSplash = Astro.locals.starlightRoute.entry.data.template === "splash"; ) } @@ -75,4 +86,14 @@ const isSplash = Astro.locals.starlightRoute.entry.data.template === "splash"; margin-inline-start: auto; } + .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"; }