diff --git a/docs/telemetry-contract.md b/docs/telemetry-contract.md index 3c1b21d..ff285d8 100644 --- a/docs/telemetry-contract.md +++ b/docs/telemetry-contract.md @@ -22,6 +22,7 @@ older than 14 days nightly. | `player` | `exo_error` | `{host, anime}` — native player error | | `player` | `av_delay_applied` | `{audioMs, host}` — user dialed in an audio offset (the "a track falls behind" signal) | | `session` | `session_summary` | one row per playback — see below. **Written only by `SessionFeedbackService._write`.** | +| `error` | `session_hold_failed` | `{op, sessionId}` — an open session could not be held on disk; see [below](#a-session-the-app-never-saw-end-ended-unseen) | | `qa` | `qa_session` | `{sid, lines[], truncated?}` — the QA decision log for one playback (issue #92), off by default | | `source` | `source_select` | one row per source sheet — timings and counts, see below | | `source` | `source_pick` | **what was chosen and what it beat** — see below | @@ -64,8 +65,10 @@ The pass is arithmetic over a few dozen candidates, once per pick. `{sessionId, title, probeKey, host, durationS, watchedS, positionS, stalls, droppedFrames, renderedFrames, maxConsecutiveDropped, problemAtS[], promptOutcome, -rating?}` plus the player's provenance fields (`forcedAudio`, `sideloadedSubs`, -`resumed`, `seeks`, `seekStormMax`, codec/height/bitrate). +rating?, ended, lastSeenAt?, playedOn?}` plus the player's provenance fields +(`forcedAudio`, `sideloadedSubs`, `resumed`, `seeks`, `seekStormMax`, +codec/height/bitrate). `ended` and the two after it are explained in +[A session the app never saw end](#a-session-the-app-never-saw-end-ended-unseen). - **`watchedS` is elapsed watch-clock time. `positionS` is where playback got to.** They differ whenever someone seeks, rewatches, or stares at a spinner — a session @@ -81,6 +84,46 @@ this collection computed before that date counts Android sessions twice**, and rows older than the fix still carry the duplicate. `sessionId:isset = true` is the filter that excludes them. +### A session the app never saw end (`ended: unseen`) + +Added 2026-09-13 (amber-app, first in the build after 1.3.0+38). Until then the +summary and the amber-api health row were written only when the player closed, and +a box whose television is switched off with the film on screen never closes it. +Measured on the kids' box `Xiaomi MIBOX4 · ff3bcf`: **13 of 13 films in 14 days +left no summary and no health row.** At the end of every one the 5 s position +save ran at full rate up to the last 45 s `api_usage` tick and then stopped, and +no row from the hide/pause flush arrived, then or at any later launch (0 of 3572 +`api_usage` rows were delivered late). That box gives the app no callback, so a +write on `AppLifecycleState.paused` would have recovered none of them. + +Now both players hold the open session on disk every 10 s +(`/open_sessions/.json`). A normal close writes as before +and deletes the copy once the summary is in the telemetry spool. The next launch +writes whatever is still held, through the same single writer, with: + +| field | meaning | +|---|---| +| `ended` | `closed`: the player closed and wrote it. `unseen`: a later launch wrote it from the held copy. Absent on older builds. | +| `lastSeenAt` | `unseen` only. When the session was last held, device clock, UTC. The row's own `ts` and `created` are the later launch, often the next day. | +| `playedOn` | `unseen` only, and only when it differs: the `appVersion gitSha` that played it, because a row is stamped with the build that sends it. | + +Reading an `unseen` row: + +- The counters stop at `lastSeenAt`, at most 10 s before the end, and + `promptOutcome` is `notShown`. +- It does not say how the session ended (TV switched off, power cut, crash, killed + in the background), only that the app did not see it. +- It arrives when the box is next started. A complaint made before that start has + no row yet. +- Its amber-api health row is sent at the same moment, once. A session that was + held and then closed normally sends one health row, at the close. +- A session killed within its first 10 s was never held and still leaves nothing. + +`kind: error`, `event: session_hold_failed`, `meta: {op, sessionId}` records a +hold that could not be written (`put`), let go of (`release`), read back +(`unreadable`, the copy is deleted) or listed (`recover`). One row per session per +op, so a full disk is one row, not one per tick. + ## How Claude queries it Superuser token (same as releases publishing), then filter/sort the collection: