The counters had an endpoint and no way to read them without jq. Adds a pure formatter plus a CLI that prints the app, queue, and model boundaries. The formatter is a function of the snapshot and the three frozen vocabularies, so a caller-supplied failure label cannot reach the terminal: unknown classes are simply not printed. Refusal paths are explicit — 404 means the route is off because no token is configured, 403 means the token was rejected — and the token is never echoed in the report or an error line. RED: tests/model-metrics-report-format.test.js failed with ERR_MODULE_NOT_FOUND before src/model-metrics-report.js existed; the refusal-path test failed with a generic 'answered 404' before the 404 branch was written. Tests: npm test 298/298, npm run test:metrics-report 8/8, syntax and diff gates clean, real-Chromium ui acceptance green with the privacy disclosure visible.
5.6 KiB
Privacy-safe model health counters
Issue #19. This is the operator surface for "is the worker healthy?", and it is deliberately the smallest one that answers the question. The journal stays the only place a person's bowel data lives.
Endpoint
GET /api/metrics
Authorization: Bearer $TIMMY_METRICS_TOKEN
TIMMY_METRICS_TOKENunset → 404. A default deployment has no metrics route at all; nothing is exposed by forgetting to configure something.- Token set, header missing or wrong → 403, with a body that contains no counters.
- Token set and matched → 200 with the snapshot below.
The snapshot is read from process memory. It survives no restart, is written to no log, and is sent to no third party.
What is in the snapshot
{
"schema": 1,
"generatedAt": "2026-11-01T10:00:00.000Z",
"app": { "uptimeMs": 0, "analyzeRequests": 0, "refusedBeforeModel": 0 },
"queue": { "inFlight": 0, "limit": 4, "atCapacity": false, "accepted": 0 },
"model": {
"outcomes": { "suggestion": 0, "abstained": 0, "failure": 0 },
"failureClasses": { "ingress_refused": 0, "rate_limited": 0, "disabled": 0,
"provider_unreachable": 0, "provider_rejected": 0,
"schema_invalid": 0, "timeout": 0, "unexpected": 0 },
"abstentionRate": null,
"readiness": { "phase": "checking", "ready": false, "coldLoadMs": null },
"latency": { "count": 0, "meanMs": null, "maxMs": 0,
"bucketsMs": { "1000": 0, "2000": 0, "5000": 0, "15000": 0, "60000": 0 } }
}
}
The three blocks are the three boundaries, and each counts only what it owns:
| Block | Owns | Does not own |
|---|---|---|
app |
Every POST /api/analyze the process saw, and the ones refused before any model work (bad bytes, per-device budget, analysis disabled). |
Queue occupancy or model answers. |
queue |
Slots handed out and released, and the configured concurrency ceiling. | Whether the request succeeded. |
model |
Answers (suggestion / abstained), classified failures, readiness phase, and model-work latency. |
Anything decided before a slot was taken. |
abstentionRate is abstained / (suggestion + abstained); it is null until the model has answered
at least once. A failed request is not an abstention — an outage must not look like a cautious
model.
latency.bucketsMs is cumulative: "5000" counts every sample at or below 5 s. coldLoadMs is the
interval from process start to the first observation in which the pinned model was resident; a worker
that flaps back to offline keeps the original figure.
readiness.phase uses the same vocabulary the browser uses for the AI affordance (checking,
disabled, offline, loading, ready), and the value is produced by the same
visionReadiness() function the app renders, so a metric and a screen cannot disagree.
What can never appear in it
The only strings this module stores are members of three frozen lists — outcomes, failure classes, and readiness phases — plus a fixed latency-bucket edge set. Anything else is bucketed, not copied:
- an unrecognised outcome becomes
failure; - an unrecognised failure class becomes
unexpected; - an unrecognised readiness phase becomes
checking.
Therefore no note text, no image bytes, no image hash or fingerprint, no subject key, no caller
address, and no session or cookie value can reach a label. tests/model-metrics.test.js asserts this
by feeding hostile strings into every label position, and
tests/metrics-endpoint.acceptance.test.js asserts it through the shipping server.
Reading it
curl -sS -H "Authorization: Bearer $TIMMY_METRICS_TOKEN" http://127.0.0.1:4173/api/metrics | jq .
For a quick look that does not need jq, the report renders the same snapshot as three labelled blocks:
TIMMY_METRICS_TOKEN=... node scripts/model_metrics_report.mjs --url http://127.0.0.1:4173
Exit codes are the point of the command as much as the numbers: 0 means a snapshot was rendered,
1 means the server answered non-200 (404 says the route is disabled because no token is configured,
403 says the supplied token was rejected), 2 means the command itself was called wrongly or the
server could not be reached. The token is read from the environment or --token and is never echoed
in the report or in an error line.
What the numbers mean to an operator:
| Signal | Reading |
|---|---|
readiness.phase: offline |
The worker is not answering. Manual logging is unaffected; do not restart the app for this. |
readiness.phase: loading |
The worker answered but the pinned model is not resident. AI is withheld rather than offered and failed. |
queue.atCapacity: true |
Every model slot is in use. Watch latency.maxMs; a growing figure with no completions is a stuck inference, not load. |
failureClasses.provider_unreachable climbing |
Network or worker outage, not caller error. |
failureClasses.schema_invalid climbing |
The worker is answering off-contract. Treat as a regression in the pinned model or prompt, not a user problem. |
app.refusedBeforeModel climbing with no model traffic |
Abuse or a broken client; the model never saw it. |
Known gap
queue.inFlight is the app's own admission depth for POST /api/analyze. The full bounded queue
(concurrency limit enforcement, cancellation, overload fallback) is issue
#17; this
snapshot reports the slot accounting that exists today and does not claim the rest.