Telemetry contract: a session the app never saw end

Documents the amber-app change that records a film whose TV is switched off
with it on screen. session_summary gains ended (closed | unseen), lastSeenAt
and playedOn; a session held on disk and never closed is written once at the
next launch, with its one health row; a new error row, session_hold_failed,
records a hold that could not be written, released or read.

Includes the measurement that ruled out a lifecycle hook: on the kids' box
ff3bcf, 13 of 13 films in 14 days left no summary, with no callback at their
end and 0 of 3572 api_usage rows delivered late.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-09-13 21:25:53 +02:00
parent db50af0c69
commit 2e5ae66662

View file

@ -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
(`<appSupport>/open_sessions/<sessionId>.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: