# Stream analytics and viewer QoE

> How the Analytics screen measures viewer quality of experience: glass-to-glass latency, rebuffer ratio, first frame, unique viewers, watch time, and per-PoP cache hit.

The Analytics screen answers one question: **what did delivery actually
feel like for viewers in this window?** Everything on the page is
scoped to your org and to the selected time range. Nothing on the page
is a lifetime total.

Four procedures back the screen. Each owns one region of the layout:

| Procedure                            | Input                                      | Feeds                                    | Refetch |
| ------------------------------------ | ------------------------------------------ | ---------------------------------------- | ------- |
| `streams.analytics.qoeKpis`          | `orgId`, `windowMinutes`                   | The five KPI cards and the percentile row | 30 s    |
| `streams.analytics.viewerSeries`     | `orgId`, `windowMinutes`, `bucketSeconds`  | The viewers-vs-latency twin chart, CSV export | 60 s |
| `streams.analytics.g2gHistogram`     | `orgId`, `windowMinutes`                   | The glass-to-glass distribution histogram | 60 s    |
| `streams.analytics.popHealth`        | `orgId`                                    | The **By edge region** table and the staleness hint | 30 s |

Note that `popHealth` takes no window. The region table is a
*current* view of the PoP mesh, while the KPI cards, chart, and
histogram are all window-scoped. Comparing a region row against a KPI
card over a 30-day window will not reconcile.

## The QoE metric set

`qoeKpis` returns a single bundle for the selected window. The console
renders each field as-is; it does no arithmetic beyond unit formatting.

| Field           | Unit           | Rendered as                            |
| --------------- | -------------- | -------------------------------------- |
| `unique_viewers`| count          | **Unique viewers**, thousands-separated |
| `watch_minutes` | viewer-minutes | **Watch time** (see below)             |
| `g2g_p50_ms`    | ms             | **P50 glass-to-glass**, target `250ms` |
| `g2g_p90_ms`    | ms             | `P90` in the percentile row            |
| `g2g_p95_ms`    | ms             | `P95` in the percentile row            |
| `g2g_p99_ms`    | ms             | `P99` in the percentile row            |
| `rebuffer_pct`  | percent        | **Rebuffer ratio**, two decimals       |
| `first_frame_ms`| ms             | **First frame**                        |

A latency field that comes back as `0` is treated as *no sample*, not
as zero milliseconds: the card and the percentile chip render `—`. This
matters when you narrow the window on a stream that was idle for most
of it. An em dash means the procedure had nothing to summarise, not
that latency was perfect.

## Glass-to-glass latency and the 250 ms target

Glass-to-glass is the end-to-end figure: capture through the
ClutchCall relay mesh to the viewer's decoded frame. It is the number
the product is sold on, so it is the only KPI on this screen that
carries an explicit target — the **P50 glass-to-glass** card is
annotated `250ms`.

Read the target against **P50 only**. The other percentiles are
deliberately unannotated: P90/P95/P99 describe your tail, which is
dominated by individual viewers' last-mile conditions rather than by
relay behaviour, and they are there for shape, not for pass/fail.

The `G2G` component in each region row applies the same colour scale to
that PoP's `g2g_p50_ms`, which is what lets you tell "the whole window
is slow" apart from "one PoP is slow".

## Rebuffer ratio and first frame

**Rebuffer ratio** is reported as a percentage with the hint `MoQ path`.
It is a property of sustained playback: it tells you how much of the
window viewers spent stalled rather than playing. The console prints it
to two decimals precisely because healthy values live below the first
decimal place, and a jump from `0.01` to `0.40` is the signal you care
about.

**First frame** is the startup counterpart, in milliseconds: time until
the viewer sees anything at all. The two move independently. A good
first frame with a rising rebuffer ratio points at sustained delivery
capacity. A clean rebuffer ratio with a slow first frame points at
session setup.

Both fall back to `—` under the same rule as the latency fields:
`first_frame_ms` of `0` renders as no sample.

## Unique viewers and watch time

`unique_viewers` is a de-duplicated count for the window, not a sum of
concurrent-viewer samples. Because de-duplication happens per window,
the counts are **not additive across windows**: the 7-day figure is not
the sum of seven 24-hour figures, and a viewer who returns on three
days counts once in the 7-day number.

Watch time arrives as `watch_minutes` in viewer-minutes. The console
switches units for readability once the value reaches 60 minutes:

- below 60 → printed in `min` as an integer count of viewer-minutes
- 60 or above → divided by 60 and printed in `h` to one decimal

The same rule is applied per row in the region table, so a region
showing `1.5 h` and the header card showing `min` are the same unit
underneath.

To reconcile viewers against latency over time, use the twin chart
rather than these cards: it plots `viewers` per bucket against
`g2g_p50` per bucket, each normalised to its own maximum. The left
series (filled, solid) is viewers; the right series (dashed) is P50
latency. Because each scale is independent, the chart shows *shape* —
whether latency stays flat as viewers climb — and not a shared
magnitude. The chart renders "No viewer/latency samples yet." when the
series has fewer than two buckets, or when every bucket is zero on both
series.

## Glass-to-glass distribution histogram

`g2gHistogram` returns a `buckets` array for the same window. The
console renders the **first 15 buckets** and places a marker on the
bucket containing the window's P50, computed as `floor(g2g_p50_ms / 40)`
clamped to the last rendered bucket. Buckets are therefore 40 ms wide,
the rendered axis spans 0–600 ms, and the final column is labelled
`600+` because it absorbs everything above the axis.

Two empty states are distinct here:

- **No buckets returned** — the histogram draws 15 zero-height columns.
  The axis is still there; there is simply no distribution.
- **No P50** (`g2g_p50_ms` is `0`) — the marker is suppressed. You can
  get a drawn distribution with no marker if the bundle and the
  histogram disagree about having samples.

## Per-PoP rows: cache hit, health and status

Each row of **By edge region** is one PoP from `popHealth`, keyed by
`code` and labelled with its `city`. Viewers are routed to the nearest
PoP, so the row set reflects where your audience actually was.

| Column        | Source field    | Notes                                                    |
| ------------- | --------------- | -------------------------------------------------------- |
| Region        | `code`, `city`  | `code` is the PoP identifier; `city` is the label.       |
| Viewers       | `viewers`       | Missing is rendered as `0`, not `—`.                     |
| Watch time    | `watch_minutes` | Same min/h switch as the KPI card.                       |
| P50 G2G       | `g2g_p50_ms`    | Colour-scaled by the shared `G2G` component.             |
| Rebuffer      | `rebuffer_pct`  | Two decimals.                                            |
| Cache hit     | `hit_ratio`     | Percent, one decimal. Also drives the Health bar width.  |
| Health        | `status`        | Bar colour + status pip.                                 |

Two behaviours are worth knowing before you read a row:

**Watch time gates the quality columns.** If `watch_minutes` is zero or
absent, both **Watch time** and **Rebuffer** render `—`. A PoP with
connected viewers but no accumulated watch time shows a viewer count
and a dash for rebuffer; that is the intended reading, because a
rebuffer percentage over zero minutes of playback is meaningless.

**Cache hit and status are independent signals rendered in one cell.**
The Health bar's *width* is the cache hit ratio; its *colour* is the
status:

| `status`      | Bar colour | Pip    |
| ------------- | ---------- | ------ |
| `active`      | ok         | ok     |
| `maintenance` | warn       | warn   |
| anything else | bad        | bad    |

So a short green bar is a healthy PoP with a poor cache hit ratio
(viewer requests mostly missing at the edge), while a long red bar is a
PoP that was caching well and is now not serving. Do not read bar
length as health.

When no PoPs have reported, the table shows "No PoP activity yet —
region rows populate once viewers connect through the relay mesh."

## When telemetry is unavailable

`popHealth` returns a `telemetry_ok` flag alongside the rows. When it
is false, two things change:

1. The telemetry health banner appears above the KPI cards.
2. The **By edge region** hint changes from `routed to nearest PoP` to
   `routed to nearest PoP · telemetry unavailable — cache/viewer figures
   may be stale`.

This is a *staleness* warning, not an error state. Rows still render
with the last values the procedure has. Treat viewer counts, watch
time, and cache hit ratios as potentially behind while the banner is
up, and do not act on a single PoP's cache hit ratio until it clears.
The window-scoped panels above the table are not covered by this flag;
`telemetry_ok` comes only from `popHealth`.

## Time windows and bucket sizes

The **Range** chip cycles through three windows in order, and every
query on the screen re-runs when it changes:

| Range | `windowMinutes` | `bucketSeconds` | Bucket length |
| ----- | --------------- | --------------- | ------------- |
| `24h` | 1440            | 600             | 10 minutes    |
| `7d`  | 10080           | 3600            | 1 hour        |
| `30d` | 43200           | 21600           | 6 hours       |

The bucket size scales with the window so the twin chart keeps a
readable number of points. Only `viewerSeries` takes `bucketSeconds`;
`qoeKpis` and `g2gHistogram` receive the window alone and return a
single summary over it.

Widening the range therefore coarsens the chart and the CSV at the same
time. A latency spike that is visible as a 10-minute bucket at `24h`
can be averaged away inside a 6-hour bucket at `30d`.

## CSV export

**Export CSV** downloads the series currently loaded in the twin chart —
the same window and the same bucket size, one row per bucket:

```csv
bucket,viewers,g2g_p50_ms
2026-01-14T09:00:00Z,1284,212
2026-01-14T09:10:00Z,1461,208
```

Details that matter when you script against the file:

- The header names the latency column `g2g_p50_ms`, while the procedure
  field is `g2g_p50`. Values are milliseconds either way.
- The filename is `stream-analytics-<range>.csv`, where `<range>` is
  `24h`, `7d`, or `30d`.
- The export is client-side over already-fetched rows. It contains no
  percentiles, no histogram, and no per-PoP data — only what the chart
  plots. For the region breakdown, read `streams.analytics.popHealth`
  directly.
- The button is disabled while the series is empty, so a failed or
  still-loading `viewerSeries` cannot produce a header-only file.

## Why the delivery path panel shows no percentage

The **Delivery path** panel states that all viewer traffic rides
Media-over-QUIC over WebTransport. It is a fixed statement of the
delivery architecture, not a measured share: there is no HLS or WebRTC
fallback path for viewers to split across, so there is no ratio to
report and the panel deliberately renders no percentage bar.

This is also why the **Rebuffer ratio** card is hinted `MoQ path` — it
describes the one path every viewer is on.

## Related

- [Telemetry](/platform/telemetry) — the metric, trace, and CDR streams behind these panels
- [Telephony Metrics](/glossary/metrics) — definitions for the percentile and quality vocabulary used here
