Skip to content

feat(telemetry): trace dispatch, startup, and shutdown - #7131

Draft
davidzhao wants to merge 16 commits into
dz/telemetry-eot-waitfrom
dz/telemetry-startup-shutdown
Draft

feat(telemetry): trace dispatch, startup, and shutdown#7131
davidzhao wants to merge 16 commits into
dz/telemetry-eot-waitfrom
dz/telemetry-startup-shutdown

Conversation

@davidzhao

@davidzhao davidzhao commented Sep 5, 2026

Copy link
Copy Markdown
Member

What

Before the first turn and after the last one the trace had a single span, job_entrypoint, so "the agent took 4 s to say hello" and "the job hung on shutdown" could not be split into their steps. This adds the dispatch timeline, the startup spans, the shutdown spans, and the join keys for lining the agent trace up with server, SIP, and client events later.

Dispatch timeline

  • The worker stamps received_at, accepted_at, assigned_at on RunningJobInfo; the process pool stamps launched_at. StartJobRequest carries the four as 64-bit doubles (a 32-bit float only resolves ~128 s at unix-epoch magnitude, which made every latency read 0).
  • job_entrypoint is back-dated to received_at and gets one timestamped event per stage (job_received, job_accepted, job_assigned, process_assigned, entrypoint_started, job_started_on_server from JobState.started_at). The attributes are the seconds between adjacent stages, so the chain reads off the span without timestamp arithmetic: lk.job.accept_latency (request → accept), assignment_latency (accept → server assignment), launch_latency (assignment → a pool process takes the job), entrypoint_latency (process takes the job → user entrypoint runs), and their sum dispatch_latency. Raw *_at unix timestamps are not set as attributes. Unknown stages (simulation, console, resumed jobs) are skipped, not guessed.
  • agent_session is a child of job_entrypoint, so the job's trace tells the whole story from dispatch to teardown: job_entrypoint → (room_connect, agent_session, …, job_shutdown). Nothing is replayed or re-parented for the session's benefit; a viewer keyed to agent_session zooms out to the job for the rest.
  • The trace pipeline is up before the job's first span. Until now the cloud tracer provider was created on the first init_recording (inside session.start()), so in a process's first job every earlier span was a non-recording stub: job_entrypoint and room_connect were lost, and agent_session had no valid parent and started its own trace (that is why it rendered as a root). JobContext._prepare_telemetry now creates the provider and the gated exporter when the job starts, registering nothing; the gate holds the job's spans (oldest first, bounded) until init_recording decides, then uploads or drops them, and drops them at cleanup if the job never registers.
  • job_entrypoint spans the whole job. It is created before the user entrypoint runs and ended after job_shutdown, just before the telemetry release flushes, so the root covers dispatch, session and teardown. The user function returning is an entrypoint_returned event on it (most entrypoints return right after session.start()); a raising entrypoint is recorded redaction-aware, a cancelled one as entrypoint_cancelled.
  • Join keys on job_entrypoint and agent_session: lk.room_sid, lk.dispatch_id, lk.job.worker_id, lk.job.agent_id. A linked SIP participant's sip.* attributes are copied onto agent_session under lk.sip.* (call id, trunk id and number, rule id, status, headers); only the end user's sip.phoneNumber is PII and lands as lk.pii.sip.phoneNumber.

Startup

  • room_connect around JobContext.connect: room ids, auto_subscribe, e2ee, local participant, remote participant count; error status on failure. Called before session.start() it lands under job_entrypoint; called while the session is starting it nests under session_start.
  • session_start groups AgentSession.start(): start_agent_activity, setup_toolsets, RoomIO's wait_for_participant, publish_audio_output, and the room connect. Only startup work is parented there via explicit contexts; user_turn / agent_turn stay directly under agent_session (tested).
  • wait_for_audio_track on the RoomIO audio input: linked participant → first frame, with track_subscribed, pre_connect_audio, and first_frame events and lk.first_frame_delay.
  • Events on agent_session: participant_linked, participant_disconnected (reason), connection_state_changed, agent_state_changed, user_state_changed.

Shutdown

  • session_close wraps the AgentSession close with lk.close_reason (enum) and lk.close.drain; drain_agent_activity and on_exit nest inside it.
  • job_shutdown wraps the job's shutdown sequence with lk.shutdown.reason and children on_session_end, session_end_upload, room_disconnect, and one shutdown_callback per user callback named by lk.callback.name (the session's own close hook runs without a span: it is already session_close). It is a child of job_entrypoint, like the session.
  • user_turn is pinned to the session root explicitly. It can be created from any task (a late STT final arriving during session_close, for one) and used to nest under whatever was current there.
  • Startup spans are never current. session_start, room_connect, wait_for_participant, publish_audio_output and setup_toolsets wrap code that spawns the tasks living for the whole session (the room's event tasks, the published track, MCP servers). A current span there is inherited by those tasks and becomes the accidental parent of unrelated spans they emit later: in a real run user_speaking from the room's active-speaker callback landed under room_connect. They are now created with tracer.detached_span(name, context=...): parented explicitly (under session_start while it runs), never attached to the context.
  • Process warm-up (_preload_for_jobs, before the user prewarm function) does the framework's own lazy one-time work: it loads the livekit-rtc native library (otherwise the first FFI call, the AudioProcessingModule at session start, took 100–350 ms) imports the openai SDK's resources tree (otherwise the inference LLM prewarm's first client attribute access took ~300 ms), and builds one httpx client so the process's SSL context exists (otherwise the first inference client construction took ~50 ms of GIL-held CPU at session start). Both showed up as event_loop_blocked at session start that no user code caused.
  • Job cleanup (tempdir removal, the per-job telemetry release that joins exporter flush threads) moved off the event loop into asyncio.to_thread, so shutdown no longer stalls the loop the monitor watches, and THREAD-executor jobs sharing the process are not held up.
job_entrypoint  (back-dated to job_received; events per stage)
├─ agent_session
│  ├─ session_start
│  │  ├─ room_connect
│  │  ├─ wait_for_participant
│  │  ├─ wait_for_audio_track
│  │  ├─ publish_audio_output
│  │  └─ start_agent_activity
│  │     ├─ setup_toolsets
│  │     └─ on_enter
│  ├─ user_turn / agent_turn ...
│  └─ session_close
│     ├─ drain_agent_activity
│     └─ on_exit
└─ job_shutdown
   ├─ on_session_end
   ├─ session_end_upload
   ├─ room_disconnect
   └─ shutdown_callback (×N)

Tests

tests/test_startup_spans.py: StartJobRequest round trip, dispatch events and latencies, server timestamp units, room_connect under the job span, success and failure, shutdown callback naming, and a full fake session asserting the session_start / session_close nesting, that turns are not re-parented, and the state events. tests/test_recording.py: the gate holds, flushes and drops spans of undecided jobs; the prepared pipeline holds early spans until the job registers.

Not covered by unit tests (needs a real room): wait_for_audio_track and publish_audio_output.

Stacked on #7130.

🤖 Generated with Claude Code

@davidzhao
davidzhao force-pushed the dz/telemetry-startup-shutdown branch from 49e481c to 07e9986 Compare September 5, 2026 20:07
@davidzhao
davidzhao force-pushed the dz/telemetry-startup-shutdown branch from 07e9986 to 2eca23a Compare September 5, 2026 20:42
@davidzhao
davidzhao force-pushed the dz/telemetry-startup-shutdown branch from 85de80e to 1e82c8c Compare September 5, 2026 21:00
@davidzhao
davidzhao force-pushed the dz/telemetry-startup-shutdown branch from 1e82c8c to 3902f9e Compare September 5, 2026 21:07
@davidzhao
davidzhao force-pushed the dz/telemetry-startup-shutdown branch 3 times, most recently from 8d96f23 to d7fad0d Compare September 6, 2026 00:31
@davidzhao
davidzhao force-pushed the dz/telemetry-startup-shutdown branch from d7fad0d to 6e8ea0b Compare September 6, 2026 00:56
@davidzhao
davidzhao force-pushed the dz/telemetry-startup-shutdown branch 3 times, most recently from 88a4863 to 6b15234 Compare September 6, 2026 01:37
@davidzhao
davidzhao force-pushed the dz/telemetry-startup-shutdown branch 2 times, most recently from bab83f9 to 1b2e6af Compare September 6, 2026 05:56
@davidzhao
davidzhao force-pushed the dz/telemetry-startup-shutdown branch from 1b2e6af to 9d13ba9 Compare September 6, 2026 06:20
@davidzhao
davidzhao force-pushed the dz/telemetry-startup-shutdown branch 2 times, most recently from 159972b to e758ff1 Compare September 6, 2026 06:43
@davidzhao
davidzhao force-pushed the dz/telemetry-startup-shutdown branch from ca523b9 to 999bd82 Compare September 6, 2026 08:09
@davidzhao
davidzhao force-pushed the dz/telemetry-startup-shutdown branch from 999bd82 to cb42fcc Compare September 6, 2026 08:24
@davidzhao
davidzhao force-pushed the dz/telemetry-startup-shutdown branch 2 times, most recently from 3390ef0 to 6dcae44 Compare September 6, 2026 17:13
@davidzhao
davidzhao force-pushed the dz/telemetry-startup-shutdown branch from 6dcae44 to f8a9773 Compare September 6, 2026 21:10
@davidzhao
davidzhao force-pushed the dz/telemetry-startup-shutdown branch from f8a9773 to e4f8731 Compare September 6, 2026 22:13
davidzhao and others added 16 commits September 6, 2026 15:16
Before the first turn and after the last one the trace had a single span,
job_entrypoint, so "the agent took 4 s to say hello" and "the job hung on
shutdown" could not be split into their steps.

Dispatch timeline
- The worker stamps received_at / accepted_at / assigned_at on RunningJobInfo
  and the process pool stamps launched_at; StartJobRequest carries the four
  floats to the job process (IPC is internal and version-locked).
- job_entrypoint is back-dated to received_at and gets one timestamped event
  per stage (job_received, job_accepted, job_assigned, process_assigned,
  entrypoint_started, job_started_on_server from JobState.started_at) plus
  lk.job.{accept,assignment,launch,dispatch}_latency. Unknown stages
  (simulation, console, resumed jobs) are skipped, not guessed.
- Join keys on job_entrypoint and agent_session: lk.room_sid, lk.dispatch_id,
  lk.job.worker_id, lk.job.agent_id. A linked SIP participant's sip.*
  attributes are copied onto agent_session under lk.pii.sip.*.

Startup
- room_connect (JobContext.connect): room ids, auto_subscribe, e2ee, local
  participant, remote participant count; error status on failure.
- session_start groups AgentSession.start(): start_agent_activity (now with an
  explicit parent), setup_toolsets, RoomIO's wait_for_participant,
  publish_audio_output, and the room connect. Only startup work is parented
  there; user_turn / agent_turn stay directly under agent_session.
- wait_for_audio_track on the RoomIO audio input: linked participant -> first
  frame, with track_subscribed / pre_connect_audio / first_frame events and
  lk.first_frame_delay.
- Session events: participant_linked, participant_disconnected (reason),
  connection_state_changed, agent_state_changed, user_state_changed.

Shutdown
- session_close wraps AgentSession close with lk.close_reason and lk.close.drain;
  drain_agent_activity and on_exit nest inside it.
- job_shutdown (child of job_entrypoint) wraps the job's shutdown sequence with
  children on_session_end, session_end_upload, room_disconnect, and one
  shutdown_callback per user callback named by lk.callback.name (the no-arg
  wrapper now keeps the user's function name). A hung callback is why jobs hit
  the supervisor deadline; this shows which one.

Tests: tests/test_startup_spans.py covers the StartJobRequest round trip, the
dispatch events and latencies, server timestamp units, room_connect success and
failure, the shutdown callback naming, and a full fake session asserting the
session_start / session_close nesting, that turns are not re-parented, and the
state events.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The reason is free text from JobContext.shutdown(reason=...), so it becomes
lk.pii.shutdown.reason.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The reason string is developer-authored, like a log message; reverts the
lk.pii.shutdown.reason rename.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…umber

sip.trunkPhoneNumber, ids, status and headers belong to the customer or the
call, so they land under plain lk.sip.*; sip.phoneNumber is the end user's
number and becomes lk.pii.sip.phoneNumber.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
tests/test_room_io.py drives RoomIO with a SimpleNamespace session; route the
connection-state and participant-disconnected events through a guarded
helper so those tests keep working.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The entrypoint usually connects before session.start(), which put room_connect
(and JobContext.wait_for_participant) under job_entrypoint, outside the
session the trace view is organised around. Both now go through
telemetry.deferred.session_span: with a session running they are ordinary
spans in the current context; before it exists they are recorded and emitted
as back-dated children of agent_session when the session starts, so the
connection shows up as the session's opening act.

session_span / defer_to_session accept an explicit job_ctx so JobContext can
pass itself and not depend on the ambient context variable.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t_session

The stage timestamps already land on job_entrypoint, but the cloud trace view
only shows the agent_session subtree. Hold a back-dated job_dispatch span
(received_at to entrypoint start, same events and latencies) on the job and
emit it as the session's first child, ahead of room_connect.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… adjacent-stage latencies

StartJobRequest shipped received/accepted/assigned/launched as 32-bit floats,
which resolve only ~128 s at unix-epoch magnitude: every stage collapsed to
the same instant and accept/assignment latency always read 0. Use doubles.

The stage instants stay as timestamped events on the span; the attributes are
now the seconds between adjacent stages (accept, assignment, launch,
entrypoint) summing to dispatch_latency. Raw *_at unix timestamps are no
longer set as attributes: they were unreadable as a column of numbers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… rtc native library

session_start, room_connect, wait_for_participant, publish_audio_output and
setup_toolsets wrap code that spawns the tasks living for the whole session
(the room's event tasks, the published track, MCP servers). Made current,
they were inherited by those tasks and became the parent of unrelated spans
emitted later: user_speaking from the room's active-speaker callback landed
under room_connect. Create them with tracer.detached_span and pass the parent
explicitly (session_start while it runs); the pipeline stays under
agent_session.

The first FFI call loads the livekit-rtc dylib, which happened inside the job
at session start and showed as a 100-350 ms event_loop_blocked. Preload it
during process warm-up, before the user prewarm function.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
livekit.agents.inference is built on the openai SDK, which imports its whole
resources tree on the first client attribute access. That happened in the LLM
prewarm inside the job and showed as a ~300 ms event_loop_blocked at session
start. Import it alongside the rtc native library while the process warms up.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…oot, no span for framework callbacks

The cloud view is organised around agent_session; job_shutdown hung off
job_entrypoint and never showed there. Parent it to the session's root context,
kept after close for this purpose, falling back to job_entrypoint.

The session registers its own close hook as a job shutdown callback; a
shutdown_callback span for it read as a second user callback. Framework
callbacks run without a span.

A late STT final during session_close committed a user turn that nested under
the close span because user_turn took the ambient context. Pin it to the root.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
agent_session is a child of job_entrypoint, so everything before and after the
session (dispatch, room connect, shutdown) already sits in the job's trace at
its real time. Remove what only existed for a view keyed to the session span:
the job_dispatch replay, the deferred pre-session spans, and job_shutdown's
re-parenting to a closed session. telemetry.deferred becomes
telemetry.session_context: resolve the running session through the job (the
loop monitor's heartbeat predates it) and nest startup work under session_start.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… make job_entrypoint span the job

The cloud tracer provider was created on the first init_recording, inside
session.start(). In a process's first job every earlier span was therefore a
non-recording stub: job_entrypoint and room_connect were lost, and
agent_session had no valid parent and started its own trace, which is why it
rendered as a root in the cloud.

JobContext._prepare_telemetry now creates the provider and the gated exporter
when the job starts, registering nothing. The gate holds an undecided job's
spans (oldest first, bounded) until init_recording decides, then uploads or
drops them; a job that never registers drops them at cleanup.

job_entrypoint is created before the user entrypoint runs and ended after
job_shutdown, just before the telemetry release, so the job's root covers
dispatch, session and teardown; the entrypoint returning is an event on it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The first httpx.AsyncClient builds the SSL context from the CA bundle (~50 ms
of GIL-held CPU); the inference LLM, STT and TTS each construct one at session
start, which showed as an event_loop_blocked nobody could act on.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@davidzhao
davidzhao force-pushed the dz/telemetry-startup-shutdown branch from e4f8731 to 4b21826 Compare September 6, 2026 22:18
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