feat(telemetry): trace dispatch, startup, and shutdown - #7131
Draft
davidzhao wants to merge 16 commits into
Draft
Conversation
davidzhao
force-pushed
the
dz/telemetry-startup-shutdown
branch
from
September 5, 2026 20:07
49e481c to
07e9986
Compare
davidzhao
force-pushed
the
dz/telemetry-startup-shutdown
branch
from
September 5, 2026 20:42
07e9986 to
2eca23a
Compare
davidzhao
force-pushed
the
dz/telemetry-startup-shutdown
branch
from
September 5, 2026 21:00
85de80e to
1e82c8c
Compare
davidzhao
force-pushed
the
dz/telemetry-startup-shutdown
branch
from
September 5, 2026 21:07
1e82c8c to
3902f9e
Compare
davidzhao
force-pushed
the
dz/telemetry-startup-shutdown
branch
3 times, most recently
from
September 6, 2026 00:31
8d96f23 to
d7fad0d
Compare
davidzhao
force-pushed
the
dz/telemetry-startup-shutdown
branch
from
September 6, 2026 00:56
d7fad0d to
6e8ea0b
Compare
davidzhao
force-pushed
the
dz/telemetry-startup-shutdown
branch
3 times, most recently
from
September 6, 2026 01:37
88a4863 to
6b15234
Compare
davidzhao
force-pushed
the
dz/telemetry-startup-shutdown
branch
2 times, most recently
from
September 6, 2026 05:56
bab83f9 to
1b2e6af
Compare
davidzhao
force-pushed
the
dz/telemetry-startup-shutdown
branch
from
September 6, 2026 06:20
1b2e6af to
9d13ba9
Compare
davidzhao
force-pushed
the
dz/telemetry-startup-shutdown
branch
2 times, most recently
from
September 6, 2026 06:43
159972b to
e758ff1
Compare
davidzhao
force-pushed
the
dz/telemetry-startup-shutdown
branch
from
September 6, 2026 08:09
ca523b9 to
999bd82
Compare
davidzhao
force-pushed
the
dz/telemetry-startup-shutdown
branch
from
September 6, 2026 08:24
999bd82 to
cb42fcc
Compare
davidzhao
force-pushed
the
dz/telemetry-startup-shutdown
branch
2 times, most recently
from
September 6, 2026 17:13
3390ef0 to
6dcae44
Compare
davidzhao
force-pushed
the
dz/telemetry-startup-shutdown
branch
from
September 6, 2026 21:10
6dcae44 to
f8a9773
Compare
davidzhao
force-pushed
the
dz/telemetry-startup-shutdown
branch
from
September 6, 2026 22:13
f8a9773 to
e4f8731
Compare
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
force-pushed
the
dz/telemetry-startup-shutdown
branch
from
September 6, 2026 22:18
e4f8731 to
4b21826
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
received_at,accepted_at,assigned_atonRunningJobInfo; the process pool stampslaunched_at.StartJobRequestcarries 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_entrypointis back-dated toreceived_atand gets one timestamped event per stage (job_received,job_accepted,job_assigned,process_assigned,entrypoint_started,job_started_on_serverfromJobState.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 sumdispatch_latency. Raw*_atunix timestamps are not set as attributes. Unknown stages (simulation, console, resumed jobs) are skipped, not guessed.agent_sessionis a child ofjob_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 toagent_sessionzooms out to the job for the rest.init_recording(insidesession.start()), so in a process's first job every earlier span was a non-recording stub:job_entrypointandroom_connectwere lost, andagent_sessionhad no valid parent and started its own trace (that is why it rendered as a root).JobContext._prepare_telemetrynow creates the provider and the gated exporter when the job starts, registering nothing; the gate holds the job's spans (oldest first, bounded) untilinit_recordingdecides, then uploads or drops them, and drops them at cleanup if the job never registers.job_entrypointspans the whole job. It is created before the user entrypoint runs and ended afterjob_shutdown, just before the telemetry release flushes, so the root covers dispatch, session and teardown. The user function returning is anentrypoint_returnedevent on it (most entrypoints return right aftersession.start()); a raising entrypoint is recorded redaction-aware, a cancelled one asentrypoint_cancelled.job_entrypointandagent_session:lk.room_sid,lk.dispatch_id,lk.job.worker_id,lk.job.agent_id. A linked SIP participant'ssip.*attributes are copied ontoagent_sessionunderlk.sip.*(call id, trunk id and number, rule id, status, headers); only the end user'ssip.phoneNumberis PII and lands aslk.pii.sip.phoneNumber.Startup
room_connectaroundJobContext.connect: room ids,auto_subscribe, e2ee, local participant, remote participant count; error status on failure. Called beforesession.start()it lands underjob_entrypoint; called while the session is starting it nests undersession_start.session_startgroupsAgentSession.start():start_agent_activity,setup_toolsets, RoomIO'swait_for_participant,publish_audio_output, and the room connect. Only startup work is parented there via explicit contexts;user_turn/agent_turnstay directly underagent_session(tested).wait_for_audio_trackon the RoomIO audio input: linked participant → first frame, withtrack_subscribed,pre_connect_audio, andfirst_frameevents andlk.first_frame_delay.agent_session:participant_linked,participant_disconnected(reason),connection_state_changed,agent_state_changed,user_state_changed.Shutdown
session_closewraps the AgentSession close withlk.close_reason(enum) andlk.close.drain;drain_agent_activityandon_exitnest inside it.job_shutdownwraps the job's shutdown sequence withlk.shutdown.reasonand childrenon_session_end,session_end_upload,room_disconnect, and oneshutdown_callbackper user callback named bylk.callback.name(the session's own close hook runs without a span: it is alreadysession_close). It is a child ofjob_entrypoint, like the session.user_turnis pinned to the session root explicitly. It can be created from any task (a late STT final arriving duringsession_close, for one) and used to nest under whatever was current there.session_start,room_connect,wait_for_participant,publish_audio_outputandsetup_toolsetswrap 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 runuser_speakingfrom the room's active-speaker callback landed underroom_connect. They are now created withtracer.detached_span(name, context=...): parented explicitly (undersession_startwhile it runs), never attached to the context._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, theAudioProcessingModuleat session start, took 100–350 ms) imports the openai SDK'sresourcestree (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 asevent_loop_blockedat session start that no user code caused.asyncio.to_thread, so shutdown no longer stalls the loop the monitor watches, and THREAD-executor jobs sharing the process are not held up.Tests
tests/test_startup_spans.py:StartJobRequestround trip, dispatch events and latencies, server timestamp units,room_connectunder the job span, success and failure, shutdown callback naming, and a full fake session asserting thesession_start/session_closenesting, 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_trackandpublish_audio_output.Stacked on #7130.
🤖 Generated with Claude Code