Skip to content

feat(server): derive span→turn attribution from the audio timeline#93

Merged
basilebong merged 3 commits into
mainfrom
feat/metrics-eval
Jun 17, 2026
Merged

feat(server): derive span→turn attribution from the audio timeline#93
basilebong merged 3 commits into
mainfrom
feat/metrics-eval

Conversation

@LukasPoque

Copy link
Copy Markdown
Member

What

Turn membership for tool_calls / model_usage is no longer a stored turn_idx — it's derived at eval/read time by mapping each row's wall-clock started_at onto the audio timeline whose origin is replays.recording_started_at (the driver sends it as the X-Recording-Started-At header on audio upload). Model TTFT moves from a per-turn metric to an optional model_usage.ttft_ms attribute (gen_ai.response.time_to_first_chunk), with a new max_ttft_ms assertion.

Spec: docs/specs/0001-timeline-clock-alignment.md.

Key points

  • One attribution rule, no client/server divergence. timeline.ts (audioOffsetMs + clampedTurnWindows + offsetInTurnWindow) is shared by the server evaluator and the client trace tree. Windows are clamped with a monotonic cursor so they tile the timeline — every offset falls in exactly one turn, even under barge-in (overlapping VAD segments).
  • Anchor validated with a Date.parse gate. Rejects ISO forms valibot's isoTimestamp() accepts but Date.parse returns NaN for (…+02, space before offset), which would otherwise persist as a live-but-unplaceable anchor and make tool/ttft assertions report a misleading pass/fail instead of errored.
  • Client consumes server-derived audio_offset_ms, renders ttft_ms, and drops the started_at fallback — unanchored replays render Untimed (spec §3.1), never silently skewed.
  • TTFT picks the earliest in-window call that actually carries one, so a leading non-emitting call can't mask a later measured one; the errored message distinguishes "no model call in window" from "calls present, none carried the attribute".
  • SDK: RuntimeResult.recording_started_at_epoch; the orchestrator warns when a runtime produces audio without it.
  • migration 0002: drops *.turn_idx + replay_metrics.ttft_ms, adds model_usage.ttft_ms + replays.recording_started_at.

Testing

  • bun test — green (pre-existing environmental network/SSE flakes aside, identical on main); +30 net new tests covering clamped windows, TTFT selection, anchor validation, and the populated-DB migration upgrade.
  • tsc --noEmit clean, biome clean.
  • SDK: pyright --strict 0 errors, pytest 93 passed.

Follow-up

Replace stored tool_calls/model_usage turn_idx with eval/read-time
attribution: each row's wall-clock started_at is mapped onto the audio
timeline whose origin is replays.recording_started_at (driver sends it via
the X-Recording-Started-At header on audio upload). Turn windows are
clamped to tile the timeline so every offset falls in exactly one turn,
even under barge-in. Model TTFT moves from a per-turn metric to an optional
model_usage.ttft_ms attribute (gen_ai.response.time_to_first_chunk), with a
new max_ttft_ms assertion.

- timeline.ts: shared audioOffsetMs + clampedTurnWindows, used by both the
  server evaluator and the client trace tree — one attribution rule, no
  client/server boundary divergence
- validate the anchor with a Date.parse gate so ISO forms Date.parse
  rejects can't be stored as a live-but-unplaceable anchor
- client consumes server-derived audio_offset_ms, renders ttft_ms, and
  drops the started_at fallback (unanchored replays render untimed)
- orchestrator warns when a runtime produces audio without the anchor
- migration 0002 drops *.turn_idx + replay_metrics.ttft_ms, adds
  model_usage.ttft_ms + replays.recording_started_at
basilebong and others added 2 commits June 17, 2026 10:30
The tiling attribution windows must be built from each turn's voiceEndMs
(the voice-active boundary) per spec 0001 §3.4 and the clampedTurnWindows
contract, not turnEndMs. deriveTurns sets both columns to the same value
today so this is behaviour-preserving, but they are stored separately for
future overlap handling; using turnEndMs would silently mis-attribute once
they diverge, uncaught by fixtures that set them equal. Fixed in both the
server evaluator and the client trace tree so the two never drift.

Also hoist the per-row Number.isFinite(originMs) check out of the
buildReplayDetail offset closure, computed once per payload now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
hono <4.12.25 carries three advisories (GHSA-j6c9-x7qj-28xf,
GHSA-rv63-4mwf-qqc2, GHSA-wgpf-jwqj-8h8p): body-limit-middleware
bypasses on AWS Lambda and a Lambda@Edge header-dropping adapter. xray
deploys on Bun.serve as a single image, never AWS Lambda / Lambda@Edge,
so none are reachable here, but pnpm audit --audit-level=moderate blocks
the supply-chain CI gate.

Bump the direct dep to 4.12.25 and add surgical pnpm overrides for the
second copy bunqueue's embedded MCP server pulls
(@modelcontextprotocol/sdk>hono and >@hono/node-server>hono, both stuck
at 4.12.21). 4.12.25 was published 2026-06-09, past the 7-day cooldown,
so no cooldown bypass. Audit gate passes locally; 735 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@basilebong basilebong merged commit 441f8e3 into main Jun 17, 2026
7 checks passed
@basilebong basilebong deleted the feat/metrics-eval branch June 17, 2026 08:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants