Skip to content

tests: trace-shape schema and checker - #2501

Open
davidzhao wants to merge 1 commit into
dz/telemetry-agent-turnfrom
dz/telemetry-trace-schema
Open

davidzhao wants to merge 1 commit into
dz/telemetry-agent-turnfrom
dz/telemetry-trace-schema

Conversation

@davidzhao

@davidzhao davidzhao commented Sep 15, 2026

Copy link
Copy Markdown
Member

Port of livekit/agents#7148. Stacked on #2500.

Description

The nesting of spans is an emergent property of many call sites. A refactor can move a span under the wrong parent while every existing test passes, because each test asserts the one edge it was written for. This layer writes the rules down once and checks every span in every full-session test against them, and applies the same rules to a real run's export.

agents/src/telemetry/testing/trace_schema.ts (test support, not exported from the package):

  • SPAN_PARENTS: for every span name the JS framework emits, the parents it may have (ROOT for none, ANY for spans that follow their caller: rpc_call, event_loop_blocked). An unknown name is a violation, so a new span must be registered.
  • MAY_OUTLIVE_PARENT: the child/parent edges where the child may end after its parent, each with its reason (the startup spans session.start() does not wait for, on_enter, and stalls whose end is one heartbeat late). Everything else must sit inside its parent, with 2 ms of slack.
  • checkTrace(): one trace id, every parent present, parent allowed by the schema, bounds, and the per-turn invariants: one agent_turn per lk.speech_id, lk.generation_count equal to the number of generation events, every eou_wait with an outcome.
  • Two span sources, same rules: fromReadableSpans for an in-memory exporter, fromOtlpJson for an export downloaded from LiveKit Cloud. As a CLI, pnpm exec tsx agents/src/telemetry/testing/trace_schema.ts traces.json prints the span summary and the violations, tolerating orphans in a partial export.

assertTraceWellFormed(exporter.getFinishedSpans()) now ends the full-session tests of the earlier layers: agent_turn_span, coverage_spans, eou_wait_span, session_lifecycle_span.

Schema differences from Python

All from what the JS code emits: answering_machine_detection is JS's name for amd; JS has no llm_fallback_adapter / tts_fallback_adapter / tts_stream_adapter spans (its adapters emit the plain request spans, which nest under the attempt's *_request_run), no wait_for_video_track, no judge_evaluation; rpc_handler may be a root, since without a session the SDK dispatches on a context carrying no span. MAY_OUTLIVE_PARENT is identical to Python's.

Testing

  • New telemetry/testing/trace_schema.test.ts (9 tests): schema self-consistency; a sound trace passes; a misparented llm_request, an eou_detection outside its wait, an unknown span, a missing parent, a child outside its parent, a duplicated speech turn, a generation-count mismatch and a second trace id are each reported; the deliberate overruns pass; OTLP and in-memory sources agree; a full fake session is well-formed.
  • Test hardening: the eou_wait full-session helper decided the turn 20 ms after the fake STT final was due, which under load opened a second user turn; the endpointing delay now leaves a 170 ms margin.
  • No shipped code changes. Full agents suite green.

🤖 Generated with Claude Code

@davidzhao
davidzhao added this pull request to stack #2502 September 15, 2026 06:32
@changeset-bot

changeset-bot Bot commented Sep 15, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: f0c186a

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@davidzhao
davidzhao force-pushed the dz/telemetry-trace-schema branch 2 times, most recently from 0a979d8 to 473c53d Compare September 16, 2026 04:28
@davidzhao
davidzhao force-pushed the dz/telemetry-trace-schema branch from 473c53d to 70effd6 Compare September 16, 2026 04:34
@davidzhao
davidzhao force-pushed the dz/telemetry-trace-schema branch from 70effd6 to 1bb26cb Compare September 20, 2026 05:02
@davidzhao
davidzhao force-pushed the dz/telemetry-trace-schema branch from 1bb26cb to 378dc23 Compare September 20, 2026 05:58
@davidzhao
davidzhao force-pushed the dz/telemetry-trace-schema branch from 378dc23 to 434f47e Compare September 20, 2026 06:11
@davidzhao
davidzhao force-pushed the dz/telemetry-trace-schema branch from 434f47e to 0d1517e Compare September 20, 2026 07:14
@davidzhao
davidzhao force-pushed the dz/telemetry-trace-schema branch from 0d1517e to b2c5e8d Compare September 20, 2026 07:15
@davidzhao
davidzhao force-pushed the dz/telemetry-trace-schema branch 2 times, most recently from 2268d2d to f064b33 Compare September 20, 2026 17:17
@davidzhao
davidzhao force-pushed the dz/telemetry-trace-schema branch 2 times, most recently from 0daa94a to 92a97ea Compare September 20, 2026 18:02
@davidzhao
davidzhao force-pushed the dz/telemetry-trace-schema branch 2 times, most recently from b6eebad to 289b916 Compare September 21, 2026 03:28
@davidzhao
davidzhao marked this pull request as ready for review September 21, 2026 04:36
@davidzhao
davidzhao requested a review from a team as a code owner September 21, 2026 04:36

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Devin Review

@davidzhao
davidzhao force-pushed the dz/telemetry-trace-schema branch from 289b916 to 81f697d Compare September 21, 2026 05:05
Port of livekit/agents#7148. The nesting of spans is an emergent
property of many call sites; a refactor can move a span under the wrong
parent while every existing test passes, because each asserts one edge.
agents/src/telemetry/testing/trace_schema.ts writes the rules down once:

- SPAN_PARENTS: for every span the JS framework emits, the parents it
  may have (ROOT for none, ANY for spans that follow their caller).
  Unknown names are violations, so a new span must be registered.
- MAY_OUTLIVE_PARENT: the child/parent edges where the child may end
  after its parent, each with its reason. Everything else must sit
  inside its parent, with 2 ms of slack.
- checkTrace(): one trace id, every parent present and allowed, bounds,
  one agent_turn per lk.speech_id, lk.generation_count equal to the
  speech's own generation events, every eou_wait with an outcome.
- fromReadableSpans / fromOtlpJson: an in-memory exporter or an export
  downloaded from LiveKit Cloud, same rules. As a CLI
  (`pnpm exec tsx agents/src/telemetry/testing/trace_schema.ts x.json`)
  it prints the span summary and the violations, tolerating orphans in
  a partial export.

assertTraceWellFormed() now ends the full-session tests: the tool call
and plain reply in agent_turn_span, the barge-in and handoff in
coverage_spans, the hook and redaction sessions in eou_wait_span, the
lifecycle and SIP sessions in session_lifecycle_span. The module is
test support: imported by tests only, not exported from the package,
and free of framework imports so the CLI runs on the source file.

Schema differences from Python, all from what the JS code emits:
answering_machine_detection is JS's name for `amd`; JS has no
llm_fallback_adapter / tts_fallback_adapter / tts_stream_adapter spans
(its adapters emit the plain request spans, which nest under the
attempt's *_request_run), no wait_for_video_track, no judge_evaluation;
rpc_handler may be a root, since without a session the SDK dispatches
on a context carrying no span.

Test hardening: the eou_wait full-session helper decided the turn 20 ms
after the fake STT final was due, so a loaded host could open a second
user turn (the intermittent failure seen in full-suite runs); the
endpointing delay now leaves a 170 ms margin.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@davidzhao
davidzhao force-pushed the dz/telemetry-trace-schema branch from 81f697d to f0c186a Compare September 21, 2026 05:18

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

Devin Review

Comment on lines +352 to +355
const ownGenerations = span.events.filter(
(event) =>
event.name === 'generation' &&
String(event.attributes['lk.generation_id'] ?? `${speechId}_`).startsWith(`${speechId}_`),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Valid preemptive turns fail checks

After a preemptive handoff, checkTrace excludes the discarded generation. lk.generation_count includes every generation, so valid turns receive a count violation.

Learn more

A preemptive handoff preserves the discarded attempt's generation event on the adopted span. The successor changes lk.speech_id, but withAgentTurn keeps lk.generation_count cumulative across every generation event. Filtering events by the successor's speech ID therefore compares different quantities.

Example: An attempt emits attempt_1, then its successor emits reply_1. The span has lk.speech_id=reply and lk.generation_count=2, but the checker counts only reply_1 and reports 2 but 1.

Recommended fix: Compare lk.generation_count with all generation events on the span. Keep the discarded-event attribution check separate if the schema also needs to validate handoffs.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +295 to +298
const traceIds = new Set(spans.map((span) => span.traceId));
if (traceIds.size > 1) {
violations.push(`spans belong to ${traceIds.size} traces, expected one`);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Empty exports pass validation

With no spans, checkTrace accepts zero trace IDs because it rejects only multiple IDs. Empty or unrecognized exports report trace shape OK.

Suggested change
const traceIds = new Set(spans.map((span) => span.traceId));
if (traceIds.size > 1) {
violations.push(`spans belong to ${traceIds.size} traces, expected one`);
}
const traceIds = new Set(spans.map((span) => span.traceId));
if (traceIds.size !== 1) {
violations.push(`spans belong to ${traceIds.size} traces, expected one`);
}

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

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.

1 participant