ADR-092 — Analytics opt-out + browser privacy signals (GPC/DNT) + the /privacy disclosure page; no consent banner
Status · Accepted · 2026-09-10 · Operator-directed (privacy audit of the Umami implementation) · amends ADR-081 (self-hosted Umami) · third narrow exception to the no-client-storage rule after ADR-057 (locale) and ADR-075 (tour resume) · the analytics half of the ladder in ADR-082
Context
A privacy audit of the shipped Umami implementation (ADR-081) asked three questions: how much do we actually track, do we need consent, and do we need an opt-out. The audit surfaced a gap between what the code does and what the code (and the UI) claims.
What is actually collected. Umami's own autotrack records page, referrer, screen, language, UA-derived browser/OS/device and IP-derived country per visit. On top of that sit 24 custom event names across 18 files: navigation with per-route dwell time (route-enter / route-exit), click-level interaction on every route (item-click on markers, stars, missions, modules), completion funnels (mission-load → mission-complete), entity popularity, outbound link destinations, and free-text search.
That is behavioural analytics, not audience measurement, and the distinction is exactly what the regulatory exemptions turn on. The volume was a deliberate product choice and stays — but it means the "we just count pageviews" framing was never accurate, and the posture has to be defensible on its own terms rather than by understating what is collected.
Three specific defects.
cmdk-search-hitshipped the raw query.ScienceSearch.sveltecalledtrack('cmdk-search-hit', { query: query.toLowerCase().trim(), … })with no truncation — while the siblingtrackSearch()capped at 40 chars,analytics.tsdocumented the event as{ query_len, … }, and/creditspromised users "collects no personal data". Whatever a user typed into the command palette landed verbatim in the dashboard. Three separate contracts, all contradicted by one call site.- No way to object. There was no opt-out, and
navigator.globalPrivacyControl/navigator.doNotTrackwere not consulted. The audience-measurement exemptions expect a means to object; we had none. - Disclosure was one sentence. GDPR Art. 13 transparency is mandatory whatever the lawful basis. The only user-facing text was
credits_storage_introon/credits, and there was no/privacyroute at all (the layout's footer comments had reserved the slot since v0.7).
On consent. The ePrivacy Art. 5(3) trigger is storing or accessing information on the user's terminal equipment. Umami sets no cookie and writes nothing, which removes the classic cookie-banner trigger — but note that EDPB Guidelines 2/2023 read 5(3) more broadly than "no cookie, no problem": a script reading screen size, user-agent and language is arguably accessing terminal equipment. This decision does not rest on a clean 5(3) exemption.
Nor does it rest on the audience-measurement exemption. That exemption expects a purpose strictly limited to measurement, and the breadth documented above (per-item click trails, per-route dwell, completion funnels) sits at or past its edge — saying so and then invoking the exemption anyway would be incoherent.
What it rests on is legitimate interest (GDPR Art. 6(1)(f)): self-hosted, no third party, no ad tech, no cross-site identity, no profiling of individuals — plus the two things that basis actually requires, transparency and a means to object, which this ADR ships. That is an engineering position, not legal advice; if the audience or the operator's establishment changes materially, get it reviewed rather than re-reading this section.
Decision
No consent banner. An opt-out, honoured browser signals, and a real disclosure page instead.
Cap every free-text path. New
trackSearchHit(query, tab, section)inanalytics.tsapplies the same 40-char cap astrackSearchand emits{ query_len, query, tab, section }, matching the documented contract.ScienceSearch.sveltecalls it instead of rawtrack(). The module header now states the rule explicitly: every free-text path goes throughtrackSearch/trackSearchHit; never pass a raw query totrack().A single suppression gate. New
src/lib/analytics-optout.tsexportsanalyticsSuppressed()— the OR of an explicit opt-out cookie and a browser privacy signal.analyticsEnabled()andinitAnalytics()both consult it.orrery_analytics_optout— functional cookie #3. Value'1',Max-Age1 year (mirrorsorrery_locale),SameSite=Lax,Secureon https. Opting back in deletes the cookie rather than writing'0', so the default state stores nothing at all and a user who never touches the toggle carries no cookie. Under ePrivacy this is a strictly-necessary cookie: it records the user's objection and exists only to be obeyed.GPC first, DNT alongside.
navigator.globalPrivacyControl === truesuppresses. It is the signal with actual legal force — binding under CCPA/CPRA in California, recognised in Colorado and Connecticut.navigator.doNotTrack === '1'(plus the legacy'yes'/window.doNotTrack/msDoNotTrackspellings) also suppresses, but DNT is not the reason this exists: the W3C group closed in 2019 without a standard and Firefox dropped its toggle, so no jurisdiction requires it. It costs one expression to honour next to GPC. Building for DNT alone would have been the wrong target.Suppression happens before injection, not just at
track(). Umami's autotrack binds its own history listeners when the script loads and they cannot be unbound afterwards. An opted-out user therefore never receives the script at all, and the/privacytoggle reloads the page on change so that mid-session opt-out actually stops pageview collection rather than only our custom events./privacyis the disclosure surface. A prerendered route listing what is collected, what never is, where it goes, the cookies, crash reports, browser signals, and the toggle itself. It is linked from the footer ABOUT menu (the slot reserved for it since v0.7) and from the/creditsstorage card.credits_storage_introchanges "collects no personal data" → "stores no personal data". Note the precise scope of that claim: the analytics never store an IP (it is used once, in memory, to derive a country, then discarded), but ordinary web-server access logs do — see the amendment below.
Consequences
Positive
- The "no consent banner" position is now defensible rather than asserted: cookieless + self-hosted
- no sharing + a working means to object + real Art. 13 disclosure.
- One chokepoint.
analyticsSuppressed()gates script injection and every event, so a future event cannot accidentally bypass the opt-out. - The free-text leak is closed at the helper level, so the same mistake cannot recur by calling a helper — only by hand-rolling
track()with a raw string, which the module header now forbids. - GPC users are honoured automatically with no UI interaction at all.
Negative
- A third cookie. The no-client-storage rule (ADR-057) now has three exceptions. Each remains ADR-gated and none stores personal data, but the "we have two cookies" line in docs and the credits copy had to change, and the slope argument gets marginally weaker with each addition.
- The opt-out toggle reloads the page. Unavoidable given Umami's autotrack binding, and the copy says so, but it is a visible jolt.
- Opted-out and GPC users disappear from the dashboard entirely, so the numbers under-count real traffic by an unknown margin. That is the point, but it should not be read as a traffic drop.
Neutral
- No change to the event vocabulary, the env ladder (ADR-082), the mobile target switcher (ADR-083), or the edge vhost. Volume of tracking is unchanged by this ADR — it is disclosed and refusable, not reduced.
- Crash reports (ADR-067) follow the same gate conceptually and are described on
/privacy.
Alternatives considered
- A consent banner. Rejected: ePrivacy 5(3) is not engaged by cookieless analytics, so a banner would impose friction on every visitor for no legal gain, train users to click through consent dialogs, and imply the processing needs consent when legitimate interest applies. It would also be the single most visible change to a site whose whole design avoids chrome.
localStoragefor the opt-out flag. Rejected outright — ADR-057 bans client storage, and a privacy feature is the worst possible place to break that rule. A cookie is also the correct primitive: it is the narrowest, most inspectable, and user-clearable option.- DNT only. Rejected. It is the intuitive request and the dead standard; GPC is the one with legal force. Honouring both costs nothing, so both are honoured — but the decision is GPC-primary.
- Reducing what is tracked instead of disclosing it. Considered and deferred. The dwell-time and click-level events earn their keep in product decisions. The honest fix is to say so plainly and let people refuse, not to quietly trim the list. Revisit if the dashboard shows events nobody reads.
window.umami.disabled/ Umami's own opt-out. Rejected: Umami's documented mechanism is alocalStoragekey, which ADR-057 forbids.
Not covered / out of scope
- Translations. The
privacy_*andcredits_storage_*message keys ship en-US only; the other 13 locales fall back to en-US per the standard i18n rule. Runningscripts/translate-v07-ui.mjs --keys=…requires anANTHROPIC_API_KEYthat was not available in the implementing session. Deferred, tracked as a follow-up. - Server-side / edge-level suppression. The gate is client-side. A user who blocks JS sends nothing anyway; there is no server-side pageview logging to gate.
- A cookie-level "reject all" beyond analytics.
orrery_localeandorrery_tourremain strictly-functional and are not covered by the toggle. - Legal review. This ADR records an engineering position reasoned from the structure of ePrivacy and GDPR. It is not legal advice, and the analysis shifts with the operator's establishment jurisdiction and audience mix.
Amendment — 2026-09-10 (pre-push review)
An adversarial review of this branch found four published claims that were false against the code, and two reachability/correctness defects. All are fixed in the same change:
- Crash reports were not gated.
/privacysaid the toggle stops "everything on this page", and that page describes crash reports — buthooks.client.tscalledinitSentry()unconditionally at module scope. It is nowif (!analyticsSuppressed()) initSentry();, so the opt-out and GPC/DNT cover Sentry as well as Umami. This is the substantive scope change: the opt-out is now an application-wide telemetry switch, not an analytics-only one. - "Your IP address is never stored" was false. nginx
access_log /dev/stdout(shipped to VictoriaLogs by the node Alloy) and the shared Caddyhardenedsnippet's access log both record the client IP — for page requests and for every/api/sendevent respectively. The page now has a Server logs section stating this plainly, and says the opt-out does not switch those off. - "No cloud vendor and no third party receives any of it" was false. Cloudflare fronts the site and the analytics endpoint and terminates TLS. It is now named.
- "No
localStorage" was false on/creditsand by omission on/privacy:orrery.qualityTier, debug mode and offline-download keys exist. Copy corrected; TA.md, README.md and AGENTS.md updated to stop asserting the absolute, and to record three cookies rather than two. /privacywas unreachable on mobile — the link went only into the desktop footer's ABOUT menu, and.site-footeris hidden on touch. Added to the mobile drawer's About group. A page carrying the opt-out has to be reachable on the device most visitors use./privacywas not registered inscripts/site-routes.mjs, so it was missing from the sitemap and per-locale prerender seeding, and absent from the smoke suite. Both fixed.
The reviewer also noted that this ADR's original consent reasoning cited the audience-measurement exemption while simultaneously arguing the collection exceeds it. The Context section above has been rewritten to rest the conclusion on legitimate interest plus transparency and objection, and to record that EDPB Guidelines 2/2023 read Art. 5(3) more broadly than "cookieless, therefore exempt".
Still open (not fixed here): Paraglide's cookie strategy writes a fourth cookie, PARAGLIDE_LOCALE (vite.config.ts, written by setLocale()), which no disclosure mentions. It predates this branch, but this branch is what makes the cookie list an exhaustive claim. Either drop 'cookie' from the Paraglide strategy — the app resolves locale itself via orrery_locale and the URL — or disclose it as cookie #4. Operator decision.