Status: on file
Covers analytics.events + analytics.sessions — the anonymous lane, keyed by
SHA256(ip:ua). The authenticated lane (analytics.user_events) rests on Art 6(1)(b)/(f)
and is assessed separately in two-lane-model.md.
Controller: the site operator (Germany). Basis claimed: GDPR Art 6(1)(f).
Why this document exists
Two things are easy to get wrong here, and both were wrong before this assessment:
- The hash does not make the data anonymous. In EDPS v SRB (C‑413/23 P, 4 Sept 2025) the CJEU held that identifiability is judged from the controller's own position and at the time of collection. We hold the raw IP and User-Agent before hashing and we control the algorithm. The result is therefore pseudonymous personal data for us, whatever it looks like to a third party. GDPR applies in full, and a legal basis is required.
- ePrivacy and GDPR are separate gates. Clearing one does not clear the other. This document addresses only the GDPR limb. The terminal-equipment question is in two-lane-model.md.
Per EDPB Guidelines 1/2024 (adopted 8 Oct 2024, reflecting CJEU C‑621/22 KNLTB), Art 6(1)(f) requires a cumulative three-part test. All three are assessed below.
Limb 1 — Purpose test
Interest: understanding how the site is used, in aggregate, in order to operate and improve it: which pages are read, which are ignored, where navigation breaks down, where the interface is slow, and where visitors get stuck.
Is the interest real (not speculative), present (not hypothetical), and lawful?
- Real and present: the data feeds live surfaces (
/admin/analytics, the analytics showcase pages) that inform actual decisions about what to build and fix. - Lawful: KNLTB confirmed that a purely commercial interest can qualify. Operating and improving one's own website is well within that.
A purely commercial interest still has to survive Limb 3. It is not a free pass.
Limb 2 — Necessity test
The bar is necessary, not useful and not more convenient. For each element we ask whether a less intrusive route reaches the same purpose.
| Element | Necessary? | Reasoning |
|---|---|---|
| Keyed visitor hash | Yes | Without any grouping key, pageviews cannot be distinguished from reloads and "unique visitors" is unanswerable. It is HMAC-SHA256(server key, ip + user-agent) — keyed, so it cannot be reversed by sweeping the address space, which an unkeyed digest can be. It does not rotate daily: "unique visitors over 30 days" is a question the hash exists to answer, and a daily-rotating input would inflate that count by the number of days. The key is rotatable instead, at or above the 60-day retention window, so at most one boundary can fall inside any reporting range. |
| Path + templated route | Yes | The measurement itself. |
| Country (from connection) | Yes | Answers "who is this site for", and is coarse by construction — country level only, never city or coordinates. |
| Referrer | Yes | Answers "how do people find this", unanswerable any other way. |
| Device / browser family | Yes | Drives what gets tested and supported. Deliberately coarse: family only, never a version string. |
| Engaged time, scroll depth | Yes | Distinguishes read from bounced. Wall-clock time cannot — a forgotten tab looks identical to careful reading. |
| Rage / dead clicks, form-field abandonment | Yes | The "where do people get stuck" question. These are the aggregate signals that make session replay unnecessary, which is the less-intrusive-alternative argument in its strongest form. |
| Web Vitals + attribution | Yes | A bare metric identifies a problem; attribution identifies the element responsible. Without it the data cannot be acted on, so collecting it without attribution would fail this limb. |
Rejected as unnecessary — considered and deliberately not collected:
- Session replay / DOM recording. Would answer some of the same questions far more intrusively. The derived signals above cover the purpose. Fails Limb 2 outright.
- Heatmaps, mouse tracks, keystrokes. Same reasoning.
- Form field content. Only which field was abandoned is needed. Content may incidentally capture Art 9 special-category data, and is never read.
- Any additional fingerprinting entropy — screen size, timezone, canvas, fonts,
hardwareConcurrency. Would marginally improve visitor counting at a disproportionate cost to identifiability. Also see the ePrivacy consequence in the two-lane doc: adding entropy moves the technique into the confirmed scope of EDPB Guidelines 2/2023. - Raw IP storage. Never stored in any table. The hash is computed and the input discarded.
- Cross-site or third-party identifiers. No data leaves the first party. There is no vendor, no pixel, no tag manager.
Limb 3 — Balancing test
Nature of the data: pseudonymous, no special categories, no children's data sought.
Reasonable expectations (Recital 47). A visitor to a website reasonably expects the operator to count visits and measure performance. That is the mainstream expectation for first-party analytics. Where expectation gets strained is tracking across sites, profiling, and ad targeting — none of which happen here.
Impact on the data subject: low, and bounded by design.
- No decision is ever made about an individual. Output is aggregate.
- No profile is built: the identifier rotates every UTC day, so nothing accumulates across days even in principle.
- No cross-site tracking, no data sharing, no sale.
- Raw events are deleted after 60 days; aggregates carry no identifier at all.
Safeguards applied (each is enforced in code, not policy):
| Safeguard | Where |
|---|---|
| Keyed, non-reversible visitor identifier | analytics/consent.ts hashVisitorId, keyed via analytics/visitor.ts |
| Daily-rotating session identifier | analytics/consent.ts deriveCookielessSessionId |
| Referrer reduced to an origin before storage | db/analytics/mutations.ts — the write chokepoint, so every lane inherits it |
| No raw IP persisted anywhere | analytics/hook.ts — hashed before write |
| Authenticated surfaces excluded from this lane | analytics/collect-policy.ts |
| No join key to the identified lane | schema-level; see user-events.ts docblock |
| Closed allowlist on event names and properties | analytics/event-schema.ts |
| 60-day deletion, pinned by test | jobs/analytics-cleanup.ts, analytics.test.ts |
| Bot and prefetch traffic excluded | analytics/collect-policy.ts |
Right to object (Art 21). Rejecting the analytics tier in the consent banner stops the
cookie, the referrer, device/browser parsing, and all behavioural events. The banner is
reachable at any time from /showcases/privacy/cookies, and rejection is exactly as easy as
acceptance (equal-weight buttons, no dark pattern — EDPB Guidelines 05/2020 and the 2023
Cookie Banner Taskforce report).
Outcome: the interest is not overridden. The processing is proportionate, the impact is low and time-boxed, an objection route exists and is honoured in code, and every genuinely intrusive alternative was considered and rejected.
Review triggers
Re-run this assessment before any of the following. Each would change a limb:
- Adding any signal to the visitor hash (Limb 2 and Limb 3 both change).
- Rotating the visitor-hash key faster than the retention window, or not at all for longer than one.
- Widening the stored referrer beyond an origin. The full URL of a page a visitor arrives from can contain a magic-link, password-reset or OAuth token in its query string.
- Introducing any cross-session or cross-site identifier.
- Extending raw-event retention beyond 60 days.
- Introducing any per-individual decision, ranking, or personalisation from this lane.
- Sharing analytics data with any third party.
- Adding session replay, heatmaps, or form-content capture — each was rejected at Limb 2, so reintroducing one reopens the whole assessment.
Related
- two-lane-model.md — the architecture and the ePrivacy limb
- dpia-screening.md — Art 35 screening
- activation.md — what the collector actually does