-
Notifications
You must be signed in to change notification settings - Fork 6.2k
feat(observability): record event loop stalls in the server trace #13697
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
Merged
+248
−1
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
d429d1e
feat(observability): record event loop stalls in the server trace
t3dotgg 122b122
docs(observability): describe the stall window as since the previous …
t3dotgg 3539f06
refactor(observability): trim the event loop stall monitor
t3dotgg 8111bae
fix(observability): skip system sleep and startup in the stall monitor
t3dotgg 6cde623
docs(observability): note the stall monitor's known misses and false …
t3dotgg 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,77 @@ | ||
| import { assert, describe, it } from "@effect/vitest"; | ||
| import * as Effect from "effect/Effect"; | ||
| import * as Layer from "effect/Layer"; | ||
| import * as Tracer from "effect/Tracer"; | ||
| import * as TestClock from "effect/testing/TestClock"; | ||
|
|
||
| import { type EventLoopReadings, layerWith, stallMs } from "./EventLoopMonitor.ts"; | ||
|
|
||
| const ms = (value: number) => value * 1e6; | ||
|
|
||
| // Node reports a stall of S as a gap of up to S + 1 s, the histogram resolution. | ||
| const stalled: EventLoopReadings = { | ||
| delayMaxNs: ms(5_950), | ||
| activeMs: 6_200, | ||
| utilization: 0.176, | ||
| usage: { | ||
| userCPUTime: 310_400, | ||
| systemCPUTime: 95_600, | ||
| majorPageFault: 8_412, | ||
| minorPageFault: 20_031, | ||
| involuntaryContextSwitches: 57, | ||
| }, | ||
| rssBytes: 1536 * 1024 * 1024, | ||
| }; | ||
| // Over the threshold as read, but not once the resolution is subtracted. | ||
| const quiet: EventLoopReadings = { ...stalled, delayMaxNs: ms(2_950) }; | ||
|
|
||
| describe("EventLoopMonitor", () => { | ||
| it.effect("records a warning span only for samples that saw a stall", () => | ||
| Effect.gen(function* () { | ||
| const spans: Array<Tracer.NativeSpan> = []; | ||
| const tracer = Tracer.make({ | ||
| span: (options) => { | ||
| const span = new Tracer.NativeSpan(options); | ||
| spans.push(span); | ||
| return span; | ||
| }, | ||
| }); | ||
| // The first sample covers startup, so the monitor discards it. | ||
| const samples = [stalled, quiet, stalled]; | ||
|
|
||
| yield* Effect.gen(function* () { | ||
| yield* Layer.build(layerWith(Effect.succeed(Effect.sync(() => samples.shift() ?? quiet)))); | ||
| yield* TestClock.adjust("60 seconds"); | ||
| assert.lengthOf(spans, 0); | ||
| yield* TestClock.adjust("30 seconds"); | ||
| }).pipe(Effect.scoped, Effect.withTracer(tracer)); | ||
|
|
||
| assert.deepStrictEqual( | ||
| spans.map((span) => span.name), | ||
| ["server.eventLoop.stall"], | ||
| ); | ||
| const [span] = spans; | ||
| assert.deepStrictEqual(Object.fromEntries(span!.attributes), { | ||
| delayMaxMs: 4_950, | ||
| utilization: 0.18, | ||
| cpuUserMs: 310, | ||
| cpuSystemMs: 96, | ||
| majorPageFaults: 8_412, | ||
| minorPageFaults: 20_031, | ||
| involuntaryContextSwitches: 57, | ||
| rssMb: 1536, | ||
| }); | ||
| assert.deepStrictEqual( | ||
| span!.events.map(([name, , attributes]) => [name, attributes["effect.logLevel"]]), | ||
| [["event loop stalled for 4950 ms", "WARN"]], | ||
| ); | ||
| }), | ||
| ); | ||
|
|
||
| it("ignores delay the loop spent idle, such as a system sleep", () => { | ||
| // Waking from sleep reads as a long gap, but the loop was idle in poll for it. | ||
| const asleep: EventLoopReadings = { ...stalled, delayMaxNs: ms(600_000), activeMs: 900 }; | ||
| assert.isUndefined(stallMs(asleep)); | ||
| assert.strictEqual(stallMs({ ...asleep, activeMs: 600_000 }), 599_000); | ||
| }); | ||
| }); |
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,135 @@ | ||
| // @effect-diagnostics nodeBuiltinImport:off - only node:perf_hooks exposes the event loop delay histogram. | ||
| import * as NodePerfHooks from "node:perf_hooks"; | ||
|
|
||
| import * as Effect from "effect/Effect"; | ||
| import * as Layer from "effect/Layer"; | ||
| import type * as Scope from "effect/Scope"; | ||
|
|
||
| // Node's delay histogram wakes a native timer every RESOLUTION_MS and records the | ||
| // gap between wakeups, so an idle loop reads about RESOLUTION_MS and a stall of S | ||
| // reads between S and S + RESOLUTION_MS. We subtract the resolution, so a delay can | ||
| // undercount a stall by up to RESOLUTION_MS. With these values every stall over 3 s | ||
| // is caught, at 1 wakeup per second that never enters JS. | ||
| const RESOLUTION_MS = 1000; | ||
| const STALL_THRESHOLD_MS = 2000; | ||
| const SAMPLE_INTERVAL = "30 seconds"; | ||
|
|
||
| /** One sample interval as Node reports it. Delay in ns, active time in ms, CPU in µs. */ | ||
| export interface EventLoopReadings { | ||
| readonly delayMaxNs: number; | ||
| readonly activeMs: number; | ||
| readonly utilization: number; | ||
| readonly usage: Pick< | ||
| NodeJS.ResourceUsage, | ||
| | "userCPUTime" | ||
| | "systemCPUTime" | ||
| | "majorPageFault" | ||
| | "minorPageFault" | ||
| | "involuntaryContextSwitches" | ||
| >; | ||
| readonly rssBytes: number; | ||
| } | ||
|
|
||
| // Enables the delay histogram for the layer's lifetime. Each read returns the | ||
| // readings since the previous read and resets the histogram. Node skips the first | ||
| // gap after a reset, so a stall right at a sample boundary can be missed. | ||
| const makeNodeSampler = Effect.gen(function* () { | ||
| const histogram = yield* Effect.acquireRelease( | ||
| Effect.sync(() => { | ||
| const histogram = NodePerfHooks.monitorEventLoopDelay({ resolution: RESOLUTION_MS }); | ||
| histogram.enable(); | ||
| return histogram; | ||
| }), | ||
| (histogram) => Effect.sync(() => histogram.disable()), | ||
| ); | ||
| let elu = NodePerfHooks.performance.eventLoopUtilization(); | ||
| let usage = process.resourceUsage(); | ||
|
|
||
| // @effect-diagnostics-next-line returnEffectInGen:off - the read effect is the result. | ||
| return Effect.sync(() => { | ||
| const nextElu = NodePerfHooks.performance.eventLoopUtilization(); | ||
| const nextUsage = process.resourceUsage(); | ||
| const loop = NodePerfHooks.performance.eventLoopUtilization(nextElu, elu); | ||
| const readings: EventLoopReadings = { | ||
| delayMaxNs: histogram.max, | ||
| activeMs: loop.active, | ||
| utilization: loop.utilization, | ||
| usage: { | ||
| userCPUTime: nextUsage.userCPUTime - usage.userCPUTime, | ||
| systemCPUTime: nextUsage.systemCPUTime - usage.systemCPUTime, | ||
| majorPageFault: nextUsage.majorPageFault - usage.majorPageFault, | ||
| minorPageFault: nextUsage.minorPageFault - usage.minorPageFault, | ||
| involuntaryContextSwitches: | ||
| nextUsage.involuntaryContextSwitches - usage.involuntaryContextSwitches, | ||
| }, | ||
| rssBytes: process.memoryUsage.rss(), | ||
| }; | ||
| histogram.reset(); | ||
| elu = nextElu; | ||
| usage = nextUsage; | ||
| return readings; | ||
| }); | ||
| }); | ||
|
|
||
| /** | ||
| * Returns the stall to report for one sample in ms, or undefined when there was none. | ||
| */ | ||
| export const stallMs = ({ delayMaxNs, activeMs }: EventLoopReadings) => { | ||
| const delayMs = Math.round(delayMaxNs / 1e6) - RESOLUTION_MS; | ||
| // A stall is time the loop spent running code, so it counts as active time. libuv's | ||
| // clock keeps running while the system sleeps on macOS and Windows, so a sleep also | ||
| // reads as delay, but the loop spent it idle in poll. | ||
| if (delayMs <= STALL_THRESHOLD_MS || activeMs < delayMs) return undefined; | ||
| return delayMs; | ||
| }; | ||
|
|
||
| /** | ||
| * Samples event loop health every 30 s and records a `server.eventLoop.stall` span | ||
| * with a warning when the loop stalled for more than 2 s, so stalls land in | ||
| * the local trace file and Settings > Diagnostics without OTLP. Takes the sampler | ||
| * so tests can inject readings. | ||
| */ | ||
| export const layerWith = ( | ||
| makeSampler: Effect.Effect<Effect.Effect<EventLoopReadings>, never, Scope.Scope>, | ||
| ) => | ||
| Layer.effectDiscard( | ||
| Effect.gen(function* () { | ||
| const sample = yield* makeSampler; | ||
| const tick = Effect.gen(function* () { | ||
| const readings = yield* sample; | ||
| const delayMaxMs = stallMs(readings); | ||
| if (delayMaxMs === undefined) return; | ||
| const { utilization, usage, rssBytes } = readings; | ||
| // Root, as the stall has no caller to attach to. Warn level keeps it when | ||
| // T3CODE_TRACE_MIN_LEVEL is raised to cut trace noise. | ||
| yield* Effect.logWarning(`event loop stalled for ${delayMaxMs} ms`).pipe( | ||
| Effect.withSpan("server.eventLoop.stall", { | ||
| root: true, | ||
| level: "Warn", | ||
| attributes: { | ||
| delayMaxMs, | ||
| utilization: Math.round(utilization * 100) / 100, | ||
| cpuUserMs: Math.round(usage.userCPUTime / 1000), | ||
| cpuSystemMs: Math.round(usage.systemCPUTime / 1000), | ||
| majorPageFaults: usage.majorPageFault, | ||
| minorPageFaults: usage.minorPageFault, | ||
| involuntaryContextSwitches: usage.involuntaryContextSwitches, | ||
| rssMb: Math.round(rssBytes / 1024 / 1024), | ||
| }, | ||
| }), | ||
| ); | ||
| }); | ||
| const wait = Effect.sleep(SAMPLE_INTERVAL); | ||
| // The layer builds before the rest of the server, so the first sample covers | ||
| // startup work such as migrations and projection bootstrap. That can block the | ||
| // loop for seconds on a large database, so skip it rather than warn at every | ||
| // launch. Layers build outside any span, so this fiber retains no parent span. | ||
| yield* wait.pipe( | ||
| Effect.andThen(sample), | ||
| Effect.andThen(wait.pipe(Effect.andThen(tick), Effect.forever)), | ||
| Effect.forkScoped, | ||
| ); | ||
| }), | ||
| ); | ||
|
|
||
| export const layer = layerWith(makeNodeSampler); | ||
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
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.
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.
Uh oh!
There was an error while loading. Please reload this page.
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.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: pingdotgg/t3code
Length of output: 8190
🏁 Script executed:
Repository: pingdotgg/t3code
Length of output: 23340
Do not use interval-wide active time to classify one delay.
activeMscovers the full 30-second sample, butdelayMaxNsrecords only the longest individual delay. On macOS and Windows, system sleep can create that delay while the loop is idle. Independent work later in the interval can raiseactiveMsabovedelayMs, causingserver.eventLoop.stallto report sleep as a code stall.Use measurements correlated with the delayed wakeup. If that is not available, label this signal as an event-loop delay instead of a confirmed code stall. This is a minor diagnostic correctness issue, not a major availability issue.
🤖 Prompt for AI Agents
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.
Real, but rare: sleeps are usually minutes long, and a per-gap signal would need more machinery than it is worth. In 07ac9a5d1e the docs now say a short sleep in a busy window can still record a false stall, and that the span then shows CPU time far below delayMaxMs. The docs also say a stall that ends as a sample runs can be missed.
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.
🧩 Analysis chain
🏁 Script executed:
Length of output: 5785
@t3dotgg, correction: I verified commit07ac9a5d1eand the current documentation. The docs describe both the short-sleep false stall and the sample-boundary miss. My previous reply was wrong to say verification was unavailable.stallMsstill uses interval-wideactiveMs, so the original limitation remains. I understand your decision to document this rare case rather than add per-gap measurement. I’ll leave the finding open as a documented limitation, not a request for more machinery.✏️ Learnings added
🧠 Learnings used
You are interacting with an AI system.