Privacy-first analytics: opt-in per app, counted server-side, no cookie or script - #57
Merged
Merged
Conversation
…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 /.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #55.
What it does
An app opts in from its own
app.toml: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.
DNTandSec-GPCare honoured, and/_analytics/opt-outis 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, viabuild_backend(..., analytics_store=...), e.g. adolstore. The defaultJsonFileStorelives 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], orenlace.analytics_report(), gives per-path daily counts.Acceptance
enlace/tests/test_analytics.py.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.daily_counts, CLI test).Design record
misc/docs/privacy_analytics.mdcovers:Review
An independent adversarial review found no blockers and ten should-fixes. All ten are addressed in the second commit:
[A-Za-z0-9_.-];--preload.Each fix has a test. The new code adds no dependencies.
Related
https://claude.ai/code/session_01JQJh4hGdYjSKiFkyj9zrAh