Skip to main content

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 to POST /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.
A Redis outage degrades both the heartbeat POST and the two reads to a clean 503 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 coarse smooth / fair / degraded health 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.
What you get at a glance, per scope:

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:
The flush is one batched upsert keyed on (session, participant), so a re-flush or a late correction replaces values in place instead of duplicating rows.

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

Each participant entry carries 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:
Each theme row reports 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. from and to are optional ISO timestamps; omitted, the window defaults to the last 30 days, applied against the recording’s start time. from later than to is rejected with INVALID_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: false with 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.
Build against 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_UNAVAILABLE on 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.
Expect 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.