feat(telemetry): flag synchronous code blocking the event loop - #7128
feat(telemetry): flag synchronous code blocking the event loop#7128davidzhao wants to merge 21 commits into
Conversation
53eb8f4 to
3cdd5fd
Compare
c787ba9 to
3c539a7
Compare
|
Can this detect deadlock? or only short blocking code? Since deadlock never reports back |
|
this is only blocking code. deadlocks will require something more invasive. |
|
Some customers were running HTTP requests using the requests library. Depending on the timeout, the job process was just getting killed by the worker. |
|
do you mean sync http requests? I think those should be flagged with this change pretty easily (since not all of them will hit unresponsive servers). |
|
Fwiw if the goal is to detect blocking code and not deadlock. asyncio already has a slow code callback |
|
I'm wondering if there's something to do with py3.14:
|
d97378f to
33daed1
Compare
4ef3f25 to
37e2229
Compare
0158582 to
d4afa9b
Compare
chenghao-mou
left a comment
There was a problem hiding this comment.
I notice that all the comments are unnecessarily verbose. It might be worth simplifying them before merging.
2f56f59 to
0394821
Compare
cfd0c20 to
402a1c6
Compare
chenghao-mou
left a comment
There was a problem hiding this comment.
lgtm. One thing I noticed during test:
it constantly reports prewarm stall during start up (4/4):
2026-09-08 10:54:17,418 - WARNING livekit.agents - event loop blocked for 313ms at "<frozen importlib._bootstrap_external>", line 953, in get_data; synchronous work on the agent loop delays audio and turn handling, move it to a thread or an async client {"duration": 0.3127, "threshold": 0.1, "gc_time": 0.0209, "cpu_time": 0.2034, "task": "Task-2", "stack": "# loop thread sampled 102ms into the stall\n File \"/Users/chenghao/.t3/worktrees/agents/t3code-068f45de/livekit-agents/livekit/agents/llm/llm.py\", line 196, in _prewarm\n await self._prewarm_impl()\n File \"/Users/chenghao/.t3/worktrees/agents/t3code-068f45de/livekit-agents/livekit/agents/inference/llm.py\", line 285, in _prewarm_impl\n await self._client.models.list()\n File \"/Users/chenghao/.local/share/uv/python/cpython-3.14.0-macos-aarch64-none/lib/python3.14/functools.py\", line 1127, in __get__\n val = self.func(instance)\n File \"/Users/chenghao/.t3/worktrees/agents/t3code-068f45de/.venv/lib/python3.14/site-packages/openai/_client.py\", line 954, in models\n from .resources.models import AsyncModels\n [import system: 8 frames]\n File \"<frozen importlib._bootstrap_external>\", line 953, in get_data", "room": "console-e970511d", "pid": 65452, "job_id": "AJ_VGLVEcS7qzAt"}
and sometimes the eot init (1/4):
10:49:04 WARNING livekit.agents event loop blocked for 103ms at "/Users/chenghao/.t3/worktrees/agents/t3code-068f45de/livekit-agents/livekit/agents/inference/eot/transports.py", line 392, in __init__; synchronous work on the agent loop delays audio and turn handling, move it to a thread or an async client {"duration": 0.1028, "threshold": 0.1, "gc_time": 0.0003, "cpu_time": 0.0608, "task": "Task-20", "stack": "# loop thread sampled 92ms into the stall\n File \"/Users/chenghao/.t3/worktrees/agents/t3code-068f45de/livekit-agents/livekit/agents/utils/log.py\", line 17, in async_fn_logs\n return await fn(*args, **kwargs)\n File \"/Users/chenghao/.t3/worktrees/agents/t3code-068f45de/livekit-agents/livekit/agents/voice/agent_session.py\", line 1819, in _update_activity_task\n await self._update_activity(agent, wait_on_enter=False)\n File \"/Users/chenghao/.t3/worktrees/agents/t3code-068f45de/livekit-agents/livekit/agents/voice/agent_session.py\", line 1799, in _update_activity\n await self._activity.start(reuse_resources=reuse_resources)\n File \"/Users/chenghao/.t3/worktrees/agents/t3code-068f45de/livekit-agents/livekit/agents/voice/agent_activity.py\", line 896, in start\n await self._start_session(reuse_resources=reuse_resources)\n File \"/Users/chenghao/.t3/worktrees/agents/t3code-068f45de/livekit-agents/livekit/agents/voice/agent_activity.py\", line 1183, in _start_session\n self._audio_recognition._start(\n File \"/Users/chenghao/.t3/worktrees/agents/t3code-068f45de/livekit-agents/livekit/agents/voice/audio_recognition.py\", line 408, in _start\n self._update_turn_detector(self._turn_detector, stream=turn_detector_stream)\n File \"/Users/chenghao/.t3/worktrees/agents/t3code-068f45de/livekit-agents/livekit/agents/voice/audio_recognition.py\", line 968, in _update_turn_detector\n stream = detector.stream() if isinstance(detector, _StreamingTurnDetector) else None\n File \"/Users/chenghao/.t3/worktrees/agents/t3code-068f45de/livekit-agents/livekit/agents/inference/eot/detector.py\", line 201, in stream\n transport = _LocalTransport(opts=self._opts)\n File \"/Users/chenghao/.t3/worktrees/agents/t3code-068f45de/livekit-agents/livekit/agents/inference/eot/transports.py\", line 392, in __init__\n self._eot = _EOT()", "room": "console"}
maybe we can ignore certain internal calls with a different PR.
Synchronous work on the agent's event loop (a blocking HTTP client in a
tool, heavy numpy in an audio processor, time.sleep in on_enter) shows up
as unexplained latency and jitter. asyncio only reports slow callbacks in
debug mode, which is too expensive for production and only logs.
Add telemetry.loop_monitor, which watches a loop without patching asyncio:
- a heartbeat scheduled with call_later every 10 ms records the time, so a
block shows up as a late tick (resolution: one interval);
- a watchdog thread samples the loop thread's stack via sys._current_frames
once the gap crosses the warn threshold, and again at 10x, so the report
says where the loop was stuck;
- when the late tick runs, it emits a back-dated `event_loop_blocked` span
with lk.blocking.{duration,threshold,severity,task,stack,gc_time,cpu_time},
status ERROR past the error threshold, logs a rate-limited warning, and
records the lk.agents.event_loop.blocked_duration histogram.
GC time is measured through gc.callbacks so a gen-2 pause is not blamed on
user code, and the loop thread's CPU time separates busy work from blocking
waits. Spans and logs are capped per minute with a suppressed count.
The monitor runs on every job loop (proc_client.run, both PROCESS and THREAD
executors) and the worker loop. Reports are emitted inside a copy of the
job's context taken in the job_entrypoint span, so they carry the job's
attribution and parent to the primary session's root span when one exists.
Defaults: warn at 50 ms, error at 500 ms. LIVEKIT_AGENTS_LOOP_BLOCK_WARN_MS
and LIVEKIT_AGENTS_LOOP_BLOCK_ERROR_MS override them; WARN_MS=0 disables.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
asyncio task names are free text: the framework embeds participant identities in participant-entrypoint tasks and user code may name tasks anything, so the attribute becomes lk.pii.blocking.task. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Task names are the framework's or the developer's own labels, not end-user data; reverts the lk.pii.blocking.task rename. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Keep the innermost frame of a sampled stack even when it is asyncio's dispatch frame: a C function scheduled directly as a callback (time.sleep, a bound C method) has no frame of its own, and dropping the dispatch frame left the stack pointing at the runner. - Prefix each sample with when in the stall it was taken; it is a sample of the stall, not a profile. - The watchdog thread measures its own late wake-ups. When it stalled along with the loop, the process as a whole was not scheduled (host contention, CPU quota, suspended machine); the report is tagged process_descheduled and the log says so instead of blaming code on the loop. - Worker loop: no spans (there is no job or session to attach them to), log and metric only, via emit_spans=False. - Module docstring spells out what is and is not reported. Tests: idle loop, executor / to_thread work, and ~0.6 s of sustained cooperative load produce no reports (host-descheduling reports are filtered by the tag); a burst of ready callbacks in one iteration is one stall whose stack ends at the dispatch frame; a GC pause on a large heap is attributed to gc_time; worker mode logs without spans. The module passes repeatedly under 2x-cores CPU burners. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… session The trace view is organised around agent_session; a stall span anywhere else is invisible. Three cases are now handled explicitly: - during a session: the stall is a child of the session root (as before) and the agent_session span gets an `event_loop_blocked` event per stall plus lk.blocking.count / total_duration / max_duration, so a session with stalls is findable from its list entry without opening the trace; - before the session exists (the entrypoint's own work, ctx.connect() before session.start()): the stall is recorded as a telemetry.deferred.RecordedSpan and held on the JobContext; AgentSession.start() emits everything held as back-dated children of the new root, so it appears at its real time, ahead of session_start; - without a job (the worker process): log and metric only; there will never be a session to attach a span to. telemetry.deferred also provides session_span(), a context manager the connection spans can use to get the same treatment. Tests: pre-session stall held then emitted under the root with original timing; no-job stall is log-only; a stall inside a session callback lands under agent_session and is summarised on it. The monitor fixture now reports into a fake job with a live session root, the common case. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ning When the watchdog stalled along with the loop the host did not run the process; there is nothing for the developer to fix, so it must not surface as a warning or error. Logged at debug to keep a trail for capacity questions. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
When the watchdog thread also woke late by most of the stall the process was not scheduled, so the loop did not run slow code. Cap those reports at warning severity (UNSET span status) however long they lasted; ERROR stays reserved for code that blocked the loop. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ost starved the process Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…est fails Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e metric for every stall, reject NaN thresholds A native call that never releases the GIL also stops the watchdog thread, so the watchdog gap alone looked like host descheduling and the stall was downgraded to a debug log with no samples. The loop thread's CPU time tells the two apart: a descheduled process burns none, a GIL-holding call burns all of it. The report now says why no sample exists. The blocked-loop histogram was only recorded on the span path, so it went silent after 30 stalls a minute. Record it for every stall. LIVEKIT_AGENTS_LOOP_BLOCK_*_MS=NaN slipped past the '< 0' check and made every heartbeat a stall. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The histogram moved ahead of the rate limiters but outside the saved job context, so _job_attrs saw the heartbeat's context and every measurement lost its job attributes. Record it first inside _emit, which runs in that context. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A lazy import stalls through a dozen importlib frames per package level; they filled the frame budget and pushed out the caller that triggered the import. Each run collapses to one line; the innermost frame stays real. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…queue agent_session is a child of job_entrypoint, so a stall before the session belongs under the job's own span at its real time; nothing has to be held and re-parented when the session starts. During a session the root is still resolved through the job (the heartbeat's context predates it) so the stall nests under agent_session and is summarised on it. Without a job there is no trace to belong to, so the log carries it as before. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…n the session, sort imports When a descheduled process resumes, the loop thread can tick before the watchdog has recorded its late wake-up; the gap then went missing (a host stall reported as blocked code) and surfaced on the next stall instead. The tick now also considers when the watchdog last ran at all, and discards a late-wake record that predates the tick's window. The session summary was only updated when a span was emitted, so a burst past the span rate limit was undercounted. It is recorded for every stall. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…cut the runner out of stacks The watchdog sample reads the blocked task's current span (Task.get_context, 3.12+) so the stall lands under the rpc_handler, function_tool or hook that was running; older interpreters and unsampled stalls fall back to the session root. Sampled stacks drop everything through the innermost ipc/ runner frame, which is identical in every sample, and keep the frames below the user's code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The span (warning severity) and the metric keep the record; a debug line for every one of these was noise on a busy machine. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…; find the loop thread by the blocked task A 74 ms stall with no sample and no CPU is an idle loop woken late by the scheduler or timer coalescing, not blocking code: a blocking wait releases the GIL and gets sampled, a GIL-holding call burns CPU. Treat it like host descheduling (span at warning severity, no log). When the recorded thread ident does not resolve, locate the loop thread through the blocked task's coroutine frame instead of reporting an empty stack. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
50 ms proved too sensitive in practice: ordinary work such as a model load or an SDK import tripped it constantly, and a warning that always fires is ignored. The error threshold stays at 500 ms; LIVEKIT_AGENTS_LOOP_BLOCK_WARN_MS still overrides per deployment. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… do not use the log quota A blocking wait that releases the GIL can finish before the watchdog samples it, and a missing sample was read as host descheduling, so the block went unlogged. The first sample is now taken at half the warn threshold, and only a watchdog that was itself starved classifies a stall as descheduling. A descheduled-process report consumed one of the five per-minute log slots before being discarded, so a run of them silenced the warnings for real blocks. Eligibility is decided before the quota is consumed. Comments trimmed to what a reader of the code needs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ithout source lookup Sampling at half the threshold made the watchdog take the GIL on ordinary sub-threshold jitter; one interval before the threshold still catches a block that only just crosses it. The watchdog no longer reads source lines while it holds the GIL; they load when a report is formatted. Tests: the GIL-holding call test accepts a sample taken in the instant the call returns; the sustained-load test keeps each loop iteration far under the threshold now that a sleep-based lag is no longer reclassified as host stall. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
613fcc9 to
e8ec302
Compare
The release carrying the RpcInterceptor hook the stack's RPC tracing installs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
e8ec302 to
64b0740
Compare
What
Synchronous work on the agent's event loop (a blocking HTTP client in a tool, heavy numpy in an audio processor,
time.sleepinon_enter) shows up as unexplained latency and jitter. asyncio only reports slow callbacks in debug mode, which is too expensive for production and only logs.This adds
telemetry.loop_monitor, which flags blocks of 100 ms or more as spans, warnings, and a histogram, so users see the programming issue next to the turn it delayed.How
No monkeypatching of asyncio. The monitor observes one loop with:
call_laterevery 10 ms; a block shows up as a late tick (resolution: one interval);sys._current_frames()once the gap crosses the warn threshold, and again at 10x, so the report says where the loop was stuck;event_loop_blockedspan withlk.blocking.{duration,threshold,severity,task,stack,gc_time,cpu_time}, sets statusERRORpast the error threshold, logs a rate-limited warning with the innermost location, and recordslk.agents.event_loop.blocked_duration.Details:
gc.callbacksso a gen-2 pause is not blamed on user code; loop-thread CPU time separates busy work from blocking waits.lk.blocking.suppressed.proc_client.run, PROCESS and THREAD executors). The worker loop is monitored too, but there is no job or session to attach a span to there, so it logs and records the metric only.event_loop_blockedhistogram is recorded for every stall, before the span (30/min) and log (5/min) rate limits, so dashboards do not undercount sustained blocking.LIVEKIT_AGENTS_LOOP_BLOCK_*_MSrejects NaN and infinity like any other garbage value (falls back to the default).job_entrypointspan, so they carry job attribution. A stall during a session is a child ofagent_session(resolved through the job, since the heartbeat's own context predates the session) and the session span gets anevent_loop_blockedevent pluslk.blocking.count/total_duration/max_duration; a stall before or after the session is a child ofjob_entrypointat its real time. Without a job (the worker process) there is no trace to belong to: log and metric only.lk.blocking.count/total_duration/max_duration, oneevent_loop_blockedevent per stall) is updated for every stall, like the histogram, before the span (30/min) and log (5/min) rate limits apply.Task.get_context(), Python 3.12+), so atime.sleepin an RPC handler shows underrpc_handler, a slow tool underfunction_tool, a slow hook underon_user_turn_completed. Older interpreters, and stalls with no sample, fall back toagent_session.livekit/agents/ipc/frame (process bootstrap, client loop, entrypoint wrapper) is dropped, since it is the same in every sample; framework frames below the user's code stay because they show what blocked.Defaults: warn at 100 ms, error at 500 ms.
LIVEKIT_AGENTS_LOOP_BLOCK_WARN_MS/LIVEKIT_AGENTS_LOOP_BLOCK_ERROR_MSoverride;WARN_MS=0disables.Overhead: one timer callback and one thread wake-up every 10 ms, well under 0.1% of a core.
Tests
tests/test_loop_monitor.py(unit): a 200 mstime.sleepyields one back-dated error span whose stack names the blocking function andtime.sleep, with low CPU time; a 70 ms block is a warning with status UNSET; an idle loop, executor /to_threadwork, and ~0.6 s of sustained cooperative load yield nothing; a burst of ready callbacks in one iteration is one stall whose stack ends at the dispatch frame; a GC pause on a large heap is attributed togc_time; worker mode logs without spans;set_report_contextparents the span; stop is idempotent; rate limiter, env parsing, per-loop registry, constructor validation. The module passes repeatedly under 2x-cores CPU burners.Try it
Stacked on #7127.
🤖 Generated with Claude Code