Repository navigation
Description
A repeated error keeps the first occurrence's numbers forever — so 11 turns shared one reading
workers/api/src/lib/error-log.ts:59-64, collapseRepeat:
UPDATE error_log
SET repeat_count = repeat_count + 1, last_seen_at = datetime('now')
WHERE id = (SELECT id FROM error_log WHERE source = ?1 AND message = ?2 AND level = ?3 …)context is never updated. Occurrences 2..N are counted and time-stamped, and every measurement they carried is discarded.
What it cost today, concretely
The voice investigation on 2026-08-12 read this row:
"voice turn discarded at end-of-turn — the live gate heard no words"
repeat_count 11, created 08:50:18, last_seen 09:02:01
context: { peakLevel:0.664, noiseFloor:0, onsetFloor:0.1, frames:244,
gateAlive:true, gateHeardSpeech:false }
Those numbers describe one turn — the 08:50:18 one. The other ten are invisible. The diagnosis held because the first sample happened to be unambiguous (0.664 against a 0.1 floor), but the questions that actually mattered — was the peak similar every time? did frames vary? was the gate dead throughout or only sometimes? — were unanswerable from a log that had physically recorded them and then thrown them away.
This is the telemetry 97bf5a4 (#510) added hours earlier precisely so this class of failure could be diagnosed from a query instead of a code read. Collapsing discards most of it.
It will now silently drop the newest diagnostics
9fb7cd6 (#535) added gateHearing and gateSawWords to these rows. The collapse key is (source, message, level) and these messages are deliberately fixed strings — so a new-build occurrence folding into an old-build row inside the dedup window keeps the old context and the new fields never appear. Anyone reading the log would reasonably conclude the fields were not deployed.
This no longer bites the end-of-turn message (that path can't emit after #535), but it applies to the noise-rejection and clip-gate rows, and to every future field added to any collapsed source.
Design tension worth naming
Collapsing exists for a good reason — client:voice-control produced 8 refusals in one burst on 2026-08-11, and one row per refusal would bury the log. The bug is not the collapse, it is that the row keeps the wrong sample. Options:
- Keep the latest context (
SET context = ?) — one-line change; the count still shows the burst, and the sample tracks the newest occurrence. Loses the first, which is sometimes the interesting one. - Keep first and last — a
last_contextcolumn. Cheap, and covers "did it change over the burst". - Collapse on a narrower key — include a coarse bucket of the numeric context so materially different occurrences do not merge. More faithful, most complex, and risks the burst-flood the collapse prevents.
- Do not collapse rows that carry a numeric
contextat all, only ones that do not.
(2) looks like the best value; (1) is the smallest honest improvement. Decide on the code and the real row volume.
Acceptance criteria
- A burst of N occurrences with differing
contextdoes not present as N copies of the first one's numbers. - Whatever is kept, the row states which occurrence the context belongs to — a reader must not mistake a stale sample for the latest.
- A row created by an older build that later collapses a newer occurrence does not hide fields the newer build added.
- The burst-flood protection the collapse exists for is preserved; state the row-volume assumption.
- A test with two occurrences carrying different
context, asserting the surviving row is not silently the first.
Verified
error-log.ts:59-64 reads exactly as quoted — the UPDATE sets only repeat_count and last_seen_at. The 11-occurrence row with a single frames:244 reading was measured today via list_errors. The claim about gateHearing/gateSawWords being dropped on a fold follows from the collapse key being (source, message, level) with fixed message strings; not yet observed live, because nothing has touched the microphone since the deploy.
Activity
fix(observability): a collapsed error row keeps the latest occurrence…
feat(observability): every client error row names the build that wrot…
fix(coding): a Cloudflare attempt error is classified, reported as an…
fix(errors): the voice stack says whether a report is a failure or a …
serge-ivo commentedon Aug 15, 2026
Audited against the code rather than re-implemented: this is already delivered and deployed, and the issue outlived the fix. Recording the trace so it can be closed on evidence.
6becf3a(2026-08-12) took option (2) — keep first and last — with migration0124addingerror_log.last_context.cfe8ff7(#539) then closed the remaining half of criterion 3 by putting the build in the collapse identity. Both commits'Deploy API Workerruns are green, and that workflow runswrangler d1 migrations apply pags --remotebefore it replaces the Worker, solast_contextexists in production D1.The five criteria, walked against the current code
collapseRepeat(error-log.ts:179) now setslast_context = ?7alongside the counter and timestamp. The test replays the exact production row — 11 occurrences with driftingpeakLevel/frames— and asserts the surviving row still hasrepeat_count: 11and thatframesreads 254, not 244.contextis documented as the bucket-opening sample andlast_contextas the newest (ErrorRow,:285/:296); the admin feed labels themFirst of 11 · 08-12 08:50andLatest · 08-12 09:02(store/admin/src/pages/Errors.tsx:162), and a single-occurrence row haslast_context IS NULL— asserted, so "there has only been one" is a state a reader can read rather than infer.build, so two builds can never share a row at all; and for the sources that carry no build (server-side), the absorbed occurrence's context still lands inlast_context— tested withgateSawWords, the [bug] A deaf Web Speech gate vetoes 10 turns the energy detector proved were speech — isAlive() answers "has ever run", not "is hearing now" #535 field this criterion was written about.rows).toHaveLength(1). The assumption is written at the constants: one hour anchored oncreated_at⇒ ≤24 rows/day/signature, plus at most one bucket per signature per deployed build (a build id is constant for a tab).realSchemaD1(), not a stub. The docblock says why: the stubs answer "what SQL was issued", and this bug hid behind an UPDATE that was issued, did change a row, and still threw away ten of eleven measurements.Also checked, since a column nobody selects is worthless:
ERROR_COLUMNSincludeslast_context, soGET /v1/errors, MCPlist_errors(which passes the route's JSON through untouched) and both admin pages all carry it.Not verified: I did not read a live collapsed row back out of production D1 — no D1 credential on this path — so the confirmation is "the migration's deploy succeeded and the logic is tested against the schema that migration builds", not "I saw a two-sample row in prod". If you want the last inch, the next
client:voiceburst on the Errors page should show two labelled context blocks instead of one. Closing on the strength of the above.feat(diagnostics): which agent, which repo, which failure class — and…