Broadcast analytics model: live audience, in-room participation, recurring themes
A broadcast spans three distinct populations, and Orbit measures each with a different pipeline. This page covers the three planes, why each store is shaped the way it is, and the degradation and empty-state contracts every plane shares. The endpoint-by-endpoint contract lives in the Video API reference; the room model itself lives in The video room model.The three planes
The split exists because each plane’s population has a different source of
truth. Broadcast viewers never connect to the SFU, so no server-side
participant record exists for them. In-room signals arrive at the SFU at
roughly 10 Hz, far too hot to write to the database per event. Theme data
already exists on recording rows, so the aggregate is a query, not a new
pipeline. Pick the plane your question belongs to, and you read one store
instead of joining all three.
Plane 1 — CDN audience: heartbeat + Redis TTL semantics
Broadcast viewers consume the stream over HLS or WHEP through a CDN; they are not in-room participants, and the SFU never sees them. The only authoritative audience signal is each viewer’s own player, which posts a heartbeat toPOST /video/broadcast-viewership/:name/heartbeat on a short cadence
(players heartbeat about every 10 seconds).
Each heartbeat carries a player-minted viewer_id (an opaque session id,
never personal data), a playback_state (playing, buffering, paused,
ended), and playback-health signals: rebuffer_ratio, startup_ms,
dropped_frame_ratio, bitrate_kbps. All metrics are clamped to sane
ranges at ingest so a buggy player build cannot poison the aggregate with
NaN or runaway values.
The service keeps a sliding-window presence set in Redis:
- A heartbeat refreshes the viewer’s presence timestamp with a 30-second TTL. Players heartbeat every ~10 seconds, so one missed heartbeat does not flap the viewer out of the count, but a closed tab ages out of the concurrent count within one window.
- The presence, health, and peak keys themselves expire a 60 seconds after the last heartbeat.
- The per-room health history stays readable for 24 hours after the last heartbeat so the post-broadcast report remains available for review, with the retained sample count capped per room (the oldest samples are evicted first).
All of this lives in Redis only — audience telemetry is an engagement and
troubleshooting artifact, never a billing record, and never written to the
database.
SERVICE_UNAVAILABLE, never a 500 — a viewership read must not
break a working broadcast or a working player.
Live and post-broadcast metrics
Three views read the same heartbeat stream:- The live monitor (
GET /video/broadcast-viewership/:name) — the concurrent-viewer count, the observed peak, the playing/buffering split, and aggregate health (average rebuffer ratio, startup, dropped frames, bitrate) plus a coarsesmooth/fair/degradedhealth bucket. To bound the payload on very large broadcasts, the per-viewer sample list is capped at the 100 worst-faring viewers. A stream with no active viewers returns a valid all-zeros snapshot. - The post-broadcast report (
GET /video/broadcast-viewership/:name/report) — unique viewers, peak concurrent viewers, total and average watch time, and stream-health percentiles aggregated over the retained history. A room with no retained history returns an empty-but-valid report (sample_count: 0). - The heartbeat response itself returns the live concurrent count and peak so the player can render a “watching now” badge without a second read.
Plane 2 — In-room participation: buffer-then-flush
The in-room engagement surface reports, per participant: talk time and its share of the session, screen-share duration, hand raises, and the merged dominant-speaker timeline. In-room signals are resampled — the host browser collects active-speaker, screen-share, and hand-raise signals during the meeting and flushes the finalized per-participant totals once at room end:Why the buffer-then-flush shape exists
The media server’s speaker-change webhook fires at roughly 10 times per second. Writing every change to the database would put a permanent write load on your tenant schema’s hot path for no durable gain — everyone in the room already sees who is speaking live. Buffering in the host browser costs nothing server-side, and the one flush at room end lands a complete, durable record. Live captions use the same buffer-then-flush shape for the same reason, so the post-meeting summary and transcript can be built without touching the media server’s hot path either.Reading the panel
participant_identity, display_name,
speaking_duration_seconds, speaking_share (against the whole-session
sum, guarded so an all-silent session reports 0 rather than NaN),
screen_share_duration_seconds, hand_raise_count, the participant’s own
dominant_segments, and first_spoke_at / last_spoke_at. The response
also carries the merged dominant_timeline — one sorted, whole-session
timeline of who held the floor when.
The participant list is keyset-paginated: ?limit= (default 50, capped at
200), ?offset= for a one-shot start, or ?after= with the opaque cursor
from the previous page’s page.next_cursor. A page is ranked by speaking
time descending, then identity ascending — a total order, so paging never
duplicates or skips a row. Session-scope totals (participant_count,
total_speaking_seconds, each entry’s speaking_share, and
dominant_timeline) are computed over the full roster, not the current
page, so a page-bounded view never misreports them. An undecodable cursor
falls back to the first page rather than an error.
Plane 3 — Post-recording themes
The chaptering pipeline writes short chapter titles onto each recording (the same titles the playback sidebar shows). Chapter analytics aggregates across your recordings so the dashboard can answer “which topics keep coming up” — for example, “pricing objection” or “product demo” — without a per-recording fan-out:theme, occurrence_count (total chapters with
that theme), recordings_count (distinct recordings containing it), and
avg_duration_ms (average chapter duration, computed across occurrences —
not per recording, which would dilute long recordings with many chapters
of the same theme). Rows are sorted by occurrence count, capped at 50
themes, so the dashboard can render a top-N panel without re-sorting.
- Window.
fromandtoare optional ISO timestamps; omitted, the window defaults to the last 30 days, applied against the recording’s start time.fromlater thantois rejected withINVALID_WINDOW. Keep the window as narrow as the question you are asking — the aggregate has to unwind every recording’s chapters in the range. - Theme key. A theme is the chapter title lowercased and trimmed, and occurrences are grouped by exact match. Deliberate: the pipeline emits short, normalized titles, so “Pricing Objection” and “pricing objection” group together. A fuzzy topic clusterer would belong to a separate analytics service, not a read path.
- Operator-safe unwind. The chapters field is unwound such that a malformed (non-array) cell yields an empty set of chapters rather than aborting the whole aggregate.
Soft-deleted recordings are excluded from the aggregate, so a GDPR-erased
call never leaks its theme strings into a dashboard panel.
The empty-state contract
A session or workspace with nothing to report must look empty, not zeroed:- A session with no engagement rows (predates the feature, or the host
never flushed) returns an explicit
has_data: falsewith zeroed aggregates and empty lists, so your UI renders “No engagement recorded for this session” instead of a misleading all-zeros panel. - A live broadcast with no active viewers returns a valid all-zeros
snapshot; a post-broadcast report with no retained history returns
sample_count: 0. - Chapter analytics with no matching themes returns an empty theme list.
has_data rather than “is the array empty” — the empty and
never-recorded cases are indistinguishable from zeros otherwise.
Tenant isolation and 503 degradation
All three planes scope every request to your own workspace:- The engagement and chapter planes resolve through your workspace’s
private data schema, and a workspace not yet provisioned for engagement
analytics degrades to the empty state on reads and a 503
SERVICE_UNAVAILABLEon flush writes — never a raw 500. - The broadcast-viewership plane namespaces every Redis key by workspace, so a cross-workspace audience read is structurally impossible, and a Redis outage returns 503 as described above.
- Room names and session ids in the path are resolved inside your own workspace — a caller cannot read another workspace’s audience, engagement, or themes.
404 NOT_FOUND for an unknown session, 400 for an inverted
chapter window, and 503 for the degraded cases above; none of these is a
server fault.
See also
The video room model
Room lifecycle, join tokens, recording and broadcast egress — all three
analytics planes hang off it.
Video API reference
The endpoint-by-endpoint contract for all three planes.
Video engagement analytics
The hands-on guide to the engagement panel and its pagination.
Recording lifecycle
The egress and chaptering pipeline that writes the per-recording
chapters the theme plane aggregates.
Tenant isolation
The isolation model behind every plane’s scoping.
Video webinars
Running a webinar or broadcast end to end.