-
Notifications
You must be signed in to change notification settings - Fork 370
feat(telemetry): nest event loop stalls under the blocked span and sample their stacks #2541
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
69a5bc9
feat(telemetry): nest event loop stalls under the blocked span and sa…
davidzhao 17d51f4
chore(agents): drop the missing-release-tag noise from the API report
davidzhao 8780988
feat(telemetry): sample loop stall stacks in the worker process too
davidzhao 44aaa61
fix(telemetry): loop stall parenting, sampling contract and inspector…
davidzhao 776c050
tests(telemetry): find the sampled stall by its stack instead of by p…
davidzhao File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@livekit/agents': patch | ||
| --- | ||
|
|
||
| Event loop stalls now nest under the span that was running when the loop blocked (`function_tool`, `rpc_handler`, `on_user_turn_completed`, ...) and carry `lk.blocking.stack`, the loop thread's call stack sampled by the monitor's watchdog thread through an inspector session (at the warn threshold and again at ten times it). Sampling starts after a process's first stall, or from the start with `LIVEKIT_AGENTS_LOOP_BLOCK_STACKS=1`, never with `0`; it costs a one-time enumeration of the loaded scripts on the loop when it starts and about 1% of throughput afterwards. Not enabled while an inspector is attached to the process. |
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
Large diffs are not rendered by default.
Oops, something went wrong.
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,159 @@ | ||
| // SPDX-FileCopyrightText: 2026 LiveKit, Inc. | ||
| // | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| import { type Context, type Span, trace } from '@opentelemetry/api'; | ||
| import type { ReadableSpan, SpanProcessor } from '@opentelemetry/sdk-trace-base'; | ||
|
|
||
| /** | ||
| * Which span was running when the event loop stalled, without sampling a stack. | ||
| * | ||
| * Every framework span is created and ended on the main thread, so a span created before a | ||
| * stall and ended after it (or at its end, when the blocking call returned) was current while | ||
| * the loop was blocked. The innermost such span, the one created last, is where the stall hurt: | ||
| * `function_tool` for a slow tool, `rpc_handler` for a slow RPC, `on_user_turn_completed` for a | ||
| * slow hook. The Python monitor reads the same span off the blocked task's context; Node has no | ||
| * cross-thread view of a task's context, so the span processor keeps the bookkeeping instead. | ||
| * | ||
| * Creation and end are timed by the wall clock at the processor call, not by the span's own | ||
| * timestamps: a span back-dated at creation (`eou_wait` starts at the user's last speech) must | ||
| * not claim a stall that predates it. | ||
| */ | ||
| export class BlockedSpanTracker implements SpanProcessor { | ||
| /** Spans not yet ended, by span id. */ | ||
| readonly #open = new Map<string, { span: Span; createdAt: number }>(); | ||
| /** Spans ended recently: a stall's report runs a heartbeat after the blocking call returned. */ | ||
| readonly #ended: { span: Span; createdAt: number; endedAt: number }[] = []; | ||
| readonly #retention: number; | ||
| readonly #maxEnded: number; | ||
|
|
||
| constructor(options: { retention?: number; maxEnded?: number } = {}) { | ||
| this.#retention = options.retention ?? 5_000; | ||
| this.#maxEnded = options.maxEnded ?? 256; | ||
| } | ||
|
|
||
| onStart(span: Span): void { | ||
| this.#open.set(span.spanContext().spanId, { span, createdAt: Date.now() }); | ||
| } | ||
|
|
||
| onEnd(span: ReadableSpan): void { | ||
| const id = span.spanContext().spanId; | ||
| const entry = this.#open.get(id); | ||
| if (!entry) return; | ||
| this.#open.delete(id); | ||
| const now = Date.now(); | ||
| this.#ended.push({ span: entry.span, createdAt: entry.createdAt, endedAt: now }); | ||
| this.#prune(now); | ||
| } | ||
|
|
||
| async forceFlush(): Promise<void> {} | ||
|
|
||
| async shutdown(): Promise<void> { | ||
| this.#open.clear(); | ||
| this.#ended.length = 0; | ||
| } | ||
|
|
||
| /** | ||
| * The span that was current across `[startedAt, endedAt]` (epoch ms): created before the | ||
| * window opened and still open, or ended no earlier than the window closed. Spans of the given | ||
| * names are skipped (the stall span itself). `slack` widens both ends: the block's start is | ||
| * known to within a heartbeat, and its end is the late heartbeat's run, a little after the | ||
| * blocking call returned and the span it was in ended. | ||
| * | ||
| * Among the spans that qualify, the innermost of one ancestry is the answer. When two | ||
| * operations of the same kind were both in flight (two tools, two RPC handlers), timing alone | ||
| * cannot say which one blocked: the answer is then their nearest common ancestor, or nothing, | ||
| * rather than a guess at one of them. Operations of different kinds overlap all the time (a | ||
| * user turn is open while an RPC handler runs) and the newest of them is the one that blocked. | ||
| */ | ||
| blockedSpan( | ||
| startedAt: number, | ||
| endedAt: number, | ||
| exclude: ReadonlySet<string>, | ||
| slack = 2, | ||
| ): Span | undefined { | ||
| const opened = startedAt + slack; | ||
| const closed = endedAt - slack; | ||
| const candidates = new Map<string, { span: Span; createdAt: number }>(); | ||
| 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); | ||
|
davidzhao marked this conversation as resolved.
|
||
| }; | ||
| for (const entry of this.#open.values()) consider(entry); | ||
| for (const entry of this.#ended) { | ||
| if (entry.endedAt >= closed) consider(entry); | ||
| } | ||
| if (!candidates.size) return undefined; | ||
|
|
||
| // the leaves: candidates no other candidate descends from | ||
| const hasChild = new Set<string>(); | ||
| for (const entry of candidates.values()) { | ||
| let parent = parentSpanId(entry.span); | ||
| while (parent !== undefined && candidates.has(parent) && !hasChild.has(parent)) { | ||
| hasChild.add(parent); | ||
| parent = parentSpanId(candidates.get(parent)!.span); | ||
| } | ||
| } | ||
| const leaves = [...candidates.entries()].filter(([id]) => !hasChild.has(id)); | ||
| // ties (same millisecond) go to the later-created span, which the maps yield last | ||
| let newest = leaves[0]!; | ||
| for (const leaf of leaves) if (leaf[1].createdAt >= newest[1].createdAt) newest = leaf; | ||
| const sameKind = leaves.filter( | ||
| ([, entry]) => spanName(entry.span) === spanName(newest[1].span), | ||
| ); | ||
| if (sameKind.length <= 1) return newest[1].span; | ||
|
|
||
| // indistinguishable: the nearest ancestor (among the candidates) common to all of them | ||
| const chains = sameKind.map(([id]) => { | ||
| const chain: string[] = []; | ||
| let current: string | undefined = id; | ||
| while (current !== undefined && candidates.has(current)) { | ||
| chain.push(current); | ||
| current = parentSpanId(candidates.get(current)!.span); | ||
| } | ||
| return chain; | ||
| }); | ||
| const shared = chains[0]!.find((id) => chains.every((chain) => chain.includes(id))); | ||
| if (shared === undefined) return undefined; | ||
| // the common ancestor is the deepest one of that kind that is not itself ambiguous | ||
| return candidates.get(shared)!.span; | ||
| } | ||
|
|
||
| /** The context carrying {@link blockedSpan}, or undefined. */ | ||
| blockedContext( | ||
| startedAt: number, | ||
| endedAt: number, | ||
| exclude: ReadonlySet<string>, | ||
| base: Context, | ||
| slack = 2, | ||
| ): Context | undefined { | ||
| const span = this.blockedSpan(startedAt, endedAt, exclude, slack); | ||
| return span ? trace.setSpan(base, span) : undefined; | ||
| } | ||
|
|
||
| /** @internal test hook */ | ||
| get openCount(): number { | ||
| return this.#open.size; | ||
| } | ||
|
|
||
| #prune(now: number): void { | ||
| const cutoff = now - this.#retention; | ||
| while ( | ||
| this.#ended.length && | ||
| (this.#ended[0]!.endedAt < cutoff || this.#ended.length > this.#maxEnded) | ||
| ) { | ||
| this.#ended.shift(); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| function spanName(span: Span): string { | ||
| return (span as { name?: string }).name ?? ''; | ||
| } | ||
|
|
||
| function parentSpanId(span: Span): string | undefined { | ||
| return (span as { parentSpanContext?: { spanId: string } }).parentSpanContext?.spanId; | ||
| } | ||
|
|
||
| /** The one tracker the loop monitor consults; installed on every provider the framework owns. */ | ||
| export const blockedSpanTracker = new BlockedSpanTracker(); | ||
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
Oops, something went wrong.
Oops, something went wrong.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Unrelated spans capture stall parenting
With another provider span open during a job stall,
blockedSpancan select it as the parent. Candidates are never restricted to the fallback job trace. The stall enters an unrelated trace instead of the job timeline.Learn more
A span processor receives every recording span created by its provider, not only LiveKit framework spans. Custom providers can therefore contribute application or auto-instrumentation spans from unrelated traces.
blockedContextreceives a base context carrying the current session or job root, butblockedSpandoes not use that trace identity when building candidates. A newer unrelated leaf can become the parent, andtrace.setSpanthen replaces the base span with that unrelated span.Example: A background HTTP request opens span
http.requestin trace A while job trace B runs a blocking tool. Sincehttp.requestwas created later and overlaps the stall window, the emittedevent_loop_blockedspan becomes its child in trace A. It was expected under the blocked tool or the session root in trace B.Recommended fix: Pass the fallback span context's trace ID into
blockedSpanand discard candidates whosespanContext().traceIddiffers. Apply the filter before leaf and ambiguity selection so unrelated spans cannot suppress a valid same-trace candidate.Was this helpful? React with 👍 or 👎 to provide feedback.