Privacy-preserving analytics
Aliran ships analytics that are aggregate-only, server-side only. There is no per-user tracking. This is not a policy you have to trust — it is an architectural fact you can verify in the code and on the wire.
Why the operator cannot see who watches what
In a conventional OTT stack, the CDN logs every request. "Who watched what, when, from where" exists by default, so privacy becomes a promise about what the operator does with those logs. Aliran's data path makes that record impossible to produce:
- Viewers replicate peer-to-peer. A client fetches stream blocks from whichever peers hold them — other viewers, a repeater, or the origin — and never tells the panel what it is watching. There is no playback request to log, because there is no media server to request from.
- The panel sees logins, not viewing. It serves the OPRF login and session RPC, and replicates the (public) catalog. It learns that an account signed in — never what that account plays.
- The broadcaster sees anonymous swarm links. A connection is an ephemeral Noise keypair, not an account. The broadcaster cannot map a peer to a user: the panel knows accounts but not peers, the broadcaster knows peers but not accounts, and nothing joins the two.
Analytics is therefore defined as: aggregate what the operator's own nodes already observe as a side effect of serving. Nothing new is collected from clients, no wire message was added, and the client privacy story is unchanged.
The invariant
Identity-carrying inputs (usernames, hex keys, IPs, device ids) may exist only in ephemeral in-memory structures inside the current hour or day. They are reduced to counts at rollup, and the raw value is never written to any analytics file,
/api/analyticsresponse, or/metricsline.
This is not just a design note. npm run test:analytics (in the required CI
lane) drives distinctive needle usernames, keys, device ids, and IPs through
every path, then scans every rollup file, API response, and metrics body for
them, asserting zero hits. The one place an identity is touched at all is the
panel's unique-viewers count: usernames of verified sessions go into an
in-memory Set, and its .size becomes the day's uniqueViewers count. The set
dies at day rollover (or process exit) and is never serialized. Failed login
attempts are counted with no argument at all — an attacker-controlled
username from a failed attempt never enters any structure.
What is collected
All series are UTC hour buckets, rolled into per-day JSON files under each
service's DATA_DIR/analytics/ (atomic tmp+rename writes; nothing goes in the
replicated catalog, which is public and append-only).
Panel (panel/src/analytics.js):
| Series | Source | Notes |
|---|---|---|
| Logins ok / failed per hour | The session RPC — the panel's only honest ok/failed signal (the OPRF stage is oblivious: a wrong password fails on the client) | Counts only |
| Sessions issued per hour | Same site as the ok count | |
| Unique viewers per day | In-memory day-scoped Set → count |
Verified sessions only; undercounts across a restart (by design — better than persisting names) |
| Apps online (min/mean/max per hour) | Panel swarm connection count, sampled every 5 min | Every open app holds the catalog connection. Approximate: also counts non-viewer peers (repeaters, broadcaster links) |
| Catalog composition (live/redirect/vod) | A slow (30 min) catalog scan | A snapshot, not an event count |
Broadcaster (broadcaster/src/analytics.js):
| Series | Source | Notes |
|---|---|---|
| Peer links per channel (min/mean/max per hour) | Each channel's swarm connection count, sampled every 5 min | A lower bound (≥) on audience — see below |
| Egress bytes per channel per hour | UDX per-connection byte counters: closed connections accumulate on close, live ones are added at sample time (so closed connections' bytes don't vanish) |
Bytes, not who received them |
| Respawns per channel per hour | The existing watchdog restart counter (delta per tick, reset-aware) | |
| Incidents per hour | The existing correlated incident ring | Count of ring events |
Repeater: /metrics only — aliran_repeater_served_bytes_total{stream_id,core}
beside the existing held_blocks/core_peers. No rollup files: the repeater
stays a zero-state keyless cache, and its opt-in STATUS_PORT
(default off, zero listening sockets) is unchanged.
Reseller: out of scope — its credit ledger already is the business analytics.
What is NOT collected
- No per-user watch history — impossible, see above.
- No IP addresses, no geolocation, no device fingerprints.
- No client-side beacon, telemetry, or SDK calls of any kind — zero client/SDK changes, zero new wire messages.
- No per-peer records: peer keys are never stored, and byte counts are summed before anything touches disk.
- Nothing in the replicated Hyperbee (it is public and append-only — an analytics write there would be broadcast to every client, forever).
The honesty rule: peer counts are a lower bound
Origin-side peer counts understate the audience, because viewers serve each other: a channel with 40 origin connections may have hundreds of watchers downstream of those peers (a repeater measurably absorbs about half of origin egress). Every Aliran surface therefore labels peer-derived figures as "≥ N" and never presents them as a viewer count. Read these numbers the same way. (Related: the broadcaster's "peer links" tile also counts a single browsing viewer several times while zap-prefetch holds neighbouring channels — links, not people, in both directions.)
Surfaces
| Surface | Where |
|---|---|
| Panel Analytics tab | Admin dashboard — today's counts, 48 h login/apps-online charts (inline SVG, no dependencies), daily totals table |
GET /api/analytics?days=N (panel) |
Admin Bearer — day rollups + the in-progress hour (reference) |
GET /api/analytics?days=N (broadcaster) |
Control Bearer — per-channel day rollups (reference) |
| Broadcaster dashboard 24 h column | Peak peers (≥) + egress per channel on the channels table |
/metrics (panel) |
aliran_panel_logins_{ok,failed}_total, aliran_panel_sessions_issued_total, aliran_panel_catalog_channels{class} |
/metrics (broadcaster) |
aliran_broadcaster_channel_peers{stream_id}, aliran_broadcaster_channel_egress_bytes_total{stream_id} |
/metrics (repeater, opt-in) |
aliran_repeater_served_bytes_total{stream_id,core} |
Retention, durability, and the off switch
- One knob:
ANALYTICS_RETENTION_DAYS(panel and broadcaster; default90). Rollup files older than this are pruned at boot and at every day rollover. ANALYTICS_RETENTION_DAYS=0disables collection entirely — no counters, no timers, and no files or directories are ever created./api/analyticsanswers{enabled:false}, and the per-channel metrics extension disappears. The pre-existing health gauges stay.- Crash semantics: the in-progress hour lives only in memory and is lost on any exit (crash or clean shutdown). Completed hours are on disk. This is a deliberate trade — no partial-hour merge logic, at the cost of at most one hour of counts.
- Rollup files are plain JSON (
DATA_DIR/analytics/YYYY-MM-DD.json, UTC days). Inspect them yourself — that inspection is the point.
What about real audience measurement?
An accurate concurrent-viewer count would require clients to announce themselves (a presence beacon). That would change the client privacy story, so it is deliberately not part of this feature — it is parked as a separate, explicit decision an operator community can make with its eyes open. Until then: honest lower bounds, clearly labeled.