Appearance
Analytics without surveillance
A public mini app receives 1,200 page views after a launch post. The maker wants channel attribution and a count of the main action. That does not require IP addresses, user agents, full URLs, fingerprints, or permanent visitor profiles.
Collect less by design. Aggregate at write time, cap every dimension, expire the result, and state what the numbers cannot prove.
Data path
text
browser
-> first-party collector
-> privacy-signal stop
-> exact app-origin check
-> shape validation and sanitization
-> one atomic aggregate write
-> expiring daily counts and session estimates
configuration in Postgres
active aggregates in isolated Redis
reports read only the active generationNo raw event table exists.
Terms
Aggregate-only storage increments counters and sketches without retaining individual event rows.
Cardinality is the number of distinct label values, such as paths or campaign tuples. Unbounded cardinality is both a privacy and capacity problem.
Global Privacy Control, or GPC, is a browser privacy signal. This collector treats it as a stop signal for optional analytics.
Do Not Track, or DNT, is an older signal that this collector also honors.
A generation is a random identifier selecting the currently visible analytics data set for one app.
A HyperLogLog is a compact probabilistic structure for estimating distinct values. It gives an estimate, not an exact visitor count.
Collect a deliberately small shape
The browser can send a fixed event type, a sanitized pathname, referrer hostname, normalized UTM fields, a declared goal name, and a random tab token. Reject or drop everything else.
Never accept raw query strings or URL fragments. They may contain personal data or capability tokens. Exclude link text, page title, form values, DOM text, screen characteristics, and user agent.
Constrain paths to a few low-entropy segments. For example, allow at most three lowercase alphanumeric, underscore, or hyphen segments. Fold anything else into other. Normalize campaign fields to short lowercase slugs. Goals must come from an app configuration list capped at ten.
The collector creates a random 128-bit value in sessionStorage. The server hashes it with the app slug before adding it to HyperLogLog. The resulting tab-session estimate does not identify people or connect devices.
Call it what it is.
Stop before reading the body
The client should exit when navigator.globalPrivacyControl is true or DNT is 1. The server must repeat the check using Sec-GPC: 1 and DNT: 1 before parsing the request body. Client controls can be bypassed, and server enforcement keeps the promise.
Require the Origin header to match the app slug on an approved Pages alias or custom app domain. Reject missing, cross-app, nested, lookalike, and non-HTTPS origins.
This is not traffic authentication. A non-browser client can forge Origin and send events. Reports must say "best-effort traffic," not "verified visitors." Public analytics can be spammed.
Bound storage and write atomically
Use UTC daily buckets with one count hash and one HyperLogLog per app, generation, and day. Expire them at day end plus the retention period.
Cap daily dimensions before adding labels:
- 50 sanitized paths
- 25 referrer hosts
- 50 campaign tuples
- 10 configured goals
Fold excess distinct values into other. Cap request bodies, events per batch, events per minute, and events per day. A reasonable small-app policy might accept at most 20 events and 8 KB per request, 600 events per minute, and 100,000 per day.
All checks and increments must happen in one server-side operation. In Redis, a Lua script can verify configuration and generation, enforce quotas and cardinality, increment counters, update HyperLogLog, and set expiry. A rejected batch counts nothing.
A conceptual key layout is:
text
analytics:config:<app>
analytics:<app>:<generation>:<day>:counts
analytics:<app>:<generation>:<day>:sessions
analytics:<app>:quota:minute:<bucket>
analytics:<app>:quota:day:<day>Keep quota keys outside the generation. Otherwise clearing analytics would also reset abuse limits and allow a sender to buy a fresh allowance.
Isolate optional analytics
Run analytics in a dedicated Redis with a strict memory ceiling and noeviction. A full store should drop analytics writes, not evict shared documents, push subscriptions, or job output. Give only the collector and report service its connection URL.
Cap enabled apps per cell. Reserve a slot atomically, and reconcile the registry from authoritative Postgres configuration at startup.
Do not let analytics failure break the app. Catch store errors, increment a low-cardinality dropped-write metric, and return a bounded error. The page, sync path, and agent should continue.
Retention and generation rotation
Offer a small set of retention periods, such as 30, 60, or 90 days. The Privacy page names the selected period. Changing retention rotates the generation and starts an empty data set. This prevents a new 30-day promise from being applied dishonestly to older 90-day data.
Clearing analytics also rotates the generation. Update Postgres first, replace the collection marker, then delete known keys from the old generation on a best-effort basis. Reports switch immediately because they read only the active generation. Late writes to the old generation remain invisible.
Old bytes may remain in Redis append-only files until compaction. Say so. Logical deletion is immediate at the report layer; physical storage cleanup follows Redis maintenance.
Do not back up this analytics store. A disaster restore starts empty, creates a new generation, and sets a new availableFrom time. Reports then mark requests that extend before that time as partial. Backing up disposable analytics would complicate deletion and retention promises.
Prior reports or assistant summaries in chat do not vanish when aggregates are cleared. Infrastructure access logs may also remain under their own policy. "Clear" must not imply more.
Report only supported conclusions
Reports can show views, estimated tab sessions, clicks, declared goals, and bounded top dimensions. Compare periods only when the full baseline falls after availableFrom and inside retention.
An absent covered day means zero. A day before availability means missing coverage. Those are different.
UTM links attribute requests to shared URLs, not human intent. Monetization clicks are not purchases or revenue. HyperLogLog sessions are not people. Public counts are forgeable.
Decisions and rejected alternatives
Use a first-party collector rather than a third-party analytics script. This keeps fields, retention, network destinations, and failure behavior under the application's control.
Use aggregate-only Redis rather than a raw event warehouse. Raw rows would make future questions easier, but they would also create a surveillance data set, increase breach impact, and make deletion harder.
Use a tab-scoped random token instead of cookies or fingerprinting. Cross-device identity and cohorts are explicitly unsupported.
Use ordinary canonical URLs with UTMs instead of redirects or shorteners. Redirect infrastructure adds another public service and can become a tracking identifier.
Data minimization does not establish consent or settle legal requirements.
Failure modes
- One attacker fills every path slot with random strings.
- Cardinality caps must fold overflow before allocation.
- A clear operation resets quotas and invites abuse.
- A marker expires with daily buckets and silently disables collection.
- A disaster restore reuses the old generation and reports a continuous period that did not exist.
- Request logging captures bodies even though analytics storage does not.
- A dashboard calls HyperLogLog output "users."
- A monetization report labels outbound clicks as revenue.
Field checklist
Previous: Chapter 39, "Backups, restores, and drills".
Next chapter
Chapter 41, "Memory that earns its place", begins Part V with bounded, editable memory rather than transcript accumulation.