Skip to content

Privacy-first analytics: opt-in per app, counted server-side, no cookie or script - #57

Merged
thorwhalen merged 2 commits into
mainfrom
feat/privacy-analytics
Sep 25, 2026
Merged

thorwhalen merged 2 commits into
mainfrom
feat/privacy-analytics

Conversation

@thorwhalen

Copy link
Copy Markdown
Member

Closes #55.

What it does

An app opts in from its own app.toml:

[analytics]
mode = "privacy"

The enlace server counts that app's HTML page views as it serves them. A pure-ASGI middleware only observes each response: no JavaScript, no beacon, no request to another origin, nothing written to the device, and the page bytes are unchanged. Apps without the table record nothing, and when no app opts in (and no old data exists) nothing is installed at all.

Stored: daily aggregates per app. Each dimension is its own marginal count, never crossed with another: path (no query string, identifier-like segments redacted), referrer host name, primary language, device class. Bots and scanner probes are counted apart. No IP is read and no identifier is stored. DNT and Sec-GPC are honoured, and /_analytics/opt-out is a no-script page (English/French) for a privacy notice to link to. It sets one opt-out cookie, and only when the visitor asks.

Storage is any MutableMapping, via build_backend(..., analytics_store=...), e.g. a dol store. The default JsonFileStore lives under ~/.local/share/enlace/analytics. Each worker writes only its own record per app and day, so workers never race. A background task (started by lifespan, running in a thread) flushes the counts and runs the daily maintenance: a retention purge that runs regardless of traffic (at most 750 days), and compaction of finished days under a lock.

Reading: enlace analytics [--app-name X] [--days 30] [--json], or enlace.analytics_report(), gives per-path daily counts.

Acceptance

  • An opted-in app records a page view; an app without the key records nothing. Covered by unit and integration tests in enlace/tests/test_analytics.py.
  • A headless browser check, tests/test_analytics_browser.py: Chromium loads an opted-in page from a live uvicorn server. Every request is same-origin, there are no cookies and localStorage, sessionStorage and IndexedDB are empty, and exactly one page view is recorded. Passed locally. It is skipped in CI, where Playwright isn't installed.
  • The owner can read per-path daily counts for the last 30 days (daily_counts, CLI test).
  • Also verified by hand under a real uvicorn server: a lone view reached the store on the background timer before shutdown, and the CLI printed it.

Design record

misc/docs/privacy_analytics.md covers:

  • why counting is server-side rather than a self-hosted Plausible, Umami or GoatCounter;
  • what counts as a page view;
  • the storage design;
  • why unique visitors are not counted in v1: sharing a daily salt across workers needs a decision on where the salt may live;
  • the CNIL audience-measurement exemption checklist, with references;
  • the operator's remaining duties: a privacy-notice entry, and their own access logs.

Review

An independent adversarial review found no blockers and ten should-fixes. All ten are addressed in the second commit:

  • event-loop blocking by store scans;
  • file growth with each restart;
  • retention that depended on traffic;
  • a lone view not saved until the next one;
  • silent data loss for app names outside [A-Za-z0-9_.-];
  • junk-path floods;
  • unsanitised stored values (escape codes, IPs, emails);
  • an unknown timezone crashing boot;
  • log floods when the store is unwritable;
  • writer-id collisions under --preload.

Each fix has a test. The new code adds no dependencies.

Related

https://claude.ai/code/session_01JQJh4hGdYjSKiFkyj9zrAh

…ie or script

An app opts in with [analytics] mode = "privacy" in its app.toml. The
enlace server counts that app's HTML page views as it serves them, with a
pure-ASGI middleware that only observes: no JavaScript, no third-party
request, nothing written to the device, page bytes unchanged. Apps without
the table record nothing.

Stored: daily aggregates per app, each dimension (path without query string,
referrer domain, primary language, device class) as its own marginal count,
never crossed; bots counted apart; no IP read, no identifier kept. DNT and
Sec-GPC are honoured, and /_analytics/opt-out is a no-script page a privacy
notice can link to (sets one opt-out cookie only when asked).

Storage is any MutableMapping (build_backend(..., analytics_store=...));
the default JsonFileStore writes one record per worker per day under
~/.local/share/enlace/analytics, so workers never race; retention capped at
25 months. Owners read counts with `enlace analytics` or analytics_report().

misc/docs/privacy_analytics.md records the design, why not a self-hosted
Plausible/Umami/GoatCounter, why unique visitors are not counted, and the
CNIL audience-measurement exemption checklist with operator duties.
tests/test_analytics_browser.py checks the acceptance line in headless
Chromium (skipped where Playwright is not installed).

Closes #55
- Writes and daily maintenance run in a background task started with the
  server lifespan (in a thread), so no store scan ever blocks a request and a
  lone view is saved on the timer, not on the next view.
- Retention purge runs daily regardless of traffic, and keeps running while
  old data remains after every app opted out.
- Finished days (>= 2 days old) are compacted into one merged record under a
  store-wide lock; the merged record lists its sources, so neither readers nor
  an interrupted run double-count. Stale temp files are swept.
- Writer ids derive from the pid at first use (safe under gunicorn --preload).
- App names are percent-encoded in keys (no silent loss for 'my app').
- Scanner probes (dot-segments, non-HTML file names) count as bot hits.
- Stored paths drop non-printables and redact email/UUID/token-like segments;
  referrers are plain host names, (ip) for addresses.
- Unknown timezone is a config error; retention cap 750 days; window exact.
- Write/maintenance failures log at most once an hour.
- Attribution moved to Analytics (PageAttribution) so a future beacon route
  can reuse it; platform paths are excluded even under an app mounted at /.
@thorwhalen
thorwhalen merged commit a670937 into main Sep 25, 2026
12 checks passed
@thorwhalen
thorwhalen deleted the feat/privacy-analytics branch September 25, 2026 11:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Privacy-first analytics (cookie-free, no third-party tracker) as an enlace feature enabled per app

1 participant