Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions .claude/skills/enlace/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,9 @@ enlace diagnose <dir> # 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
Expand Down Expand Up @@ -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 # at most 750 (under 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
Expand Down
12 changes: 12 additions & 0 deletions enlace/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -37,6 +44,7 @@
__version__ = "0.0.0+local"

__all__ = [
"AppAnalyticsConfig",
"AppConfig",
"AppImportError",
"BuildConfig",
Expand All @@ -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",
Expand Down
56 changes: 56 additions & 0 deletions enlace/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -603,6 +604,60 @@ 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
in the current directory (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, default_analytics_store

config = PlatformConfig.from_toml()
store = default_analytics_store(config)
report = analytics_report(app_name, days=days, config=config, store=store)
if json:
print(json_module.dumps(report, indent=2, ensure_ascii=False))
return

def shown(value) -> str:
# Stored values are sanitized on write; this also covers older data.
return "".join(ch for ch in str(value) if ch.isprintable())

where = "platform.toml" if Path("platform.toml").exists() else "defaults"
print(f"store: {store.root} (from {where})", file=sys.stderr)
if not report:
print("No analytics recorded yet.")
return
for name, data in report.items():
totals = data["totals"]
views = totals["pageviews"]
print(f"{shown(name)}: {views} 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} {shown(path)}")
for dim in ("referrers", "languages", "devices"):
top = ", ".join(f"{shown(k)} {v}" for k, v in list(totals[dim].items())[:8])
print(f" {dim}: {top or '(none)'}")
if totals["bot_hits"]:
print(f" bot and scanner 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,
Expand All @@ -613,6 +668,7 @@ def app_meta(
build,
diagnose,
doctor,
analytics,
]


Expand Down
Loading
Loading