Skip to content

feat(telemetry): flag synchronous code blocking the event loop - #7128

Open
davidzhao wants to merge 21 commits into
mainfrom
dz/telemetry-loop-monitor
Open

feat(telemetry): flag synchronous code blocking the event loop#7128
davidzhao wants to merge 21 commits into
mainfrom
dz/telemetry-loop-monitor

Conversation

@davidzhao

@davidzhao davidzhao commented Sep 5, 2026

Copy link
Copy Markdown
Member

What

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.

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:

  • a heartbeat scheduled with call_later every 10 ms; a block shows up as a late tick (resolution: one interval);
  • a watchdog thread that 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 (block over), it emits a back-dated event_loop_blocked span with lk.blocking.{duration,threshold,severity,task,stack,gc_time,cpu_time}, sets status ERROR past the error threshold, logs a rate-limited warning with the innermost location, and records lk.agents.event_loop.blocked_duration.

Details:

  • GC time is measured through gc.callbacks so a gen-2 pause is not blamed on user code; loop-thread CPU time separates busy work from blocking waits.
  • Spans capped at 30/min and logs at 5/min; the next span carries lk.blocking.suppressed.
  • Runs on every job loop (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.
  • The watchdog thread measures its own late wake-ups: when it stalled along with the loop, the whole process was descheduled (host contention, CPU quota). That is not a programming issue, so it is not logged at all (the span, at warning severity with UNSET status, and the metric keep the record); ERROR is reserved for code that blocked the loop. A native call that holds the GIL for the whole stall also starves the watchdog, so the loop thread's CPU time tells the two apart: a descheduled process burns none, a GIL-holding call burns all of it. That case stays an error, and since the sampler could not run, the stack attribute says so instead of staying empty.
  • The event_loop_blocked histogram 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_*_MS rejects NaN and infinity like any other garbage value (falls back to the default).
  • Stack samples keep the innermost frame even when it is asyncio's dispatch frame (a C function scheduled directly has no frame of its own) and are prefixed with when in the stall they were taken.
  • Reports run inside a copy of the job's context taken in the job_entrypoint span, so they carry job attribution. A stall during a session is a child of agent_session (resolved through the job, since the heartbeat's own context predates the session) and the session span gets an event_loop_blocked event plus lk.blocking.count/total_duration/max_duration; a stall before or after the session is a child of job_entrypoint at its real time. Without a job (the worker process) there is no trace to belong to: log and metric only.
  • The session's stall summary (lk.blocking.count/total_duration/max_duration, one event_loop_blocked event per stall) is updated for every stall, like the histogram, before the span (30/min) and log (5/min) rate limits apply.
  • The watchdog gap is race-proof: when a descheduled process resumes, the loop thread may tick before the watchdog has recorded its late wake-up, so the tick also looks at when the watchdog last ran at all; a late-wake record from an earlier stall is discarded rather than mislabelling the next one.
  • A stall the watchdog never observed running anything, with no CPU burned, is host-caused too: nothing blocked the loop, the scheduler or timer coalescing woke it late. A blocking wait in code releases the GIL and gets sampled; a GIL-holding call burns CPU; both stay warnings. If the recorded thread ident does not resolve, the sampler finds the loop thread through the blocked task's coroutine frame and remembers it.
  • A stall nests under the span the blocked task was in: the watchdog sample reads the blocked task's current span (Task.get_context(), Python 3.12+), so a time.sleep in an RPC handler shows under rpc_handler, a slow tool under function_tool, a slow hook under on_user_turn_completed. Older interpreters, and stalls with no sample, fall back to agent_session.
  • Sampled stacks cut through the job runner: everything up to and including the innermost 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_MS override; WARN_MS=0 disables.

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 ms time.sleep yields one back-dated error span whose stack names the blocking function and time.sleep, with low CPU time; a 70 ms block is a warning with status UNSET; an idle loop, executor / to_thread work, 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 to gc_time; worker mode logs without spans; set_report_context parents 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

@function_tool
async def slow_tool(self):
    time.sleep(0.3)  # -> event_loop_blocked span under the turn, warning in logs

Stacked on #7127.

🤖 Generated with Claude Code

@davidzhao
davidzhao force-pushed the dz/telemetry-loop-monitor branch from 53eb8f4 to 3cdd5fd Compare September 5, 2026 21:00
@davidzhao
davidzhao force-pushed the dz/telemetry-loop-monitor branch 2 times, most recently from c787ba9 to 3c539a7 Compare September 5, 2026 21:10
@davidzhao
davidzhao marked this pull request as ready for review September 6, 2026 01:11
@davidzhao
davidzhao requested a review from a team as a code owner September 6, 2026 01:11
devin-ai-integration[bot]

This comment was marked as resolved.

@theomonnom

Copy link
Copy Markdown
Member

Can this detect deadlock? or only short blocking code?

Since deadlock never reports back

Copy link
Copy Markdown
Member Author

this is only blocking code. deadlocks will require something more invasive.

IMO deadlocks are more likely in framework code. this is to flag blocking code in user's logic

Copy link
Copy Markdown
Member

Some customers were running HTTP requests using the requests library. Depending on the timeout, the job process was just getting killed by the worker.

Copy link
Copy Markdown
Member Author

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).

perhaps when we kill the process, we should log all of the pending frames.. that'll help flag deadlocks

theomonnom commented Sep 6, 2026

Copy link
Copy Markdown
Member

Fwiw if the goal is to detect blocking code and not deadlock. asyncio already has a slow code callback

Copy link
Copy Markdown
Member

I'm wondering if there's something to do with py3.14:

Python 3.14 adds external introspection (python -m asyncio ps PID and pstree), useful for a support engineer poking at a live process, not for automatic detection.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@davidzhao
davidzhao force-pushed the dz/telemetry-loop-monitor branch from d97378f to 33daed1 Compare September 6, 2026 08:24
devin-ai-integration[bot]

This comment was marked as resolved.

@davidzhao
davidzhao force-pushed the dz/telemetry-loop-monitor branch 2 times, most recently from 4ef3f25 to 37e2229 Compare September 6, 2026 17:13
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

Base automatically changed from dz/telemetry-session-options-describe to main September 7, 2026 05:05
@davidzhao
davidzhao force-pushed the dz/telemetry-loop-monitor branch 2 times, most recently from 0158582 to d4afa9b Compare September 7, 2026 05:48

@chenghao-mou chenghao-mou left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I notice that all the comments are unnecessarily verbose. It might be worth simplifying them before merging.

Comment thread livekit-agents/livekit/agents/telemetry/loop_monitor.py Outdated
Comment thread livekit-agents/livekit/agents/telemetry/loop_monitor.py Outdated
@davidzhao
davidzhao force-pushed the dz/telemetry-loop-monitor branch 2 times, most recently from 2f56f59 to 0394821 Compare September 8, 2026 04:39
devin-ai-integration[bot]

This comment was marked as resolved.

@davidzhao
davidzhao force-pushed the dz/telemetry-loop-monitor branch from cfd0c20 to 402a1c6 Compare September 8, 2026 07:30

@chenghao-mou chenghao-mou left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

davidzhao and others added 20 commits September 8, 2026 22:47
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>
@davidzhao
davidzhao force-pushed the dz/telemetry-loop-monitor branch 2 times, most recently from 613fcc9 to e8ec302 Compare September 9, 2026 05:55
The release carrying the RpcInterceptor hook the stack's RPC tracing installs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@davidzhao
davidzhao force-pushed the dz/telemetry-loop-monitor branch from e8ec302 to 64b0740 Compare September 9, 2026 05:56
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.

3 participants