Skip to content

feat(telemetry): nest event loop stalls under the blocked span and sample their stacks - #2541

Merged
davidzhao merged 14 commits into
dz/telemetry-trace-schemafrom
dz/telemetry-loop-stacks
Sep 26, 2026
Merged

davidzhao merged 14 commits into
dz/telemetry-trace-schemafrom
dz/telemetry-loop-stacks

Conversation

@davidzhao

@davidzhao davidzhao commented Sep 20, 2026 •

Copy link
Copy Markdown
Member

Stacked on #2501. Not a port: the Python monitor gets these from sys._current_frames() and the blocked task's context, which Node has no equivalent for; this layer reaches the same observable result with Node mechanisms.

Description

A cloud export from the JS build showed every event_loop_blocked span parented to agent_session with no stack, which made the stalls hard to act on. Two changes, both matching what the Python monitor reports:

A stall nests under the span that was blocked. Every framework span is created and ended on the main thread, so a span created before a stall and still open (or ended when the blocking call returned) was current during it. BlockedSpanTracker, a span processor installed wherever the framework installs its own processors, keeps creation and end times; the monitor parents a stall under the innermost such span, then the session root, then the job root. A slow tool lands under function_tool, a slow RPC under rpc_handler, a slow hook under on_user_turn_completed. Creation time is used rather than the span's start time, so a back-dated eou_wait cannot claim a stall that predates it.

lk.blocking.stack is populated. The watchdog worker thread attaches an inspector session to the main thread and, when the heartbeat is a warn threshold late, posts Debugger.pause: V8 honours it inside a spinning script, hands back full symbolized frames (optimized code included), and resumes about a millisecond later. A second sample at 10x the threshold, like Python's late sample. The format matches the Python monitor: # loop thread sampled Nms into the stall then the innermost 20 frames; Node internals dropped except innermost; frames outer than the framework's job runner cut. The warn log names the innermost frame of the agent's own code.

Sampling is adaptive by default: off until the process reports its first code-caused stall, then on for that process, so a healthy process never enables the debugger domain. LIVEKIT_AGENTS_LOOP_BLOCK_STACKS=always turns it on from the start, never off. Enabling the debugger domain costs one loop gap of about 90 ms with the framework loaded (script enumeration), once per process; in adaptive mode that gap is itself reported as a stall with the note that the sampler was starting, and in always mode it happens at monitor start, before any job work. Not enabled while an inspector is attached to the process. The worker process and an idle child sample too (third commit): their stalls only reach the log, and a log with a stack names the blocking code just as a span does, so no stall is reported with a note saying its process does not sample.

Rejected alternative, not what ships: V8's sampling profiler was tried first. On Node 24 only the first profile of a process names code optimized before it started, reading its samples requires restarting the profile, and each restart blocked the loop for 25 to 40 ms with the framework loaded. Those figures describe the profiler, not the Debugger.pause mechanism above.

Measured overhead of the shipped mechanism with the framework loaded (ten interleaved rounds, medians): with the debugger domain enabled a tight compute loop is 1.1% slower and a mixed JSON/regex/promise workload 0.6% slower; idle CPU with the 20 ms heartbeat is unchanged (0.33% vs 0.31% of a core); a pause sample costs about 1 ms and only while a stall is under way. In adaptive mode none of this is paid until the process's first code-caused stall.

Limits. A native call that does not check for interrupts (sync file or child-process calls, native addons) keeps the pause waiting until it returns; the sample then shows the caller still in the frame that made the call, which is what the Python watchdog sees once a native call releases the GIL. lk.blocking.task stays unset: Node has no task names.

Verified live

Hotel receptionist example with a test RPC that spins for 600 ms, called twice from a client: both rpc_handler spans under agent_session, both stalls under their rpc_handler, the first without a stack (adaptive), the second sampled 121 ms into the stall naming the handler, the SDK's invokeRpcHandler, and the interceptor's interceptIncoming.

Testing

  • telemetry/loop_monitor_stacks.test.ts (13 tests): parenting under an open span, fallbacks, tracker retention, adaptive / always / never, the formatted stack from a real blocked call, restart bounds, env parsing. The existing 28 monitor tests unchanged.
  • Full agents suite green; build, typecheck, lint, API report updated.

API report noise (second commit). Exports in @livekit/agents are not release-tagged, so API Extractor wrote an ae-missing-release-tag warning for every one of them into agents.api.md (854 by this layer), burying the real changes in each review. agents/api-extractor.json now silences that one message for this package and the report is regenerated: the diff is only the removed warning lines. Other warning kinds (ae-forgotten-export, ae-unresolved-link, ...) are untouched.

Review follow-ups (fourth commit)

  • Parenting: when two in-flight operations of the same kind qualify for a stall (two function_tools of one turn, two rpc_handlers), the tracker no longer picks the newer one, which timing cannot justify: the stall lands on their nearest common ancestor among the open spans, or on the session/job root when there is none. Operations of different kinds (a user_turn open while an rpc_handler runs) still resolve to the newest. An exact answer for sampled stalls is possible as a follow-up: evaluate the active span on the paused main thread during the sample, which reads the blocked operation's async context the way Python reads the blocked task's context variables.
  • Sample offsets are measured from the stall's start as the report dates it; the header used to overstate by one heartbeat interval.
  • stacks: always|adaptive with watchdog: false is normalized to never (the sampler runs on the watchdog thread), documented, so such a configuration does not note on every stall that sampling starts after the first one.
  • An inspector attached after sampling started stops it: the heartbeat checks the inspector URL each tick and has the watchdog disable the debugger domain and disconnect, so a debugger's breakpoints are never resumed by the sampler. A debugger; statement in user code is still resumed while no inspector is attached: it only pauses at all because the sampler enabled the domain. Later stalls note that an inspector is attached. Tested against a real inspector.open().

🤖 Generated with Claude Code

@davidzhao
davidzhao added this pull request to stack #2502 September 20, 2026 05:43
@changeset-bot

changeset-bot Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f6d6d6a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 39 packages
Name Type
@livekit/agents Patch
@livekit/agents-plugin-anam Patch
@livekit/agents-plugin-anthropic Patch
@livekit/agents-plugin-assemblyai Patch
@livekit/agents-plugin-azure Patch
@livekit/agents-plugin-baseten Patch
@livekit/agents-plugin-bey Patch
@livekit/agents-plugin-cartesia Patch
@livekit/agents-plugin-cerebras Patch
@livekit/agents-plugin-deepgram Patch
@livekit/agents-plugin-did Patch
@livekit/agents-plugin-elevenlabs Patch
@livekit/agents-plugin-fishaudio Patch
@livekit/agents-plugin-google Patch
@livekit/agents-plugin-hume Patch
@livekit/agents-plugin-inworld Patch
@livekit/agents-plugin-krisp Patch
@livekit/agents-plugin-lemonslice Patch
@livekit/agents-plugin-liveavatar Patch
@livekit/agents-plugin-livekit Patch
@livekit/agents-plugin-meta Patch
@livekit/agents-plugin-minimax Patch
@livekit/agents-plugin-mistral Patch
@livekit/agents-plugin-mistralai Patch
@livekit/agents-plugin-neuphonic Patch
@livekit/agents-plugin-openai Patch
@livekit/agents-plugin-perplexity Patch
@livekit/agents-plugin-phonic Patch
@livekit/agents-plugin-protoface Patch
@livekit/agents-plugin-resemble Patch
@livekit/agents-plugin-rime Patch
@livekit/agents-plugin-runway Patch
@livekit/agents-plugin-sarvam Patch
@livekit/agents-plugin-silero Patch
@livekit/agents-plugin-soniox Patch
@livekit/agents-plugin-tavus Patch
@livekit/agents-plugins-test Patch
@livekit/agents-plugin-trugen Patch
@livekit/agents-plugin-xai Patch

Not sure what this means? Click here to learn what changesets are.

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

@davidzhao
davidzhao force-pushed the dz/telemetry-loop-stacks branch from e7ad595 to 7a62e5d Compare September 20, 2026 05:58
@davidzhao
davidzhao force-pushed the dz/telemetry-loop-stacks branch 2 times, most recently from 174b98e to d743e9c Compare September 20, 2026 07:14
@davidzhao
davidzhao force-pushed the dz/telemetry-loop-stacks branch from d743e9c to bfc5828 Compare September 20, 2026 07:15
@davidzhao
davidzhao force-pushed the dz/telemetry-loop-stacks branch from bfc5828 to c67fc87 Compare September 20, 2026 07:21
@davidzhao
davidzhao force-pushed the dz/telemetry-loop-stacks branch from 99f381d to 2c7ad49 Compare September 20, 2026 17:17
@davidzhao
davidzhao force-pushed the dz/telemetry-loop-stacks branch from 2c7ad49 to 2767781 Compare September 20, 2026 17:43
@davidzhao
davidzhao force-pushed the dz/telemetry-loop-stacks branch 2 times, most recently from 12c06b9 to d8bfa3f Compare September 20, 2026 23:39
@davidzhao
davidzhao force-pushed the dz/telemetry-loop-stacks branch from bbdbc0d to ca21e29 Compare September 21, 2026 03:28
@davidzhao
davidzhao marked this pull request as ready for review September 21, 2026 04:42
@davidzhao
davidzhao requested a review from a team as a code owner September 21, 2026 04:42
devin-ai-integration[bot]

This comment was marked as resolved.

@davidzhao
davidzhao force-pushed the dz/telemetry-loop-stacks branch from ca21e29 to ed17ea1 Compare September 21, 2026 05:05
@davidzhao
davidzhao force-pushed the dz/telemetry-loop-stacks branch from ed17ea1 to 78051b0 Compare September 21, 2026 05:18
@davidzhao
davidzhao force-pushed the dz/telemetry-loop-stacks branch from c6fedfb to d4c0833 Compare September 21, 2026 05:37
const sameKind = leaves.filter(
([, entry]) => spanName(entry.span) === spanName(newest[1].span),
);
if (sameKind.length <= 1) return newest[1].span;

@chenghao-mou chenghao-mou Sep 23, 2026 •

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.

from Codex:

Differently named operations have the same ambiguity. A tool can await I/O, a newer RPC can start and await I/O, then the tool can resume and block. Both spans remain open, but this selects the idle RPC.

Should we use the common ancestor across all overlapping leaves, regardless of span name?

const consider = (entry: { span: Span; createdAt: number }) => {
if (entry.createdAt > opened) return;
if (exclude.has(spanName(entry.span))) return;
candidates.set(entry.span.spanContext().spanId, entry);

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.

also from Codex:

Can we filter candidates by the session/job context’s trace ID before selecting a parent? This processor receives spans from the entire provider. A newer background HTTP span from another trace can win here, moving event_loop_blocked out of the agent’s trace.

Comment thread agents/src/telemetry/loop_stack_sampler.ts Outdated

@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

Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.

Devin Review found 1 new potential issue.

4 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

slack = 2,
): Span | undefined {
const opened = startedAt + slack;
const closed = Math.min(endedAt - slack, startedAt + slack);

@devin-ai-integration devin-ai-integration Bot Sep 25, 2026 •

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.

🟡 Earlier operation claims later stall

When operations run consecutively without a heartbeat, blockedSpan can select an earlier operation that already ended. Its span survives the early cutoff and outranks an older span still doing the long block, misparenting the stall.

Learn more

The monitor dates a stall from the first missed heartbeat, not from the exact start of the synchronous call. This cutoff admits every span that ends after that early point, even when another operation subsequently keeps the loop blocked for most of the report. An ended span with a later creation time can then beat the operation still running when the heartbeat finally fires. The report builder derives that approximate start from the timer lag, so it cannot distinguish consecutive blocking operations by their start times alone.

Example: A turn span starts at 0 ms. An RPC span starts at 15 ms and ends at 50 ms; the turn then blocks until 700 ms without letting the 20 ms heartbeat run. The report starts near 20 ms. Both spans qualify at the 40 ms cutoff, so the RPC claims the entire 680 ms stall even though the turn caused nearly all of it.

Recommended fix: Avoid attributing an entire late heartbeat to a span that only survived the beginning of its interval when another eligible span continued through its end. Preserve the ended-RPC case where it is the only credible blocker; add a consecutive-operations regression test.

Devin Review


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

@davidzhao
davidzhao force-pushed the dz/telemetry-loop-stacks branch from aa86bbb to 8964dfb Compare September 26, 2026 01:56

@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

Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.

Devin Review found 3 new potential issues.

4 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

Comment thread agents/src/telemetry/loop_stack_sampler.ts Outdated
Comment thread agents/src/telemetry/loop_stack_sampler.ts Outdated
Comment on lines +155 to +159
while (
this.#ended.length &&
(this.#ended[0]!.endedAt < cutoff || this.#ended.length > this.#maxEnded)
) {
this.#ended.shift();

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.

🟡 Busy turn drops the blocking parent

If a blocking tool ends before 256 newer spans in one turn, #prune evicts its span before the late heartbeat. The stall loses its tool parent and falls back to an ancestor.

Learn more

The tracker retains ended spans because the monitor emits a stall one heartbeat after the blocking call returns. Each onEnd appends to the queue and enforces a fixed count, regardless of whether the heartbeat has reported a pending stall. Synchronous work or microtasks can end many spans before the next timer turn. When that happens, the blocking span is evicted before blockedContext looks it up.

Example: A tool blocks for 200 ms and ends. Its callback then ends 256 short spans before yielding to timers. The pending stall is eventually emitted under the agent turn instead of the blocking tool.

Recommended fix: Keep ended spans long enough for the monitor's pending stall to be reported, or snapshot the eligible parent at the late heartbeat before count-based eviction can discard it. Keep a bounded policy for spans not needed by pending reports.

Devin Review


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

@davidzhao
davidzhao force-pushed the dz/telemetry-loop-stacks branch from 8964dfb to 3c55365 Compare September 26, 2026 05:02
@davidzhao
davidzhao force-pushed the dz/telemetry-loop-stacks branch from 3c55365 to 5178bf1 Compare September 26, 2026 05:26
@davidzhao
davidzhao force-pushed the dz/telemetry-loop-stacks branch from 5178bf1 to e7efc1c Compare September 26, 2026 05:40
davidzhao and others added 14 commits September 25, 2026 22:52
…mple their stacks

The Python monitor parents a stall under the span the blocked task was in
and attaches lk.blocking.stack from its watchdog thread's samples of the
loop thread. Node has no cross-thread stack read; this layer gets the same
two outcomes by other means.

Parent: BlockedSpanTracker, a span processor installed on every provider
the framework owns (and on a user's provider through the same registrar
as PII redaction), keeps the spans created and ended on the main thread
with wall-clock creation and end times. A span created before a stall's
window opened and still open, or ended no earlier than the window closed,
was current during the stall; the innermost such span (created last) is
the stall's parent, then the session root, then the job root. Creation
time rather than the span's own start time, so a back-dated span
(eou_wait) cannot claim an earlier stall; one heartbeat of slack on both
ends, since the block's start is known to within a tick and its end is
the late heartbeat's run.

Stack: the existing watchdog worker thread attaches an inspector session
to the main thread (Session.connectToMainThread) with the debugger domain
enabled. It measures the heartbeat's lag from a shared slot the monitor
writes each tick; at the warn threshold it posts Debugger.pause, receives
the main thread's call frames (function names, files, lines, optimized
code included) and resumes about a millisecond later, and looks once more
at LATE_SAMPLE_FACTOR (10x) the threshold. The report formats each sample
like the Python monitor's: a header with the offset into the stall, then
the innermost 20 frames innermost first, Node's internals and everything
outer than the framework's job runner cut. The warn log names the
innermost frame of the agent's own code. A native call that does not
check for interrupts (sync child process, sync fs) keeps the pause
waiting until it returns, and the sample then shows the caller's frame,
as the Python watchdog sees a native call once it releases the GIL; a
pause that lands after the loop moved on is discarded with a note. Frame
URLs missing from the pause (vm scripts) are resolved from the debugger's
scriptParsed events.

Gating: adaptive by default, sampling starts after a process's first
code-caused stall; LIVEKIT_AGENTS_LOOP_BLOCK_STACKS=1|always from the
start, 0|never not at all. Job processes only (the worker never samples),
and never while an inspector is attached to the process, whose session
our pauses would land in.

Cost, measured on this machine with the framework loaded: enabling the
debugger domain enumerates the loaded scripts on the loop once (about
90 ms in a process with @livekit/agents loaded; in adaptive mode that is
a stall of its own, reported with a note saying so); with it enabled a
mixed workload (object churn, JSON, numeric loops) runs 1.2% slower
(1205/1222/1198 vs 1193/1165/1223 units/s interleaved) and idle CPU is
unchanged; a pause costs about 1 ms when the loop is blocked, nothing
otherwise.

V8's sampling profiler was tried first and rejected: on Node 24 only the
first profile of a process names code optimized before it started (every
later profile attributes a hot function's samples to its caller), reading
samples means restarting the profile, and each Profiler.start blocks the
loop for 25 to 40 ms with the framework loaded.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Exports in @livekit/agents are not release-tagged, so API Extractor wrote a warning for every one of them into agents.api.md, burying the real changes in each review. Silence that one message for this package and regenerate the report.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Adaptive sampling turned on only for stalls that could carry a span, so the worker process and an idle child reported every stall with a note saying they do not sample. A log with a stack names the blocking code just as a span does, and the Python monitor samples in every process: sampling now starts after the first code stall wherever the monitor runs, and the note is gone.

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

- The blocked-span tracker no longer guesses between two in-flight operations of the same kind (two tools, two RPC handlers): timing cannot say which one blocked, so the stall lands on their nearest common ancestor, or nowhere, instead of on the newer one. Operations of different kinds still resolve to the newest.
- A stack sample's offset is measured from the stall's start as the report dates it (the tick that was due), not from the last on-time tick: every header overstated it by one heartbeat interval.
- `stacks` other than `never` with `watchdog: false` is normalized to `never`, since the sampler runs on the watchdog thread; the option docs say so and no longer claim the worker process is excluded.
- An inspector that attaches after sampling started stops it: the heartbeat checks the inspector URL and has the watchdog disable the debugger domain and disconnect, so a debugger's breakpoints are never resumed by the sampler. Later stalls note that an inspector is attached.

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

On a loaded host (CI runs every agents test file at once) the block's stall can be preceded by stalls of the host's own, and the watchdog's pause can land in one of those. The stack tests now look for the report whose sampled stack names the blocking function and retry the block a few times, rather than trusting the first report after it.

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

- BlockedSpanTracker confines candidates to the trace of the fallback
  context, so a span of an unrelated trace open during a stall (an
  application's own HTTP request) cannot become the stall's parent
- the watchdog's Debugger.paused handler no longer resumes a pause that a
  real debugger took before the monitor's next heartbeat noticed it: it
  disables sampling instead

The newest-of-different-kinds rule is kept, with the trade-off documented:
the common-ancestor alternative would file a stall during any wait (an
audio track wait, a user turn) under the session, undoing the blocking-RPC
attribution of #2575.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…heck first; eviction guard

- checkStall captures the incident's window before awaiting the pause, so
  a sample never reads an incident cleared meanwhile
- Debugger.paused hands any pause to an attached inspector before treating
  it as the sample we asked for: resuming could take a breakpoint from
  under the operator
- BlockedSpanTracker's count bound never evicts a span that ended within
  the last second, so a burst of spans ending before the loop yields cannot
  drop the blocking span the late heartbeat reports under

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@davidzhao
davidzhao force-pushed the dz/telemetry-loop-stacks branch from e7efc1c to f6d6d6a Compare September 26, 2026 05:54

@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 1 new potential issue.

⚠️ 1 issue in files not directly in the diff

⚠️ Recovered primary mislabels fallback recognition

If the primary recovers during fallback recognize(), model and provider report the primary instead of the recognizing child. The live sttIdentity callback then credits the fallback transcript to the wrong provider.

4 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

@davidzhao
davidzhao merged commit 646f0bc into main Sep 26, 2026
12 of 13 checks passed
@davidzhao
davidzhao deleted the dz/telemetry-loop-stacks branch September 26, 2026 06:04
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