Status: active
Analytics is split into lanes that share no identifier and no join key. The separation is
the load-bearing design decision in the whole subsystem. A third lane — bot_hits, for
declared crawlers — carries no identifier of any kind and therefore sits outside the
identifier wall entirely; it is documented in the schema (bot-hits.ts) and on the admin
bots page. It is also the only lane whose writes do not reach Postgres on the request
path: a hit is classified and verified in the hook, pushed onto a Redis list, and flushed
in batches (bot-hit-buffer.ts). Crawlers arrive when no human is around, and a per-hit
INSERT was waking the database ~30 times a night for nothing else.
ANONYMOUS LANE AUTHENTICATED LANE
analytics.events analytics.user_events
analytics.sessions
visitor_id = HMAC(key, ip:ua) user_id → auth.user (CASCADE)
keyed; key is rotatable stable, identified
no FK, no user reference no visitor_id column, ever
basis: Art 6(1)(f) + LIA basis: Art 6(1)(b) + 6(1)(f)
ePrivacy: contested (see below) ePrivacy: does not engage
consent tier gates collection no consent tier — Art 13 disclosure
retention 60 days retention 180 days, or immediate on erasure
public routes only /account/* only
NOT in collectUserData IS in collectUserData, section `behavior`
Within the anonymous lane, sessions carry a confirmation split
(human_confirmed_at): a session only counts toward the headline "confirmed
visitors" once client-side JavaScript corroborated it — the consent-free confirm
ping, a journey batch, or a telemetry batch. Everything else is reported as
"unconfirmed", which is where UA-spoofing crawler traffic lands (measured: 588 of
612 "visitors" in one production week). ip_class ranks that unconfirmed bucket
by connection origin (datacenter / iCloud-Relay / unknown) and must never become
an exclusion predicate — its false positives are VPN and Private Relay users.
Why the wall exists
If a hashed visitor could be walked to a user id, the anonymous lane would stop being anonymous. Concretely, it would:
- become reachable by an Art 15 access request and an Art 17 erasure, which it currently is not and is not built to be;
- invalidate the necessity and balancing analysis in legitimate-interest.md, which is argued on the premise that the lane's subjects are unidentified;
- turn a short-lived pseudonym into a durable profile, which is precisely the characteristic that separates "counting visits" from "tracking people" in reasonable expectations. The visitor hash is bounded by the 60-day retention window and by a rotatable key, not by daily rotation — daily rotation would make "unique visitors this month" unanswerable, which is the question the lane exists to answer.
Adding a join key between these tables is therefore not a refactor — it is a change of
legal position. privacy/report.ts says so in prose; user-events.ts enforces it in
schema; the erasure test in privacy.pglite.test.ts pins the consequence.
Which lane claims a request
Decided in one place, analytics/collect-policy.ts, so the two collection paths cannot drift:
| Path | Lane | Note |
|---|---|---|
/, /blog/*, /showcases/* … |
anonymous | Public surfaces |
/account/* |
authenticated | Only when a session exists; user_surface = account |
/desk/* |
authenticated | Only when a session exists; user_surface = desk. Joined on 2026-09-18 so the desk's command_invoked events (the menu-review evidence) have a lane — page views ride along |
/admin/* |
neither | Operator's own usage — high volume, no insight |
/api/*, /_app/*, *.ico |
neither | Not pages |
| Bots, prefetch, prerender | neither | Not visitors |
A path is eligible for exactly one lane, and a prefix rule is a path-segment rule
(/desk claims /desk/…, never /desktop). Both the server hook and the two beacon
endpoints import the same predicates — they previously disagreed, and client-side navigations
into /admin and /account leaked into the anonymous lane as a result. The telemetry beacon
(journey/collect) splits one batch per event: a signed-in user's rows from a user-lane path
go to analytics.user_events with no consent check; the rest take the anonymous gates. The
client mirrors the rule (telemetry.ts tags each queued event with its lane) so a consent
withdrawal drops exactly the anonymous rows and nothing else.
The ePrivacy question
ePrivacy Art 5(3) / TDDDG §25 gates access to terminal equipment, independently of whether GDPR is satisfied. Two elements matter:
The session cookie is unambiguous. It writes to the device and is not strictly necessary,
so it requires consent. Below the analytics tier it is never set, an existing one is
actively deleted, and session grouping falls back to hash(visitorId + UTC day) — the
Plausible/Fathom pattern, which stores nothing on the device.
The visitor hash itself is contested, and we do not claim otherwise. No primary source — EDPB, CNIL, DSK, or a court — resolves this exact pattern: no cookie, no added entropy, a rotatable key with 60-day retention (the session id rotates daily; the visitor hash does not — see legitimate-interest.md), aggregate-only output. Two credible readings exist:
- Narrow: IP and User-Agent are unavoidable HTTP transport metadata. Nothing additional is read from or written to the device, so Art 5(3) does not engage and it is purely an Art 6 question.
- Broad: rooted in Art 29 WP Opinion 9/2014 on device fingerprinting and the technology-neutral framing of "access" in EDPB Guidelines 2/2023 — the User-Agent reports terminal configuration, and combining it with an IP for identification purposes is a form of access however it arrives.
We hold the narrow reading, with the mitigations that make it strongest: no added entropy, a daily-rotating cookieless session id, aggregate-only output, short retention, a rotatable key, and a documented LIA. This is a risk-managed position, not legal certainty. Note also that Germany has no CNIL-style administrative exemption for consent-free audience measurement; the DSK/BfDI consent-management-service regime is a mechanism for capturing consent, not for dispensing with it.
The operative consequence is a rule: never add a signal to the visitor hash. Screen size,
timezone, canvas, fonts, hardwareConcurrency — each would convert an arguably-out-of-scope
technique into one that is confirmed in scope under Guidelines 2/2023, and would collapse the
narrow reading we rely on.
The confirm ping (/api/analytics/journey/confirm) is designed to stay on the safe side
of the same line: its payload is a constant server-issued token, it reads nothing from the
device (no cookie, no storage, no navigator probing — the DSK's "einfaches Zählpixel"
shape, OH Digitale Dienste ¶88), and it must never grow a payload field — anything added
re-opens the §25 question. ip_class is a transient comparison, not stored data about
the device: the connection address is checked once against published ranges inside the
session INSERT and never written, the same lifetime contract as bot verification.
Why the authenticated lane needs no consent tier
For a logged-in user the ePrivacy gate does not engage: the auth cookie is already strictly necessary under TDDDG §25(2) Nr.2, so reading it requires no further permission. The processing rests on Art 6(1)(b) — operating the account the user asked for — and Art 6(1)(f) for improving it. This is disclosed under Art 13, not consented to under Art 6(1)(a), and the consent banner correctly says nothing about it.
The hard stop is Art 22: nothing derived from this lane may drive a solely automated decision producing legal or similarly significant effects for the user. Aggregate product analytics — cohorts, retention, funnels, which screens get used — is fine. Automated account restriction, materially consequential personalisation, or scoring is not, and would need explicit consent, contractual necessity, or a Member State law basis.
Related
- legitimate-interest.md — the Art 6(1)(f) assessment
- dpia-screening.md — Art 35 screening
- activation.md — collector wiring
- ../../stack/capabilities/gdpr.md — data-subject rights