---
title: "Streaming API v2: Playback reports"
description: "Report stalls, crashes and blocked hosts so the provider can tell network, device and server problems apart."
---

## 7.7 Playback reports: stalls, crashes and blocks

The app tells the panel why playback stutters, so the provider can answer "is
it your connection, your device, or us?" without asking the user for
anything. Reports are small, rare and never about healthy playback.

Send reports only when the user has "Send diagnostics" on (on by default).
Node reports need `reports: true` on the node in `playback-routes` (7.1);
`diagnostics` needs the capability `playback_reports` in `identity`. Without
them, send nothing.

**What to report**

- `stall`: one record per stall **after `playing`**, at most one every 30 s
  per device. Not in the 3 s after a zap or a seek by the user, and not while
  an on-demand channel starts. The player's own seeks (jump to live,
  adjustments) do not open that grace.
- `crash`: one record in the next batch when the app was closed while
  playing. Set a persistent mark on `playing`; clear it on a clean exit and
  when the app goes to the background (TV systems kill background apps).
- `block`: one record per confirmed block of a streaming host: the device has
  internet (the connectivity check answers) and still cannot reach the host,
  twice, 30 s or more apart, on the same network. A captive portal is not a
  block.
- Nothing else: no healthy sessions, no channels that are down, no start-up
  times.

**Where to send them**

| Records | Where |
|---|---|
| `stall`, `crash` | The node where they happened: `POST <node base>/app-diag` |
| `block` | The panel: `POST /api/v2/diagnostics`, through the gateway |

Keep one queue per node. Send one batch per node at most every minute, and
drop records older than 24 h (a node that cannot be reached for 24 h loses
them). Never send stalls to `diagnostics` or blocks to a node.

### `POST /app-diag` on the node

`<node base>` is `scheme://host:port` + `prefix` of that node, built as in
7.1 (the host the player used, or the next one in `hosts`).

```http
POST https://lb1.example.com:443/app-diag
Content-Type: text/plain;charset=UTF-8

{"st":"<session_token>","r":[{"t":"stall","seq":812,"ago":4200,"ch":4512,"eng":"hls","h":1,"v":"net","busy":0.97,"buf":0.2,"slow":0,"x5":0,"rc":200,"err":"net.timeout","bwm":"1840000:2.1","dec":1480,"drop":3,"dropq":1,"long":0,"fix":"reload","code":"7KQ2-M9XA-T4PE","net":"wifi","rssi":-71}]}
```

- The body is JSON, **16 KB at most** (the session token alone takes 1-3 KB), with the current session token in `st`
  and up to 30 records in `r` (the rest are ignored).
- Send it as `text/plain` with no `Authorization` or custom headers: it is a
  CORS simple request, with no preflight (nodes do not answer preflights for
  `POST`). A web app may use `fetch(url, { method: "POST", mode: "no-cors",
  body })`: the answer is then opaque, so a resolved promise means
  delivered and a network error means keep the batch.
- The node sets the time, its own id and the device's IP. Never send them.

| Status | Meaning | What the app does |
|---|---|---|
| `204` | Accepted. Invalid records are dropped silently | Drop the batch |
| `400` | Not a JSON object with `st` and `r` | Drop the batch |
| `404` | The session token is not valid here (expired, or another panel) | `refresh-session`, then send again once; drop it if it fails again |
| `413` | Body over 16 KB | Send fewer records |
| `429` | One batch per device per minute (`Retry-After`) | Keep the records, send them after `Retry-After` |
| `503` | The node cannot store them now (`Retry-After`) | Keep the records, send them later |
| network error | | Keep the records |

### `POST diagnostics` on the API

```http
POST /api/v2/diagnostics
Authorization: Bearer <session_token>
Content-Type: application/json

{"r":[{"t":"block","seq":813,"ago":61000,"host":"lb1.example.com","type":"dns","times":2,"node":3,"code":"7KQ2M9XAT4PE","net":"wifi"}]}
```

```json
{ "accepted": 1 }
```

Up to 30 records, 16 KB at most, one batch per device per minute
(`429 rate_limited` with `Retry-After` otherwise: keep the records and send
them later). Errors are the JSON errors of section 4.

### Record fields

All records:

| Field | Type | Meaning |
|---|---|---|
| `t` | string | `stall`, `crash` or `block` |
| `seq` | integer | 1 to 2147483647. A counter per device, shared by every record (node and API), kept across restarts and never reused: the panel drops a repeated `(device, seq)`, so resending after a lost answer is safe |
| `ago` | integer | Milliseconds since the event, up to 86400000. The panel stores its own clock minus `ago`; the device's clock is never used |
| `code` | string | Support code of the playback session: 12 Crockford base32 characters (`0-9`, `A-Z` without `I`, `L`, `O`, `U`), dashes allowed. Show the same code in the stats overlay, in "No connection" and in errors: the provider finds the reports by it |
| `net` | string | `wifi`, `eth`, `cell` or `other` |

`stall` and `crash`:

| Field | Type | Meaning |
|---|---|---|
| `ch` | integer | Content id |
| `ct` | string | `channel` (default), `vod` (movie id) or `saga` (episode id) |
| `eng` | string | Player: `hls` (hls.js), `native` (the TV's own player) or `exo` (ExoPlayer) |
| `h` | integer | Index in the node's `hosts` of the host in use |
| `fix` | string | What fixed it: `none`, `reload` (playlist reloaded), `recreate` (player recreated), `host` (another host), `node` (another node) |

`stall` only (the inputs of the local verdict, so the panel can recalibrate
without a new app):

| Field | Type | Meaning |
|---|---|---|
| `v` | string | The local verdict: `net` (the connection is not enough), `server` (5xx, timeouts or slow first bytes), `device` (dropped frames), `content` (a splice or discontinuity), `none` (no data) |
| `busy` | number | Share of the last 30 s spent downloading, 0 to 1 |
| `buf` | number | Buffered seconds at the stall |
| `slow` | integer | Fragments (not the last of the playlist) with a first byte of 2 s or more in the window |
| `x5` | integer | 5xx answers or timeouts in the last 10 s |
| `rc` | integer | HTTP status of the last failed request, 0 if none |
| `err` | string | Last player error: hls.js `details` or the ExoPlayer error mapped to `net.*` / `fmt.*` |
| `bwm` | string | `"<bytes>:<seconds>"` downloaded in the window |
| `dec`, `drop` | integer | Decoded and dropped frames in the window |
| `dropq` | integer | `1` if the frame counters move with playback; `0` if they do not (that device never gets the `device` verdict) |
| `long` | integer | `1` if the stall lasted 30 s or more |
| `rssi` | integer | Wi-Fi signal in dBm, -150 to 0 |

`block` only:

| Field | Type | Meaning |
|---|---|---|
| `host` | string | The host that could not be reached (a name or an IP). Required |
| `type` | string | `dns` (the name fails and the node's IP works), `ip` (both fail and the panel's API works), `dns_api` (the API's own domain; report it once the app got through another way). Required |
| `times` | integer | Confirmations behind the record |
| `node` | integer | Key of the node in `playback-routes`; leave it out for the API's domain |

Strings are ASCII; text fields are lowercased and anything else is dropped
(the field, not the record). Unknown fields are ignored. Records with a bad
`t`, `seq` or `ago` (or a `block` without a valid `host` and `type`) are
dropped.

Where the platform lets you, send a User-Agent of the form
`<YourApp>/<version> (<model>)` (for example `MyPlayer/1.4.2 (Fire TV Stick 4K)`)
on playback and report requests, so the requests of your app are easy to tell
apart. The panel does not count watch time per app from it: stalls are turned
into a rate with the watch time of the devices that send playback reports.
The App/Device column of Live Connections comes from a fixed list of known
players, read from the User-Agent of the stream request; an app that is not
on that list shows as "Other".
