openvidu/openvidu-dashboard/openspec/changes/media-stats-availability/design.md

7.4 KiB

Design

Context

  • Telemetry stats reach the writer from two sources: per-track media stats, built from the RTP buffers of the server's media path, and the signal (TR_SG…) and data (TR_DT…) pseudo-tracks, built from WebSocket and data channel byte counters. A media engine that does not move RTP through those buffers produces only the second kind.
  • projection/stats.go adds every stat to the participant (bytes, lastStatsAt) and skips pseudo-tracks before the room delta, so the room gets lastStatsAt, bytes and quality only from media stats.
  • nodes documents are written by store.Heartbeat on every batch tick and expire 30 days after lastSeenAt. Node ids are generated per process, so a restarted node writes a new document and the old one expires on its own.
  • Rooms take nodeId from ROOM_CREATED (and from the first event of any kind while it is unknown); participant sessions take it from their first event. Entities are kept for retention.entities (90 days by default).
  • analytics.Node{ID, Region} is built in openvidu/openvidu.go; the projection options in analytics.go. A server edition adjusts analytics defaults through openviduconfig package variables before the analytics start (DefaultStatsDetail).

Goals / Non-Goals

Goals:

  • Every view that shows bytes or quality can tell "not reported" apart from "zero", for the whole entity retention.
  • The CE code and specs stay engine-agnostic: the server states a capability, not an engine name.

Non-Goals:

  • Hiding views or features: only the values and charts that depend on media statistics change.
  • Marking track or subscription documents: views reach them through their participant.

Decisions

D1. A capability flag set by the server

openviduconfig.MediaStats (default true) says whether this server's media engine reports media statistics. A server whose engine does not sets it to false before the analytics start, like DefaultStatsDetail. openvidu.Start passes it as analytics.Node.MediaStats. There is no configuration key: the value is a fact of the build and the engine, not an operator choice.

Rejected: the engine name in the data (rtcEngine: "…"). It ties the CE contract to an edition's engines and must be reinterpreted when an engine gains statistics; the capability flips by itself.

D2. Mark both the node and the entities

The heartbeat $sets mediaStats (true or false) on every tick. Rooms and participant sessions created by a node with MediaStats: false get mediaStats: false; others get no field. Views of rooms and sessions read the entity, so the mark lives as long as the entity; analytics read the nodes, since rollups have no per-engine dimension.

Rejected: nodes only, with the dashboard joining nodeId. Node documents expire 30 days after their last heartbeat and every restart creates a new node id, so rooms between 30 and 90 days old would fall back to "0 B". Rejected: entities only. A range or scope of the Analytics page has no entity to read the mark from.

D3. Only false is stored on entities

Absent means "reports media statistics", which is also what every document written before the change says. No backfill, no extra bytes on the common case, and the API omits the property unless it is false.

D4. The mark follows nodeId

The mark is computed where nodeId is: ROOM_CREATED sets nodeId and sets mediaStats: false or removes it ($$REMOVE); the touch of other events sets mediaStats only when the document has no nodeId yet, in the same stage, so the first writer of the room decides until ROOM_CREATED arrives. Participants follow their own nodeId rule. The legacy migration replays with MediaStats: true: the engine of legacy nodes is unknown.

D5. Pseudo-track stats advance the room's lastStatsAt

A pseudo-track stat creates the room delta with statsAt and zero bytes and quality, exactly as a media stat of zero bytes would. Every connected participant flushes signal stats every 30 s, so the room's lastStatsAt becomes a liveness signal under any engine: the room stale flag, the stale hints of the overview and the reconciler's empty-listing guard (Untrusted live listing) cover these rooms. With media stats nothing changes, since the room already gets the same flush time from them.

D6. Analytics coverage from heartbeat spans, on the summary only

The summary reads nodes documents whose span overlaps the range (firstSeenAt <= to, lastSeenAt >= from, plus _id for a node scope) with a mediaStats projection and folds them into none, partial or nothing. The page applies the summary's coverage to the Data up / down tile and the Bandwidth section, and exposes it to the cards of the page's extension slots as AnalyticsPageContext.mediaStats (a signal of 'partial' | 'none' | undefined), so the series and breakdown responses do not change.

Rejected: a coverage field on every series response (the page already holds the summary of the same range and scope). Rejected: inferring coverage from rollup bytes at 0 (a quiet range reads the same).

D7. Values stay as stored

The rooms list keeps bytes next to mediaStats: false, and the summary keeps bytesUp and bytesDown; only the UI replaces them. API clients see the same shapes plus one optional property.

D8. A generator option for nodes without media statistics

--nodes-without-media-stats <n> (default 0) marks the last n generated nodes: their node documents carry mediaStats: false, their rooms and sessions are stamped and get no media track_stats, room bytes or quality, and their rollup contributions lose bytes and quality. The other nodes get mediaStats: true. The keep-alive preserves the flag.

Data model

  • nodes.mediaStats: bool, written on every heartbeat. rooms.mediaStats and participants.mediaStats: bool, only false, set by the projections. Contract schemas of the three collections gain the optional property; the dashboard mirror and its typings follow.
  • No index, retention or schema version change (the v2 schema has not shipped). No mapping from legacy events (D4).

API

  • RoomListItem.mediaStats?: false (GET /api/v1/rooms).
  • AnalyticsSummary.mediaStats?: "partial" | "none" (GET /api/v1/analytics/summary).
  • The coverage query runs on nodes, backed by the lastSeenAt TTL index; the collection holds one document per node process of the last 30 days.

Performance budget

One extra query per summary on a collection of tens of documents: under 5 ms, verified by an integration test with an explain() plan (IXSCAN on lastSeenAt) next to the existing analytics plan tests. The summary p95 budget (150 ms at scenario L) is unchanged. The pseudo-track room delta adds at most one room update per room and batch, and only for rooms whose flush carries no media stat; the writer benchmark of the batch path checks it stays within its budget.

Risks / Trade-offs

  • [A room touched first by an egress or ingress event of another node] → it takes that node's mark until its ROOM_CREATED is applied, which then sets the host's mark (D4).
  • [Coverage misses nodes whose documents expired] → ranges older than 30 days may read as fully reported; the entity marks keep rooms and sessions right.
  • [A pseudo-track-only room delta on a room not written yet] → the same upsert as a media stat delta today.

Migration Plan

Ships with the v2 writer and dashboard. Rollback: dashboards ignore the property and writers without it never write it.