Add client_logs collection for background device diagnostics (feedback 2 §4)
The family runs Amber on hardware I can't debug directly, so the app batches a low-volume diagnostic stream here: uncaught errors, player error codes, per-session playback summaries, and the audio-delay a user dials in to fix desync (the "a track falls behind" signal). - Migration 1788000000_client_logs.js: owner-create-only, superuser-read-only (list/view/update/delete = null); fields kind/event/message/meta/appVersion/ platform/device/ts + created. - client_logs.pb.js: nightly cron trims rows older than 14 days. - docs/telemetry-contract.md: event catalogue, how I query it, and the privacy/redaction guarantees (host-only, never a token or full URL). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
0e33a83d90
commit
418f566603
3 changed files with 134 additions and 0 deletions
55
docs/telemetry-contract.md
Normal file
55
docs/telemetry-contract.md
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
# Client diagnostics (`client_logs`)
|
||||||
|
|
||||||
|
Background diagnostics from prod devices (feedback 2 §4). The family runs Amber
|
||||||
|
on hardware that can't be debugged directly, so the app batches a low-volume
|
||||||
|
stream of events to the `client_logs` collection. Designed to be invisible on
|
||||||
|
the device (in-memory ring buffer, slow best-effort flush, never on a playback
|
||||||
|
thread) and safe (owner-create-only, superuser-read-only, redacted at source).
|
||||||
|
|
||||||
|
## Collection `client_logs`
|
||||||
|
Migration `1788000000_client_logs.js`. Fields: `user` (relation), `kind`,
|
||||||
|
`event`, `message`, `meta` (json), `appVersion`, `platform`, `device`, `ts`
|
||||||
|
(device clock), `created` (server clock).
|
||||||
|
|
||||||
|
Rules: `create` = owner (`user = @request.auth.id`); list/view/update/delete =
|
||||||
|
**null** (superusers only). Retention: `client_logs.pb.js` cron trims rows
|
||||||
|
older than 14 days nightly.
|
||||||
|
|
||||||
|
## Event kinds
|
||||||
|
| kind | event | meta |
|
||||||
|
|-----------|---------------------|--------------------------------------------------|
|
||||||
|
| `error` | `uncaught` | `{library}` — Flutter framework errors |
|
||||||
|
| `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` | `{host, durationS, watchedS, stalls, audioDelayMs, anime}` on player close |
|
||||||
|
|
||||||
|
`host` is only the stream's `scheme://host` — **never** a full signed URL or an
|
||||||
|
addon token (redacted client-side in `TelemetryService.redactUrl` before write).
|
||||||
|
|
||||||
|
## How Claude queries it
|
||||||
|
Superuser token (same as releases publishing), then filter/sort the collection:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOKEN=$(...auth as superuser...)
|
||||||
|
# recent player errors + desync signals across the fleet, newest first
|
||||||
|
curl -s "$PB/api/collections/client_logs/records?perPage=100&sort=-created&filter=$(python3 -c '
|
||||||
|
import urllib.parse;print(urllib.parse.quote("kind='"'"'player'"'"'"))')" \
|
||||||
|
-H "Authorization: $TOKEN" | python3 -m json.tool
|
||||||
|
```
|
||||||
|
Useful filters: `kind='player'` (errors + desync), `event='av_delay_applied'`
|
||||||
|
(who's fighting sync and by how much — the reported "track falls behind" bug),
|
||||||
|
`kind='session' && meta.stalls > 3` (hitchy playback). Group by `device` /
|
||||||
|
`platform` / `appVersion` to see which hardware struggles.
|
||||||
|
|
||||||
|
## What it deliberately does NOT capture (yet)
|
||||||
|
True per-track A/V PTS drift needs native instrumentation on both players
|
||||||
|
(ExoPlayer exposes one clock; mpv would need `audio-pts`/`video-pts` sampling).
|
||||||
|
v1 uses **proxies**: buffering-stall counts and the manual audio-delay the user
|
||||||
|
applies to fix desync — which directly answers "is a track falling behind, on
|
||||||
|
which sources/devices, and by how much". Deeper PTS sampling is a follow-up if
|
||||||
|
the proxies point somewhere specific.
|
||||||
|
|
||||||
|
## Privacy / kill switch
|
||||||
|
On by default; a per-device Settings toggle ("Diagnostika") disables it and
|
||||||
|
drops the pending buffer. Only sends while signed in. No addon credential or
|
||||||
|
full stream URL ever leaves the device.
|
||||||
14
pb_hooks/client_logs.pb.js
Normal file
14
pb_hooks/client_logs.pb.js
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
|
||||||
|
// Retention for the client_logs diagnostics (feedback 2 §4). These are for
|
||||||
|
// live debugging, not an archive — trim anything older than 14 days daily so
|
||||||
|
// the collection can't grow unbounded. Runs in PocketBase's cron scheduler.
|
||||||
|
cronAdd("trimClientLogs", "0 4 * * *", () => {
|
||||||
|
try {
|
||||||
|
const cutoff = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000)
|
||||||
|
.toISOString().replace("T", " ").slice(0, 19) + ".000Z"
|
||||||
|
const stale = $app.findRecordsByFilter(
|
||||||
|
"client_logs", "created < {:c}", "created", 500, 0, { c: cutoff })
|
||||||
|
for (const r of stale) $app.delete(r)
|
||||||
|
} catch (_) { /* nothing to trim / collection absent */ }
|
||||||
|
})
|
||||||
65
pb_migrations/1788000000_client_logs.js
Normal file
65
pb_migrations/1788000000_client_logs.js
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
|
||||||
|
// Background diagnostics from prod devices (feedback 2 §4).
|
||||||
|
//
|
||||||
|
// The family runs Amber on hardware I can't attach a debugger to, so the app
|
||||||
|
// batches a small stream of diagnostic events here: uncaught errors, player
|
||||||
|
// error codes, per-session playback summaries (host/resolution/stalls), and —
|
||||||
|
// the signal for the "a track falls behind" reports — the audio/subtitle delay
|
||||||
|
// the user dialed in to fix desync, plus stall counts. It's deliberately
|
||||||
|
// low-volume and best-effort so it never slows the device (see TelemetryService).
|
||||||
|
//
|
||||||
|
// Privacy/security: rows are owned by the authenticated user (create-only for
|
||||||
|
// them, no read/update/delete), and the client redacts addon tokens / stream
|
||||||
|
// URLs to their host before anything is written — never a credential or a full
|
||||||
|
// signed URL. Reading is superusers-only (that's how I query it).
|
||||||
|
|
||||||
|
migrate((app) => {
|
||||||
|
const users = app.findCollectionByNameOrId("users")
|
||||||
|
|
||||||
|
const logs = new Collection({
|
||||||
|
type: "base",
|
||||||
|
name: "client_logs",
|
||||||
|
// Create-only for the owner; nobody but a superuser can read them back.
|
||||||
|
listRule: null,
|
||||||
|
viewRule: null,
|
||||||
|
createRule: "@request.auth.id != '' && user = @request.auth.id",
|
||||||
|
updateRule: null,
|
||||||
|
deleteRule: null,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: "relation",
|
||||||
|
name: "user",
|
||||||
|
required: true,
|
||||||
|
collectionId: users.id,
|
||||||
|
cascadeDelete: true,
|
||||||
|
maxSelect: 1,
|
||||||
|
},
|
||||||
|
// "error" | "player" | "session" | "info" — a coarse bucket for filtering.
|
||||||
|
{ type: "text", name: "kind", required: true, max: 20 },
|
||||||
|
// Short event slug, e.g. "uncaught", "exo_error", "session_summary",
|
||||||
|
// "av_delay_applied".
|
||||||
|
{ type: "text", name: "event", required: true, max: 80 },
|
||||||
|
// Human-ish message / error text (already redacted client-side).
|
||||||
|
{ type: "text", name: "message", max: 4000 },
|
||||||
|
// Structured payload (redacted): host, resolution, stalls, delayMs, etc.
|
||||||
|
{ type: "json", name: "meta", maxSize: 20000 },
|
||||||
|
// Device/app context so I can tell platforms apart without a debugger.
|
||||||
|
{ type: "text", name: "appVersion", max: 40 },
|
||||||
|
{ type: "text", name: "platform", max: 40 },
|
||||||
|
{ type: "text", name: "device", max: 120 },
|
||||||
|
// Client-set event time (device clock); `created` is the server clock.
|
||||||
|
{ type: "date", name: "ts" },
|
||||||
|
{ type: "autodate", name: "created", onCreate: true },
|
||||||
|
],
|
||||||
|
indexes: [
|
||||||
|
"CREATE INDEX `idx_client_logs_created` ON `client_logs` (`created`)",
|
||||||
|
"CREATE INDEX `idx_client_logs_kind` ON `client_logs` (`kind`)",
|
||||||
|
],
|
||||||
|
})
|
||||||
|
app.save(logs)
|
||||||
|
}, (app) => {
|
||||||
|
try {
|
||||||
|
app.delete(app.findCollectionByNameOrId("client_logs"))
|
||||||
|
} catch (_) { /* already gone */ }
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue