From 362696130f12e962f2359efcdfd6c1d2b1ff22db Mon Sep 17 00:00:00 2001
From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com>
Date: Fri, 25 Sep 2026 16:18:54 +0530
Subject: [PATCH 1/2] Privacy-first analytics: opt-in per app, counted
server-side, no cookie 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
---
.claude/CLAUDE.md | 1 +
.claude/skills/enlace/SKILL.md | 6 +
README.md | 25 +
enlace/__init__.py | 12 +
enlace/__main__.py | 46 ++
enlace/analytics.py | 795 +++++++++++++++++++++++++++++
enlace/base.py | 16 +
enlace/compose.py | 28 +-
enlace/data/skills/enlace/SKILL.md | 6 +
enlace/discover.py | 8 +
enlace/tests/test_analytics.py | 488 ++++++++++++++++++
misc/docs/privacy_analytics.md | 95 ++++
tests/test_analytics_browser.py | 111 ++++
13 files changed, 1635 insertions(+), 2 deletions(-)
create mode 100644 enlace/analytics.py
create mode 100644 enlace/tests/test_analytics.py
create mode 100644 misc/docs/privacy_analytics.md
create mode 100644 tests/test_analytics_browser.py
diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md
index b2d8930..c89941c 100644
--- a/.claude/CLAUDE.md
+++ b/.claude/CLAUDE.md
@@ -71,6 +71,7 @@ enlace/
├── supervise.py # Dev-mode asyncio process supervisor (health checks, restart, logs)
├── diagnose.py # diagnose_app(): scan an app dir for enlace compatibility issues
├── manifest.py # DeployManifest schema + /_meta endpoint + X-Deploy-* headers
+├── analytics.py # Opt-in, cookie-free page-view counts: PageViewMiddleware, store, report
├── serve.py # Orchestrates gateway Uvicorn + supervised process-mode children
├── __main__.py # CLI via argh.dispatch_commands
├── __init__.py # Public API facade
diff --git a/.claude/skills/enlace/SKILL.md b/.claude/skills/enlace/SKILL.md
index 7a34292..d04b366 100644
--- a/.claude/skills/enlace/SKILL.md
+++ b/.claude/skills/enlace/SKILL.md
@@ -68,6 +68,7 @@ enlace show-config --json # Machine-readable
enlace show-config --verbose # Show where each value came from
enlace check # Validate config, check route conflicts
enlace list-apps # Table: name, route, type, access
+enlace analytics # Page views per day/path (opted-in apps)
```
## Creating an App
@@ -197,8 +198,13 @@ access = "public"
display_name = "My Custom App"
entry_point = "application.py"
app_attr = "my_app"
+
+[analytics] # optional: cookie-free, server-side page-view counts
+mode = "privacy" # absent / "none" = nothing recorded
```
+Read analytics with `enlace analytics [--app-name X] [--days 30] [--json]`. Storage, retention and timezone live in `platform.toml`'s `[analytics]` table; see `misc/docs/privacy_analytics.md` in the enlace repo.
+
For process-mode apps (non-Python or separate process):
```toml
mode = "process"
diff --git a/README.md b/README.md
index 4f24c99..87cbf63 100644
--- a/README.md
+++ b/README.md
@@ -157,6 +157,9 @@ enlace diagnose
# Analyze an app for enlace compatibility
enlace doctor --base-url http://127.0.0.1:8000
# Post-deploy smoke: probe /auth/csrf and every
# mounted app; exit nonzero on any failure.
+enlace analytics [--app-name kids] [--days 30] [--json]
+ # Page views per day and per path, for apps that
+ # opted in to privacy-first analytics.
```
### Python API
@@ -360,6 +363,28 @@ Home-screen icons must be raster: an app whose icon is only an SVG, emoji or
monogram gets a favicon but no PNG. `enlace.app_icons.home_screen_gaps(config)`
lists those apps.
+### Privacy-first analytics
+
+An app can have page views counted with no cookie, no JavaScript and no third party. It opts in from its own `app.toml`:
+
+```toml
+[analytics]
+mode = "privacy"
+```
+
+The enlace server counts the app's HTML page views as it serves them, and the pages are not changed at all. It stores only daily aggregates per app: views per path (query strings dropped), referrer domain, primary language and device class (mobile/tablet/desktop). Each is kept as a separate count, never crossed with the others. No IP address is read and no visitor identifier is kept. Bots are counted separately. `DNT: 1` and `Sec-GPC: 1` are honoured, and `/_analytics/opt-out` is a page a privacy notice can link to. An app without the table records nothing.
+
+Read the counts on the serving host with `enlace analytics`, or from Python with `enlace.analytics_report()`. Storage defaults to JSON files under `~/.local/share/enlace/analytics`. It is configured in `platform.toml`, and `build_backend(config, analytics_store=...)` takes any `MutableMapping` (e.g. a `dol` store):
+
+```toml
+[analytics]
+store_path = "~/.local/share/enlace/analytics"
+retention_days = 395 # capped at 25 months
+timezone = "Europe/Paris" # whose midnight starts a new day
+```
+
+The design and how it maps onto the CNIL's consent exemption for audience measurement are in [`misc/docs/privacy_analytics.md`](misc/docs/privacy_analytics.md). That doc also lists what a site operator still has to do: a privacy-notice entry, and handling their own access logs.
+
### Deploy manifest (`/_meta`)
enlace answers "what is actually deployed?" via an always-on, cheap manifest
diff --git a/enlace/__init__.py b/enlace/__init__.py
index 7f25d06..2a79e50 100644
--- a/enlace/__init__.py
+++ b/enlace/__init__.py
@@ -8,6 +8,13 @@
from importlib.metadata import version as _version
from pathlib import Path
+from enlace.analytics import (
+ AppAnalyticsConfig,
+ JsonFileStore,
+ PlatformAnalyticsConfig,
+ analytics_report,
+ daily_counts,
+)
from enlace.base import (
AppConfig,
AppImportError,
@@ -37,6 +44,7 @@
__version__ = "0.0.0+local"
__all__ = [
+ "AppAnalyticsConfig",
"AppConfig",
"AppImportError",
"BuildConfig",
@@ -48,14 +56,18 @@
"DiagnosticReport",
"ExternalRef",
"Issue",
+ "JsonFileStore",
"MANIFEST_SCHEMA_VERSION",
+ "PlatformAnalyticsConfig",
"PlatformConfig",
"Plugin",
"ConventionDiscoverer",
"EnlaceConfigError",
"SourceRef",
+ "analytics_report",
"build_backend",
"create_app",
+ "daily_counts",
"diagnose_app",
"discover_apps",
"load_manifest",
diff --git a/enlace/__main__.py b/enlace/__main__.py
index 6bb92b6..b6ef52b 100644
--- a/enlace/__main__.py
+++ b/enlace/__main__.py
@@ -6,6 +6,7 @@
enlace show-config # Show resolved configuration
enlace check # Validate configuration
enlace list-apps # List discovered apps
+ enlace analytics # Page-view counts for apps that opted in
"""
import json as json_module
@@ -603,6 +604,50 @@ def app_meta(
print()
+def analytics(
+ app_name: str = "",
+ *,
+ days: int = 30,
+ json: bool = False,
+):
+ """Show privacy-first page-view counts: per day and per path, for each app.
+
+ Reads the analytics store named by ``platform.toml``'s ``[analytics]`` table
+ (default ``~/.local/share/enlace/analytics``) — run it on the host that
+ serves the apps. Only apps whose ``app.toml`` has ``[analytics] mode =
+ "privacy"`` record anything.
+
+ Args:
+ app_name: Report this app only (default: every app with data).
+ days: How many days back, today included.
+ json: Output the full report (daily series + totals) as JSON.
+ """
+ from enlace.analytics import analytics_report
+
+ report = analytics_report(app_name, days=days)
+ if json:
+ print(json_module.dumps(report, indent=2, ensure_ascii=False))
+ return
+ if not report:
+ print("No analytics recorded yet.")
+ return
+ for name, data in report.items():
+ totals = data["totals"]
+ print(f"{name}: {totals['pageviews']} page views in the last {days} days")
+ for day in reversed(data["days"]):
+ if not day["pageviews"]:
+ continue
+ print(f" {day['date']} {day['pageviews']:>6}")
+ for path, n in sorted(day["paths"].items(), key=lambda kv: -kv[1]):
+ print(f" {n:>6} {path}")
+ for dim in ("referrers", "languages", "devices"):
+ top = ", ".join(f"{k} {v}" for k, v in list(totals[dim].items())[:8])
+ print(f" {dim}: {top or '(none)'}")
+ if totals["bot_hits"]:
+ print(f" bot hits (not counted above): {totals['bot_hits']}")
+ print()
+
+
#: The SSOT for the CLI surface: a verb that is not in this list does not exist.
COMMANDS = [
serve,
@@ -613,6 +658,7 @@ def app_meta(
build,
diagnose,
doctor,
+ analytics,
]
diff --git a/enlace/analytics.py b/enlace/analytics.py
new file mode 100644
index 0000000..ee16b5a
--- /dev/null
+++ b/enlace/analytics.py
@@ -0,0 +1,795 @@
+"""Privacy-first page-view analytics, turned on per app.
+
+An app opts in from its own ``app.toml``::
+
+ [analytics]
+ mode = "privacy"
+
+Without that table the app records nothing. With it, the enlace server that
+already serves the app counts its page views **on the server, from its own
+request handling**: no JavaScript, no beacon, no third-party request, and
+nothing written to the visitor's device. The page a visitor receives is
+byte-for-byte what it would be without analytics.
+
+What is kept: daily aggregates per app, one counter set per dimension:
+
+- ``pageviews``: total HTML page views;
+- ``paths``: views per page path, relative to the app, with query string and
+ fragment dropped (so no campaign IDs or tokens from URLs get in);
+- ``referrers``: the referring *domain* only, or ``(direct)`` / ``(internal)``;
+- ``languages``: the primary subtag of ``Accept-Language`` (``fr``, ``en``);
+- ``devices``: ``mobile`` / ``tablet`` / ``desktop``;
+- ``bot_hits``: page requests from crawlers, counted apart and nowhere else.
+
+Dimensions are stored as separate marginal counts and are **never crossed**
+(no "path × language × device" table), so a rare combination cannot single
+out one visitor on a low-traffic page. No IP address is read, and no
+identifier of any kind is stored. Unique visitors are deliberately not
+counted: see ``misc/docs/privacy_analytics.md`` for why, and for how this
+design maps onto the CNIL's audience-measurement exemption.
+
+Visitors can object: ``DNT: 1`` and ``Sec-GPC: 1`` are honoured, and
+``/_analytics/opt-out`` is a page a privacy notice can link to. It sets a
+single first-party opt-out cookie when (and only when) the visitor asks.
+
+Storage is any ``MutableMapping[str, dict]`` (the ``store`` seam — a ``dol``
+store drops in unchanged); the default is :class:`JsonFileStore` under
+``~/.local/share/enlace/analytics``. Each worker process writes only its own
+records (``{app}/{day}/{writer}``), so several workers never race on a file,
+and readers sum the writers. Records older than ``retention_days`` are purged.
+"""
+
+import json
+import logging
+import os
+import re
+import tempfile
+import threading
+import time
+import uuid
+from collections.abc import Iterator, MutableMapping
+from contextlib import suppress
+from datetime import date, datetime, timedelta
+from pathlib import Path
+from typing import TYPE_CHECKING, Callable, Literal, Optional, Sequence
+from urllib.parse import urlsplit
+from zoneinfo import ZoneInfo
+
+from pydantic import BaseModel, ConfigDict, Field
+
+if TYPE_CHECKING: # pragma: no cover
+ from fastapi import FastAPI
+
+ from enlace.base import AppConfig, PlatformConfig
+
+_logger = logging.getLogger("enlace.analytics")
+
+# ---------------------------------------------------------------------------
+# Configuration
+# ---------------------------------------------------------------------------
+
+AnalyticsMode = Literal["none", "privacy"]
+
+#: The CNIL's ceiling for keeping audience-measurement data: 25 months.
+MAX_RETENTION_DAYS = 25 * 31
+
+#: Where the platform's analytics routes live (the opt-out page).
+ANALYTICS_ROUTE_PREFIX = "/_analytics"
+
+
+class AppAnalyticsConfig(BaseModel):
+ """An app's ``[analytics]`` table in ``app.toml``. Absent means ``none``.
+
+ Strict (unknown keys and modes are errors), so a typo fails at discovery
+ instead of silently collecting nothing — or something else.
+ """
+
+ model_config = ConfigDict(extra="forbid")
+
+ mode: AnalyticsMode = "none"
+
+ @property
+ def enabled(self) -> bool:
+ """Whether this app's page views are counted."""
+ return self.mode != "none"
+
+
+class PlatformAnalyticsConfig(BaseModel):
+ """The platform's ``[analytics]`` table in ``platform.toml``.
+
+ Every field has a working default; the table is only needed to change one.
+ """
+
+ model_config = ConfigDict(extra="forbid")
+
+ store_path: Optional[Path] = Field(
+ default=None,
+ description="Directory of the default JSON store "
+ "(default: $XDG_DATA_HOME/enlace/analytics, i.e. ~/.local/share/...).",
+ )
+ retention_days: int = Field(
+ default=395,
+ ge=1,
+ le=MAX_RETENTION_DAYS,
+ description="Days of daily aggregates kept; older ones are purged. "
+ "Capped at 25 months (the CNIL ceiling).",
+ )
+ timezone: str = Field(
+ default="UTC", description="IANA zone whose midnight starts a new day."
+ )
+ flush_interval_seconds: float = Field(
+ default=10.0,
+ ge=0,
+ description="How often a worker writes its buffered counts (0 = every view).",
+ )
+ max_values_per_dimension: int = Field(
+ default=500,
+ ge=1,
+ description="Distinct values kept per dimension per day and worker; "
+ "the rest are counted under '(other)'. Bounds junk-URL floods.",
+ )
+ honor_opt_out_signals: bool = Field(
+ default=True, description="Skip requests carrying DNT: 1 or Sec-GPC: 1."
+ )
+ exclude_prefixes: tuple[str, ...] = Field(
+ default=("/_", "/auth/"),
+ description="Platform paths never attributed to the landing app.",
+ )
+ opt_out_cookie: str = "enlace_analytics_opt_out"
+
+
+def default_store_path() -> Path:
+ """``$XDG_DATA_HOME/enlace/analytics``, else ``~/.local/share/enlace/analytics``."""
+ base = os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share"
+ return Path(base) / "enlace" / "analytics"
+
+
+# ---------------------------------------------------------------------------
+# Storage: the default store (any MutableMapping[str, dict] will do)
+# ---------------------------------------------------------------------------
+
+_KEY_SEGMENT_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
+
+
+class JsonFileStore(MutableMapping):
+ """``key -> dict``, one JSON file per key at ``{root}/{key}.json``.
+
+ Keys are ``/``-separated relative paths. Writes are atomic (temp file +
+ ``os.replace``), so a reader never sees half a record. Stdlib only; swap in
+ any ``MutableMapping`` (e.g. a ``dol`` store over S3) through the ``store``
+ argument of :func:`make_analytics` or ``build_backend``.
+ """
+
+ _SUFFIX = ".json"
+
+ def __init__(self, root: Path | str):
+ self.root = Path(root).expanduser()
+
+ def _path(self, key: str) -> Path:
+ parts = key.split("/")
+ if not all(_KEY_SEGMENT_RE.match(p) and p not in (".", "..") for p in parts):
+ raise KeyError(f"invalid key: {key!r}")
+ return self.root.joinpath(*parts).with_name(parts[-1] + self._SUFFIX)
+
+ def __getitem__(self, key: str) -> dict:
+ try:
+ return json.loads(self._path(key).read_text(encoding="utf-8"))
+ except FileNotFoundError:
+ raise KeyError(key) from None
+
+ def __setitem__(self, key: str, value: dict) -> None:
+ path = self._path(key)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".tmp-", suffix=".json")
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
+ json.dump(value, f, sort_keys=True)
+ os.replace(tmp, path)
+ except BaseException:
+ with suppress(OSError):
+ os.unlink(tmp)
+ raise
+
+ def __delitem__(self, key: str) -> None:
+ path = self._path(key)
+ try:
+ path.unlink()
+ except FileNotFoundError:
+ raise KeyError(key) from None
+ with suppress(OSError):
+ path.parent.rmdir() # drop the day directory once it is empty
+
+ def __iter__(self) -> Iterator[str]:
+ if not self.root.is_dir():
+ return
+ for path in sorted(self.root.rglob(f"*{self._SUFFIX}")):
+ if path.name.startswith("."):
+ continue
+ yield path.relative_to(self.root).with_suffix("").as_posix()
+
+ def __len__(self) -> int:
+ return sum(1 for _ in self)
+
+
+def _record_key(app: str, day: str, writer: str) -> str:
+ return f"{app}/{day}/{writer}"
+
+
+def _split_key(key: str) -> Optional[tuple[str, str, str]]:
+ parts = key.split("/")
+ return (parts[0], parts[1], parts[2]) if len(parts) == 3 else None
+
+
+# ---------------------------------------------------------------------------
+# Classifying a request (pure functions)
+# ---------------------------------------------------------------------------
+
+OTHER = "(other)"
+DIRECT = "(direct)"
+INTERNAL = "(internal)"
+UNKNOWN = "(unknown)"
+
+_BOT_RE = re.compile(
+ r"bot|crawl|spider|slurp|scrap|curl|wget|python-|httpx|go-http|java/|"
+ r"headless|lighthouse|pingdom|monitor|preview|facebookexternalhit|embedly",
+ re.IGNORECASE,
+)
+_TABLET_RE = re.compile(r"ipad|tablet|kindle|silk|playbook|android(?!.*mobile)", re.I)
+_MOBILE_RE = re.compile(
+ r"mobi|iphone|ipod|android|blackberry|opera mini|iemobile", re.I
+)
+_LANG_RE = re.compile(r"^[a-z]{2,3}$")
+_MAX_PATH_LENGTH = 200
+
+
+def is_page_request(method: str, headers: dict[str, str]) -> bool:
+ """Whether a request is a browser loading a page (not an asset, API or prefetch).
+
+ Modern browsers say so directly (``Sec-Fetch-Dest: document``); older ones
+ are recognised by ``Accept: text/html``. Prefetches and prerenders are not
+ views.
+ """
+ if method != "GET":
+ return False
+ purpose = headers.get("sec-purpose", "") + headers.get("purpose", "")
+ if "prefetch" in purpose.lower():
+ return False
+ dest = headers.get("sec-fetch-dest")
+ if dest is not None:
+ return dest == "document"
+ return "text/html" in headers.get("accept", "").lower()
+
+
+def is_page_response(status: int, headers: dict[str, str]) -> bool:
+ """Whether a response delivered a page: a 200 HTML body, or a 304 revalidation."""
+ if status == 304:
+ return True
+ return status == 200 and headers.get("content-type", "").lower().startswith(
+ "text/html"
+ )
+
+
+def has_opted_out(headers: dict[str, str], *, cookie_name: str) -> bool:
+ """``DNT: 1``, ``Sec-GPC: 1``, or the platform's opt-out cookie."""
+ if headers.get("dnt") == "1" or headers.get("sec-gpc") == "1":
+ return True
+ for part in headers.get("cookie", "").split(";"):
+ name, _, value = part.strip().partition("=")
+ if name == cookie_name and value == "1":
+ return True
+ return False
+
+
+def device_class(user_agent: str, *, client_hint_mobile: str = "") -> str:
+ """``bot``, ``tablet``, ``mobile`` or ``desktop``, from the User-Agent alone."""
+ if not user_agent or _BOT_RE.search(user_agent):
+ return "bot"
+ if _TABLET_RE.search(user_agent):
+ return "tablet"
+ if client_hint_mobile == "?1" or _MOBILE_RE.search(user_agent):
+ return "mobile"
+ return "desktop"
+
+
+def primary_language(accept_language: str) -> str:
+ """Primary subtag of the first ``Accept-Language`` entry (``fr-FR`` → ``fr``)."""
+ first = accept_language.split(",", 1)[0].split(";", 1)[0].strip().lower()
+ tag = first.split("-", 1)[0]
+ return tag if _LANG_RE.match(tag) else UNKNOWN
+
+
+def referrer_domain(referer: str, *, host: str) -> str:
+ """The referring host only; ``(direct)`` if none, ``(internal)`` if this site."""
+ if not referer:
+ return DIRECT
+ try:
+ hostname = urlsplit(referer).hostname
+ except ValueError:
+ return UNKNOWN
+ if not hostname:
+ return UNKNOWN
+ own = host.rsplit(":", 1)[0].lower() if host else ""
+ return INTERNAL if hostname == own else hostname
+
+
+def normalize_path(path: str) -> str:
+ """A page path fit to store: ``index.html`` folded into its directory, capped."""
+ if path.endswith("/index.html"):
+ path = path[: -len("index.html")]
+ return (path or "/")[:_MAX_PATH_LENGTH]
+
+
+# ---------------------------------------------------------------------------
+# Counting
+# ---------------------------------------------------------------------------
+
+DIMENSIONS = ("paths", "referrers", "languages", "devices")
+
+
+def _empty_record() -> dict:
+ return {"pageviews": 0, "bot_hits": 0, **{d: {} for d in DIMENSIONS}}
+
+
+def _copy_record(rec: dict) -> dict:
+ return {**rec, **{d: dict(rec[d]) for d in DIMENSIONS}}
+
+
+class PageViewCounter:
+ """Buffers one worker's daily aggregates and flushes them to ``store``.
+
+ Each counter writes only under its own ``writer`` id, overwriting its own
+ cumulative record for the day, so concurrent workers never read-modify-write
+ a shared record. A flush happens at most every ``flush_interval_seconds``
+ (on the next view), on :meth:`flush`, and at server shutdown.
+ """
+
+ def __init__(
+ self,
+ store: MutableMapping,
+ *,
+ retention_days: int = 395,
+ timezone: str = "UTC",
+ flush_interval_seconds: float = 10.0,
+ max_values_per_dimension: int = 500,
+ writer: Optional[str] = None,
+ clock: Callable[[], float] = time.time,
+ ):
+ self.store = store
+ self.retention_days = retention_days
+ self._tz = ZoneInfo(timezone)
+ self._flush_interval = flush_interval_seconds
+ self._max_values = max_values_per_dimension
+ self.writer = writer or uuid.uuid4().hex[:12]
+ self._clock = clock
+ self._lock = threading.Lock()
+ self._records: dict[tuple[str, str], dict] = {}
+ self._dirty: set[tuple[str, str]] = set()
+ self._last_flush = clock()
+ self._purged_through: Optional[str] = None
+
+ def today(self) -> str:
+ """The current day, ISO format, in the configured timezone."""
+ return datetime.fromtimestamp(self._clock(), tz=self._tz).date().isoformat()
+
+ def record_view(
+ self,
+ app: str,
+ *,
+ path: str,
+ referrer: str,
+ language: str,
+ device: str,
+ ) -> None:
+ """Count one page view of ``app`` (a ``bot`` device counts as a bot hit)."""
+ with self._lock:
+ rec = self._record_for(app)
+ if device == "bot":
+ rec["bot_hits"] += 1
+ else:
+ rec["pageviews"] += 1
+ for dim, value in zip(DIMENSIONS, (path, referrer, language, device)):
+ self._bump(rec[dim], value)
+ self.maybe_flush()
+
+ def _record_for(self, app: str) -> dict:
+ key = (app, self.today())
+ self._dirty.add(key)
+ if key not in self._records:
+ self._records[key] = _empty_record()
+ return self._records[key]
+
+ def _bump(self, counts: dict, value: str) -> None:
+ if value not in counts and len(counts) >= self._max_values:
+ value = OTHER
+ counts[value] = counts.get(value, 0) + 1
+
+ def maybe_flush(self) -> None:
+ """Flush if the flush interval has elapsed since the last one."""
+ if self._clock() - self._last_flush >= self._flush_interval:
+ self.flush()
+
+ def flush(self) -> None:
+ """Write every changed record, forget finished days, purge expired data."""
+ with self._lock:
+ today = self.today()
+ pending = {k: _copy_record(self._records[k]) for k in self._dirty}
+ self._dirty.clear()
+ self._last_flush = self._clock()
+ # A finished day's final state is in ``pending`` (or already written).
+ for key in [k for k in self._records if k[1] != today]:
+ del self._records[key]
+ for (app, day), rec in pending.items():
+ try:
+ self.store[_record_key(app, day, self.writer)] = rec
+ except Exception: # analytics must never break serving
+ _logger.warning(
+ "analytics: could not write %s/%s", app, day, exc_info=True
+ )
+ if self._purged_through != today:
+ self.purge_expired(today=today)
+
+ def purge_expired(self, *, today: Optional[str] = None) -> int:
+ """Delete records older than the retention window; return how many."""
+ today = today or self.today()
+ cutoff = (
+ date.fromisoformat(today) - timedelta(days=self.retention_days)
+ ).isoformat()
+ removed = 0
+ try:
+ for key in list(self.store):
+ parts = _split_key(key)
+ if parts is not None and parts[1] < cutoff:
+ try:
+ del self.store[key]
+ removed += 1
+ except KeyError: # another worker got there first
+ pass
+ except Exception:
+ _logger.warning("analytics: retention purge failed", exc_info=True)
+ return removed
+ self._purged_through = today
+ return removed
+
+
+# ---------------------------------------------------------------------------
+# Collecting: the middleware
+# ---------------------------------------------------------------------------
+
+
+def _headers(scope) -> dict[str, str]:
+ return {
+ k.decode("latin-1").lower(): v.decode("latin-1")
+ for k, v in scope.get("headers", [])
+ }
+
+
+class PageViewMiddleware:
+ """Pure-ASGI middleware counting page views of the apps that opted in.
+
+ It only observes: the request and response pass through unchanged, and
+ nothing is added to the page. A page view is attributed to the app whose
+ mount (``/{name}/`` or its route prefix) is the longest match; other paths
+ go to the landing app, except the platform's own (``exclude_prefixes``).
+ """
+
+ def __init__(
+ self,
+ app,
+ *,
+ counter: PageViewCounter,
+ apps: Sequence["AppConfig"],
+ landing_app: Optional[str] = None,
+ settings: Optional[PlatformAnalyticsConfig] = None,
+ ):
+ self.app = app
+ self._counter = counter
+ self._settings = settings or PlatformAnalyticsConfig()
+ enabled = {a.name for a in apps if a.analytics.enabled}
+ prefixes = []
+ for a in apps:
+ for prefix in {f"/{a.name}/", a.route_prefix.rstrip("/") + "/"}:
+ prefixes.append((prefix, a.name))
+ # Every app's prefixes (not just enabled ones): a disabled app's page
+ # must never fall through to an enabled landing app.
+ self._prefixes = sorted(prefixes, key=lambda p: -len(p[0]))
+ self._enabled = enabled
+ self._landing = landing_app if landing_app in enabled else None
+
+ def attribute(self, path: str) -> Optional[tuple[str, str]]:
+ """``(app, app-relative path)`` for a page to count, or ``None``."""
+ for prefix, name in self._prefixes:
+ if path.startswith(prefix):
+ if name not in self._enabled:
+ return None
+ return name, normalize_path("/" + path[len(prefix) :])
+ if self._landing and not path.startswith(self._settings.exclude_prefixes):
+ return self._landing, normalize_path(path)
+ return None
+
+ async def __call__(self, scope, receive, send):
+ if scope["type"] == "lifespan":
+ await self.app(scope, self._flushing_on_shutdown(receive), send)
+ return
+ if scope["type"] != "http":
+ await self.app(scope, receive, send)
+ return
+ target = self.attribute(scope.get("path", ""))
+ headers = _headers(scope) if target else {}
+ if (
+ target is None
+ or not is_page_request(scope.get("method", ""), headers)
+ or (
+ self._settings.honor_opt_out_signals
+ and has_opted_out(headers, cookie_name=self._settings.opt_out_cookie)
+ )
+ ):
+ await self.app(scope, receive, send)
+ return
+
+ async def observing_send(message):
+ if message["type"] == "http.response.start":
+ self._maybe_count(target, headers, message)
+ await send(message)
+
+ await self.app(scope, receive, observing_send)
+
+ def _maybe_count(self, target, headers, message) -> None:
+ try:
+ response_headers = {
+ k.decode("latin-1").lower(): v.decode("latin-1")
+ for k, v in message.get("headers", [])
+ }
+ if not is_page_response(message["status"], response_headers):
+ return
+ app, path = target
+ self._counter.record_view(
+ app,
+ path=path,
+ referrer=referrer_domain(
+ headers.get("referer", ""), host=headers.get("host", "")
+ ),
+ language=primary_language(headers.get("accept-language", "")),
+ device=device_class(
+ headers.get("user-agent", ""),
+ client_hint_mobile=headers.get("sec-ch-ua-mobile", ""),
+ ),
+ )
+ except Exception: # counting must never break a page
+ _logger.warning("analytics: failed to count a page view", exc_info=True)
+
+ def _flushing_on_shutdown(self, receive):
+ async def wrapped():
+ message = await receive()
+ if message.get("type") == "lifespan.shutdown":
+ try:
+ self._counter.flush()
+ except Exception:
+ _logger.warning("analytics: final flush failed", exc_info=True)
+ return message
+
+ return wrapped
+
+
+# ---------------------------------------------------------------------------
+# The opt-out page
+# ---------------------------------------------------------------------------
+
+_OPT_OUT_TEXT = {
+ "en": {
+ "title": "Audience statistics",
+ "about": "This site counts page views anonymously, on its own server: "
+ "no tracker, no third party, nothing stored about you.",
+ "out": "You have opted out: your visits are not counted.",
+ "in": "Your visits are counted, anonymously.",
+ "do_out": "Don't count my visits",
+ "do_in": "Count my visits again",
+ },
+ "fr": {
+ "title": "Statistiques de fréquentation",
+ "about": "Ce site compte les pages vues de façon anonyme, sur son propre "
+ "serveur : aucun traceur, aucun tiers, rien n'est conservé sur vous.",
+ "out": "Vos visites ne sont plus comptées.",
+ "in": "Vos visites sont comptées, de façon anonyme.",
+ "do_out": "Ne plus compter mes visites",
+ "do_in": "Compter à nouveau mes visites",
+ },
+}
+
+#: How long the opt-out choice is remembered (13 months, the CNIL maximum).
+_OPT_OUT_MAX_AGE = 13 * 31 * 24 * 3600
+
+
+def _add_opt_out_route(parent: "FastAPI", settings: PlatformAnalyticsConfig) -> None:
+ """``GET /_analytics/opt-out[?choice=out|in]``: a page a privacy notice links to.
+
+ A plain page with two links, no script. Choosing "out" sets one first-party
+ cookie (exempt from consent: it stores the visitor's refusal); "in" removes
+ it. GET, so it works as a link and needs no CSRF token; the worst a forged
+ link can do is stop counting someone.
+ """
+ import html
+
+ from fastapi import Request
+ from fastapi.responses import HTMLResponse
+
+ cookie = settings.opt_out_cookie
+ base = f"{ANALYTICS_ROUTE_PREFIX}/opt-out"
+
+ @parent.get(base, include_in_schema=False)
+ async def opt_out_page(request: Request, choice: str = "") -> HTMLResponse:
+ lang = primary_language(request.headers.get("accept-language", ""))
+ text = _OPT_OUT_TEXT.get(lang, _OPT_OUT_TEXT["en"])
+ opted_out = request.cookies.get(cookie) == "1"
+ if choice in ("out", "in"):
+ opted_out = choice == "out"
+ t = {k: html.escape(v) for k, v in text.items()}
+ body = (
+ f''
+ ''
+ ''
+ f"{t['title']}"
+ ''
+ f"{t['title']}
{t['about']}
"
+ f"{t['out'] if opted_out else t['in']}
"
+ f''
+ f"{t['do_in'] if opted_out else t['do_out']}
"
+ )
+ response = HTMLResponse(body, headers={"Cache-Control": "no-store"})
+ if choice == "out":
+ response.set_cookie(
+ cookie,
+ "1",
+ max_age=_OPT_OUT_MAX_AGE,
+ path="/",
+ httponly=True,
+ samesite="lax",
+ secure=request.url.scheme == "https",
+ )
+ elif choice == "in":
+ response.delete_cookie(cookie, path="/")
+ return response
+
+
+# ---------------------------------------------------------------------------
+# Wiring (called by build_backend)
+# ---------------------------------------------------------------------------
+
+
+class Analytics:
+ """The analytics feature for one platform: its counter, routes and middleware."""
+
+ def __init__(self, config: "PlatformConfig", counter: PageViewCounter):
+ self.config = config
+ self.settings = config.analytics
+ self.counter = counter
+
+ def add_routes(self, parent: "FastAPI") -> None:
+ """Register the opt-out page. Call before any catch-all ``/`` mount."""
+ _add_opt_out_route(parent, self.settings)
+ parent.state.analytics = self
+
+ def add_middleware(self, parent: "FastAPI") -> None:
+ """Install the counting middleware (it only observes; order-insensitive)."""
+ parent.add_middleware(
+ PageViewMiddleware,
+ counter=self.counter,
+ apps=list(self.config.apps),
+ landing_app=self.config.landing_app,
+ settings=self.settings,
+ )
+
+
+def make_analytics(
+ config: "PlatformConfig", *, store: Optional[MutableMapping] = None
+) -> Optional[Analytics]:
+ """The platform's analytics, or ``None`` when no app has opted in.
+
+ ``store`` is the storage seam: any ``MutableMapping[str, dict]``. Default: a
+ :class:`JsonFileStore` at ``[analytics].store_path`` (or
+ :func:`default_store_path`).
+ """
+ if not any(a.analytics.enabled for a in config.apps):
+ return None
+ settings = config.analytics
+ if store is None:
+ store = JsonFileStore(settings.store_path or default_store_path())
+ counter = PageViewCounter(
+ store,
+ retention_days=settings.retention_days,
+ timezone=settings.timezone,
+ flush_interval_seconds=settings.flush_interval_seconds,
+ max_values_per_dimension=settings.max_values_per_dimension,
+ )
+ return Analytics(config, counter)
+
+
+# ---------------------------------------------------------------------------
+# Reading
+# ---------------------------------------------------------------------------
+
+
+def _merge(into: dict, rec: dict) -> None:
+ into["pageviews"] += rec.get("pageviews", 0)
+ into["bot_hits"] += rec.get("bot_hits", 0)
+ for dim in DIMENSIONS:
+ for value, n in (rec.get(dim) or {}).items():
+ into[dim][value] = into[dim].get(value, 0) + n
+
+
+def apps_with_data(store: MutableMapping) -> list[str]:
+ """Names of the apps that have any analytics records."""
+ return sorted({p[0] for k in store if (p := _split_key(k)) is not None})
+
+
+def daily_counts(
+ store: MutableMapping,
+ app: str,
+ *,
+ days: int = 30,
+ today: Optional[str] = None,
+ timezone: str = "UTC",
+) -> list[dict]:
+ """One merged record per day for the last ``days`` days, oldest first.
+
+ Days with no data are included, with zero counts, so the series has no
+ gaps. Each item is ``{"date": ..., "pageviews": ..., "bot_hits": ...,
+ "paths": {...}, "referrers": {...}, "languages": {...}, "devices": {...}}``.
+ """
+ today = today or datetime.now(ZoneInfo(timezone)).date().isoformat()
+ last = date.fromisoformat(today)
+ wanted = [(last - timedelta(days=i)).isoformat() for i in range(days - 1, -1, -1)]
+ by_day = {d: {"date": d, **_empty_record()} for d in wanted}
+ for key in store:
+ parts = _split_key(key)
+ if parts is None or parts[0] != app or parts[1] not in by_day:
+ continue
+ try:
+ _merge(by_day[parts[1]], store[key])
+ except KeyError: # purged while we were reading
+ continue
+ return [by_day[d] for d in wanted]
+
+
+def summarize(daily: list[dict]) -> dict:
+ """Totals over a :func:`daily_counts` series: pageviews and each dimension."""
+ total = _empty_record()
+ for day in daily:
+ _merge(total, day)
+
+ def ranked(counts: dict) -> dict:
+ return dict(sorted(counts.items(), key=lambda kv: (-kv[1], kv[0])))
+
+ return {
+ "pageviews": total["pageviews"],
+ "bot_hits": total["bot_hits"],
+ **{dim: ranked(total[dim]) for dim in DIMENSIONS},
+ }
+
+
+def analytics_report(
+ app: str = "",
+ *,
+ days: int = 30,
+ config: Optional["PlatformConfig"] = None,
+ store: Optional[MutableMapping] = None,
+) -> dict:
+ """Per-app report for the last ``days`` days: daily series + totals.
+
+ With no ``app``, reports every app that has data. ``config`` defaults to
+ ``platform.toml`` in the current directory (only its ``[analytics]`` table
+ is used; no app is imported).
+ """
+ if config is None:
+ from enlace.base import PlatformConfig
+
+ config = PlatformConfig.from_toml()
+ settings = config.analytics
+ if store is None:
+ store = JsonFileStore(settings.store_path or default_store_path())
+ names = [app] if app else apps_with_data(store)
+ report = {}
+ for name in names:
+ series = daily_counts(store, name, days=days, timezone=settings.timezone)
+ report[name] = {"days": series, "totals": summarize(series)}
+ return report
diff --git a/enlace/base.py b/enlace/base.py
index 4e21c44..fac9aca 100644
--- a/enlace/base.py
+++ b/enlace/base.py
@@ -26,6 +26,7 @@
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
+from enlace.analytics import AppAnalyticsConfig, PlatformAnalyticsConfig
from enlace.appmeta import AppMetaConfig
if sys.version_info >= (3, 11):
@@ -155,6 +156,10 @@ class AppConfig(BaseModel):
display_name: str = ""
provenance: dict[str, str] = Field(default_factory=dict)
+ # Privacy-first page-view analytics (app.toml's [analytics] table). Off
+ # unless the app opts in with mode = "privacy". See enlace.analytics.
+ analytics: AppAnalyticsConfig = Field(default_factory=AppAnalyticsConfig)
+
# Set only when discovery ran with ``on_import_error="record"`` and this
# app's entry module raised on import. ``None`` on every healthy app, and
# on every app discovered under the default ``"raise"`` policy (which
@@ -326,6 +331,9 @@ def _check_cors_origins(cls, origins: list[str]) -> list[str]:
# and `store_path` are carried for the enlace_auth plugin (the editable
# overlay's authz + persistence), which enlace core never interprets.
app_meta: AppMetaConfig = Field(default_factory=AppMetaConfig)
+ # Platform-wide analytics settings (platform.toml [analytics]): storage,
+ # retention, timezone. Apps opt in individually; see enlace.analytics.
+ analytics: PlatformAnalyticsConfig = Field(default_factory=PlatformAnalyticsConfig)
@model_validator(mode="after")
def _normalize_dirs(self):
@@ -389,6 +397,10 @@ def from_toml(cls, path: Path = Path("platform.toml")) -> "PlatformConfig":
app_meta_data = data.get("app_meta")
if app_meta_data is not None:
platform_data["app_meta"] = app_meta_data
+ # [analytics] table — storage/retention for per-app analytics.
+ analytics_data = data.get("analytics")
+ if analytics_data is not None:
+ platform_data["analytics"] = analytics_data
# Resolve relative path-like fields against the TOML file's own
# directory (not the CWD), so the config is host-portable. Done
@@ -414,6 +426,10 @@ def _resolve(value: Any) -> Path:
if app_meta.get(key):
app_meta[key] = _resolve(app_meta[key])
+ analytics = platform_data.get("analytics")
+ if isinstance(analytics, dict) and analytics.get("store_path"):
+ analytics["store_path"] = _resolve(analytics["store_path"])
+
# Environment variable overrides
env_apps_dirs = os.environ.get("ENLACE_APPS_DIRS", "")
if env_apps_dirs:
diff --git a/enlace/compose.py b/enlace/compose.py
index 021e5a2..ad9f24e 100644
--- a/enlace/compose.py
+++ b/enlace/compose.py
@@ -21,7 +21,7 @@
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from pathlib import Path
-from typing import Callable, Optional, Sequence
+from typing import Callable, MutableMapping, Optional, Sequence
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, RedirectResponse, Response
@@ -59,7 +59,12 @@ class EnlaceConfigError(RuntimeError):
"""
-def build_backend(config: PlatformConfig, *, plugins: Sequence[Plugin] = ()) -> FastAPI:
+def build_backend(
+ config: PlatformConfig,
+ *,
+ plugins: Sequence[Plugin] = (),
+ analytics_store: Optional[MutableMapping] = None,
+) -> FastAPI:
"""Compose all app backends into a single ASGI application.
For each discovered app:
@@ -71,6 +76,10 @@ def build_backend(config: PlatformConfig, *, plugins: Sequence[Plugin] = ()) ->
Args:
config: Platform configuration with apps already discovered.
+ plugins: Compose-time plugins, e.g. ``enlace_auth.plugin``.
+ analytics_store: Where page-view analytics go, for apps that opted in
+ (any ``MutableMapping[str, dict]``, e.g. a ``dol`` store). Default:
+ JSON files under ``[analytics].store_path``. See enlace.analytics.
Returns:
A FastAPI application with all sub-apps mounted.
@@ -129,6 +138,15 @@ async def cascade_lifespan(app: FastAPI):
# HTML index_page is disabled).
_add_apps_listing_route(parent, config)
+ # Privacy-first analytics, only when some app opted in. Its routes (the
+ # opt-out page) go in now, before any catch-all "/" mount can shadow them;
+ # its middleware goes in with the others below.
+ from enlace.analytics import make_analytics
+
+ analytics = make_analytics(config, store=analytics_store)
+ if analytics is not None:
+ analytics.add_routes(parent)
+
# Deploy manifest endpoints + response headers. Built once at startup;
# cheap (one HTTP route per app + a small middleware). Endpoints are
# registered BEFORE sub-app mounts so they win the path match.
@@ -258,6 +276,12 @@ async def cascade_lifespan(app: FastAPI):
is_protected=lambda app: app.access.startswith("protected"),
)
+ # Count page views of the apps that opted in. It only observes status and
+ # content type (nothing in the body), so its position is not load-bearing;
+ # it sits inside GZip only so it sees the same responses the others do.
+ if analytics is not None:
+ analytics.add_middleware(parent)
+
# Compress sizeable text/JSON responses. Added LAST so it is the OUTERMOST
# middleware — it must wrap everything downstream (meta injection, sub-app
# responses, static files) and see final bytes.
diff --git a/enlace/data/skills/enlace/SKILL.md b/enlace/data/skills/enlace/SKILL.md
index 65a3797..ae18ae4 100644
--- a/enlace/data/skills/enlace/SKILL.md
+++ b/enlace/data/skills/enlace/SKILL.md
@@ -59,6 +59,7 @@ enlace show-config --json # Machine-readable
enlace show-config --verbose # Show where each value came from
enlace check # Validate config, check route conflicts
enlace list-apps # Table: name, route, type, access
+enlace analytics # Page views per day/path (opted-in apps)
```
## Creating an App
@@ -183,8 +184,13 @@ access = "public"
display_name = "My Custom App"
entry_point = "application.py"
app_attr = "my_app"
+
+[analytics] # optional: cookie-free, server-side page-view counts
+mode = "privacy" # absent / "none" = nothing recorded
```
+Read analytics with `enlace analytics [--app-name X] [--days 30] [--json]`. Storage, retention and timezone live in `platform.toml`'s `[analytics]` table; see `misc/docs/privacy_analytics.md` in the enlace repo.
+
### Override Precedence (lowest → highest)
```
diff --git a/enlace/discover.py b/enlace/discover.py
index a96050c..829e708 100644
--- a/enlace/discover.py
+++ b/enlace/discover.py
@@ -433,6 +433,14 @@ def _overlay_toml_fields(
fields["build"] = _parse_build_config(build_table, app_dir)
provenance["build"] = "override: app.toml [build]"
+ # [analytics] — strict: a typo'd key or mode fails discovery loudly.
+ analytics_table = toml_data.get("analytics")
+ if analytics_table is not None:
+ from enlace.analytics import AppAnalyticsConfig
+
+ fields["analytics"] = AppAnalyticsConfig.model_validate(analytics_table)
+ provenance["analytics"] = "override: app.toml [analytics]"
+
return fields, provenance
diff --git a/enlace/tests/test_analytics.py b/enlace/tests/test_analytics.py
new file mode 100644
index 0000000..ad90297
--- /dev/null
+++ b/enlace/tests/test_analytics.py
@@ -0,0 +1,488 @@
+"""Privacy-first analytics (issue #55): opt-in per app, counted on the server.
+
+The acceptance line, piece by piece:
+
+- an app with ``[analytics] mode = "privacy"`` records a page view;
+- an app without the key records nothing;
+- the page is unchanged and sets no cookie (the browser-level half of that
+ check — no request to another origin, nothing in storage — is
+ ``misc/analytics_browser_check.py``, run in a headless browser);
+- the owner can read per-path daily counts for the last 30 days.
+"""
+
+import json
+import subprocess
+import sys
+import textwrap
+from pathlib import Path
+
+import pytest
+from pydantic import ValidationError
+from starlette.testclient import TestClient
+
+from enlace import analytics as an
+from enlace.base import PlatformConfig
+from enlace.compose import build_backend
+from enlace.discover import discover_apps
+
+BROWSER = {
+ "accept": "text/html,application/xhtml+xml,*/*;q=0.8",
+ "sec-fetch-dest": "document",
+ "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) Firefox/130.0",
+ "accept-language": "fr-FR,fr;q=0.9,en;q=0.8",
+}
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+def _frontend_app(apps_dir: Path, name: str, analytics_mode: str = "") -> Path:
+ d = apps_dir / name
+ (d / "frontend" / "assets").mkdir(parents=True)
+ (d / "frontend" / "index.html").write_text(
+ f"{name}"
+ f"{name}"
+ )
+ (d / "frontend" / "assets" / "app.css").write_text("body{}")
+ if analytics_mode:
+ (d / "app.toml").write_text(f'[analytics]\nmode = "{analytics_mode}"\n')
+ return d
+
+
+@pytest.fixture
+def platform(tmp_path):
+ """Two frontend apps: ``kids`` opted in, ``plain`` not. Instant flushes."""
+ apps_dir = tmp_path / "apps"
+ _frontend_app(apps_dir, "kids", "privacy")
+ _frontend_app(apps_dir, "plain")
+ store = an.JsonFileStore(tmp_path / "analytics")
+ config = discover_apps(
+ PlatformConfig(
+ apps_dirs=[apps_dir],
+ analytics={"flush_interval_seconds": 0},
+ )
+ )
+ return config, store
+
+
+def _client(config, store):
+ return TestClient(build_backend(config, analytics_store=store))
+
+
+def _report(store, app, days=30):
+ return an.summarize(an.daily_counts(store, app, days=days))
+
+
+# ---------------------------------------------------------------------------
+# The acceptance line
+# ---------------------------------------------------------------------------
+
+
+def test_opted_in_app_records_a_page_view(platform):
+ config, store = platform
+ client = _client(config, store)
+ assert client.get("/kids/", headers=BROWSER).status_code == 200
+ totals = _report(store, "kids")
+ assert totals["pageviews"] == 1
+ assert totals["paths"] == {"/": 1}
+ assert totals["languages"] == {"fr": 1}
+ assert totals["devices"] == {"desktop": 1}
+ assert totals["referrers"] == {an.DIRECT: 1}
+
+
+def test_app_without_the_key_records_nothing(platform):
+ config, store = platform
+ client = _client(config, store)
+ assert client.get("/plain/", headers=BROWSER).status_code == 200
+ assert "plain" not in an.apps_with_data(store)
+ assert _report(store, "plain")["pageviews"] == 0
+
+
+def test_no_app_opted_in_means_no_analytics_at_all(tmp_path):
+ """Not even the opt-out route or the middleware exist."""
+ _frontend_app(tmp_path / "apps", "plain")
+ config = discover_apps(PlatformConfig(apps_dirs=[tmp_path / "apps"]))
+ store = an.JsonFileStore(tmp_path / "analytics")
+ backend = build_backend(config, analytics_store=store)
+ assert not hasattr(backend.state, "analytics")
+ client = TestClient(backend)
+ assert client.get("/plain/", headers=BROWSER).status_code == 200
+ assert client.get("/_analytics/opt-out").status_code == 404
+ assert list(store) == []
+
+
+def test_page_is_unchanged_and_sets_no_cookie(platform, tmp_path):
+ """Counting adds nothing to the response: same bytes, same headers, no cookie."""
+ config, store = platform
+ with_analytics = _client(config, store).get("/kids/", headers=BROWSER)
+ plain_config = config.model_copy(deep=True)
+ for app in plain_config.apps:
+ app.analytics = an.AppAnalyticsConfig()
+ without = TestClient(build_backend(plain_config)).get("/kids/", headers=BROWSER)
+ assert "set-cookie" not in with_analytics.headers
+ assert with_analytics.content == without.content
+ assert dict(with_analytics.headers) == dict(without.headers)
+
+
+def test_owner_reads_per_path_daily_counts_for_30_days(platform):
+ config, store = platform
+ client = _client(config, store)
+ for path in ("/kids/", "/kids/lesson/1", "/kids/lesson/1?utm_source=x"):
+ client.get(path, headers=BROWSER)
+ series = an.daily_counts(store, "kids", days=30)
+ assert len(series) == 30
+ assert series[-1]["paths"] == {"/": 1, "/lesson/1": 2} # query string dropped
+ assert all(day["pageviews"] == 0 for day in series[:-1])
+
+
+# ---------------------------------------------------------------------------
+# What is (not) a page view
+# ---------------------------------------------------------------------------
+
+
+def test_assets_api_calls_and_prefetches_are_not_page_views(platform):
+ config, store = platform
+ client = _client(config, store)
+ client.get("/kids/assets/app.css", headers={**BROWSER, "sec-fetch-dest": "style"})
+ client.get("/kids/", headers={**BROWSER, "sec-fetch-dest": "empty"}) # fetch()
+ client.get("/kids/", headers={**BROWSER, "sec-purpose": "prefetch"})
+ client.head("/kids/", headers=BROWSER)
+ client.get("/_apps", headers=BROWSER)
+ assert _report(store, "kids")["pageviews"] == 0
+
+
+def test_opt_out_signals_are_honoured(platform):
+ config, store = platform
+ client = _client(config, store)
+ client.get("/kids/", headers={**BROWSER, "dnt": "1"})
+ client.get("/kids/", headers={**BROWSER, "sec-gpc": "1"})
+ client.get("/kids/", headers={**BROWSER, "cookie": "enlace_analytics_opt_out=1"})
+ assert _report(store, "kids")["pageviews"] == 0
+
+
+def test_bots_are_counted_apart(platform):
+ config, store = platform
+ client = _client(config, store)
+ client.get("/kids/", headers={**BROWSER, "user-agent": "Googlebot/2.1"})
+ totals = _report(store, "kids")
+ assert totals["pageviews"] == 0
+ assert totals["bot_hits"] == 1
+ assert totals["devices"] == {}
+
+
+def test_referrer_is_reduced_to_its_domain(platform):
+ config, store = platform
+ client = _client(config, store)
+ client.get(
+ "/kids/",
+ headers={**BROWSER, "referer": "https://www.ecole.fr/classe/42?eleve=bob"},
+ )
+ client.get("/kids/", headers={**BROWSER, "referer": "http://testserver/kids/"})
+ assert _report(store, "kids")["referrers"] == {
+ "www.ecole.fr": 1,
+ an.INTERNAL: 1,
+ }
+
+
+def test_redirects_and_errors_are_not_page_views(platform):
+ """Only the page the visitor finally sees counts: a 307 hop does not."""
+ config, store = platform
+ client = _client(config, store)
+ client.get("/kids", headers=BROWSER) # bare prefix: 307 → /kids/
+ assert _report(store, "kids")["pageviews"] == 1
+ assert not an.is_page_response(404, {"content-type": "text/html"})
+ assert not an.is_page_response(307, {})
+
+
+def test_missing_image_is_not_a_page_view(platform):
+ config, store = platform
+ client = _client(config, store)
+ client.get("/kids/missing.png", headers={**BROWSER, "sec-fetch-dest": "image"})
+ assert _report(store, "kids")["pageviews"] == 0
+
+
+def test_landing_app_gets_root_pages_but_not_platform_or_other_apps(tmp_path):
+ apps_dir = tmp_path / "apps"
+ _frontend_app(apps_dir, "home", "privacy")
+ _frontend_app(apps_dir, "plain")
+ config = discover_apps(
+ PlatformConfig(
+ apps_dirs=[apps_dir],
+ landing_app="home",
+ analytics={"flush_interval_seconds": 0},
+ )
+ )
+ store = an.JsonFileStore(tmp_path / "analytics")
+ client = _client(config, store)
+ client.get("/", headers=BROWSER)
+ client.get("/plain/", headers=BROWSER) # another app, not opted in
+ client.get("/_analytics/opt-out", headers=BROWSER) # platform page
+ assert _report(store, "home")["paths"] == {"/": 1}
+
+
+# ---------------------------------------------------------------------------
+# The opt-out page
+# ---------------------------------------------------------------------------
+
+
+def test_opt_out_page_sets_a_cookie_only_when_asked(platform):
+ config, store = platform
+ client = _client(config, store)
+ page = client.get("/_analytics/opt-out", headers=BROWSER)
+ assert page.status_code == 200
+ assert "set-cookie" not in page.headers
+ assert "Statistiques" in page.text # French for a French browser
+ assert "