Перейти к основному содержимому

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.

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.

← Back to Blueprint

Думаете, этот паттерн можно сделать лучше? Расскажите как.

Оставить отзыв