Skip to content

Commit 0847b21

Browse files
authored
feat(monitoring): log event-loop lag, stalls, and in-flight route handlers (#9312)
- EventLoopTelemetry INFO 'Event loop snapshot' every 60s (lag p50/p99/max/mean, stalls, cpuPct, inFlight, inFlightPeak, windowMs; elu only where real, inFlightDropped when non-zero) and WARN 'Event loop stalled' when a 250ms probe fires >=1s late (max 10 per window) with inFlightDuringStall as concurrency context - withRouteHandler registers each handler in an in-flight registry kept on globalThis so instrumentation and route module instances share it; entries keyed by per-call token, capped at 4,096 with oldest-first eviction - Stall window starts at the probe's due time with a 50ms edge tolerance at both ends - TSDoc documents Bun vs Node lag semantics, the ~1.0-1.25s stall floor, and what is not attributed (streamed bodies, page renders, background loops)
1 parent 8972b6b commit 0847b21

5 files changed

Lines changed: 394 additions & 1 deletion

File tree

‎apps/sim/instrumentation-node.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,9 @@ export async function register() {
403403
const { startMemoryTelemetry } = await import('./lib/monitoring/memory-telemetry')
404404
startMemoryTelemetry()
405405

406+
const { startEventLoopTelemetry } = await import('@/lib/monitoring/event-loop-telemetry')
407+
startEventLoopTelemetry()
408+
406409
// Not awaited: the connection is warmed in the background so the first request
407410
// that needs Redis does not pay the handshake inside its own command deadline,
408411
// but boot never waits on Redis to serve requests that do not touch it.

‎apps/sim/lib/core/utils/with-route-handler.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { FILE_DELIVERY_CSP_PATH_PATTERN, getMainCSPPolicy } from '@/lib/core/sec
99
import { HttpError } from '@/lib/core/utils/http-error'
1010
import { generateRequestId } from '@/lib/core/utils/request'
1111
import { MAX_CALL_CHAIN_DEPTH, parseCallChain, SIM_VIA_HEADER } from '@/lib/execution/call-chain'
12+
import { trackInFlightRequest } from '@/lib/monitoring/in-flight-requests'
1213
import { withPermissionGroupScope } from '@/lib/permission-groups/request-scope.server'
1314

1415
const fileDeliveryPath = new RegExp(`^${FILE_DELIVERY_CSP_PATH_PATTERN}$`)
@@ -178,6 +179,7 @@ export function withRouteHandler<T>(
178179
callChain: callChainFor(request),
179180
}
180181

182+
const settleInFlight = trackInFlightRequest(requestId, method, path)
181183
return runWithRequestContext(requestContext, async () => {
182184
let response: NextResponse | Response
183185
try {
@@ -242,6 +244,6 @@ export function withRouteHandler<T>(
242244

243245
applyResponseHeaders(response, request, requestId)
244246
return response
245-
})
247+
}).finally(settleInFlight)
246248
}
247249
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/**
2+
* Event-loop health telemetry. One event loop serves every request in the
3+
* process, so a synchronous CPU burst in one request stalls all of them; this
4+
* makes that visible from the process itself.
5+
*
6+
* - Every {@link SNAPSHOT_INTERVAL_MS}, one INFO `Event loop snapshot` line with
7+
* delay percentiles from `monitorEventLoopDelay`, process CPU share, and the
8+
* in-flight route handler count.
9+
* - Whenever a {@link STALL_PROBE_MS} probe timer fires at least
10+
* {@link STALL_WARN_MS} late, one WARN `Event loop stalled` line (at most
11+
* {@link MAX_STALL_WARNINGS_PER_SNAPSHOT} per window) listing the route
12+
* handlers in flight during the stall as concurrency context; see
13+
* `in-flight-requests.ts` for what that list cannot attribute.
14+
*
15+
* Both lines are emitted after the loop recovers, since nothing on a blocked
16+
* loop can run; a stall that never ends shows up as a missing snapshot instead.
17+
*
18+
* Reading the numbers:
19+
* - On Bun the delay histogram records only the excess delay beyond the
20+
* sampling timer, so an idle loop reads near 0 ms. On Node each sample
21+
* includes the {@link HISTOGRAM_RESOLUTION_MS} resolution, so an idle loop
22+
* reads about 20 ms. Compare values only within one runtime.
23+
* - A block is counted as a stall only once the probe is a full
24+
* {@link STALL_WARN_MS} late, which takes a block of 1.0–1.25 s depending on
25+
* where in the probe interval it starts. Shorter blocks show only in
26+
* `lagMaxMs`.
27+
* - `performance.eventLoopUtilization()` is a stub of zeros on Bun, so `elu` is
28+
* emitted only where it reports real values. `cpuPct` (process CPU share,
29+
* which also counts GC and other threads) is the signal on both runtimes.
30+
*/
31+
32+
import { monitorEventLoopDelay, performance } from 'node:perf_hooks'
33+
import { createLogger } from '@sim/logger'
34+
import {
35+
findRequestsSpanningStall,
36+
getInFlightCount,
37+
takeInFlightWindow,
38+
} from '@/lib/monitoring/in-flight-requests'
39+
40+
const logger = createLogger('EventLoopTelemetry', { logLevel: 'INFO' })
41+
42+
const SNAPSHOT_INTERVAL_MS = 60_000
43+
const HISTOGRAM_RESOLUTION_MS = 20
44+
const STALL_PROBE_MS = 250
45+
const STALL_WARN_MS = 1_000
46+
/** Caps WARN volume when the loop is stalling continuously; the snapshot still counts every stall. */
47+
const MAX_STALL_WARNINGS_PER_SNAPSHOT = 10
48+
49+
const NS_PER_MS = 1e6
50+
51+
/** On `globalThis` so a second module instance (dev reloads, a second bundle graph) cannot start a second set of timers. */
52+
const g = globalThis as typeof globalThis & { _simEventLoopTelemetryStarted?: boolean }
53+
54+
const round = (value: number, digits = 1) => {
55+
const factor = 10 ** digits
56+
return Math.round(value * factor) / factor
57+
}
58+
59+
/**
60+
* Starts the snapshot and stall-probe timers once per process. Both timers are
61+
* unref'd, so they never keep the process alive. Called from instrumentation
62+
* at server start.
63+
*/
64+
export function startEventLoopTelemetry() {
65+
if (g._simEventLoopTelemetryStarted) return
66+
g._simEventLoopTelemetryStarted = true
67+
68+
const histogram = monitorEventLoopDelay({ resolution: HISTOGRAM_RESOLUTION_MS })
69+
histogram.enable()
70+
71+
let stallCount = 0
72+
let stallMaxMs = 0
73+
let stallTotalMs = 0
74+
let warningsThisWindow = 0
75+
76+
let lastProbe = performance.now()
77+
const probe = setInterval(() => {
78+
const now = performance.now()
79+
const lateMs = now - lastProbe - STALL_PROBE_MS
80+
lastProbe = now
81+
if (lateMs < STALL_WARN_MS) return
82+
83+
stallCount++
84+
stallTotalMs += lateMs
85+
if (lateMs > stallMaxMs) stallMaxMs = lateMs
86+
if (warningsThisWindow >= MAX_STALL_WARNINGS_PER_SNAPSHOT) return
87+
warningsThisWindow++
88+
89+
logger.warn('Event loop stalled', {
90+
stallMs: Math.round(lateMs),
91+
inFlight: getInFlightCount(),
92+
inFlightDuringStall: findRequestsSpanningStall(now - lateMs, now),
93+
})
94+
}, STALL_PROBE_MS)
95+
probe.unref()
96+
97+
let lastWall = performance.now()
98+
let lastCpu = process.cpuUsage()
99+
let lastElu = performance.eventLoopUtilization()
100+
101+
const snapshot = setInterval(() => {
102+
const wallMs = performance.now() - lastWall
103+
const cpu = process.cpuUsage(lastCpu)
104+
const elu = performance.eventLoopUtilization(lastElu)
105+
const count = histogram.count
106+
const inFlightWindow = takeInFlightWindow()
107+
108+
logger.info('Event loop snapshot', {
109+
lagP50Ms: count ? round(histogram.percentile(50) / NS_PER_MS) : 0,
110+
lagP99Ms: count ? round(histogram.percentile(99) / NS_PER_MS) : 0,
111+
lagMaxMs: count ? round(histogram.max / NS_PER_MS) : 0,
112+
lagMeanMs: count ? round(histogram.mean / NS_PER_MS) : 0,
113+
stalls: stallCount,
114+
stallMaxMs: Math.round(stallMaxMs),
115+
stallTotalMs: Math.round(stallTotalMs),
116+
cpuPct: wallMs > 0 ? round(((cpu.user + cpu.system) / 1000 / wallMs) * 100) : 0,
117+
...(elu.idle + elu.active > 0 ? { elu: round(elu.utilization, 3) } : {}),
118+
inFlight: getInFlightCount(),
119+
inFlightPeak: inFlightWindow.peak,
120+
...(inFlightWindow.dropped > 0 ? { inFlightDropped: inFlightWindow.dropped } : {}),
121+
windowMs: Math.round(wallMs),
122+
})
123+
124+
histogram.reset()
125+
stallCount = 0
126+
stallMaxMs = 0
127+
stallTotalMs = 0
128+
warningsThisWindow = 0
129+
lastWall = performance.now()
130+
lastCpu = process.cpuUsage()
131+
lastElu = performance.eventLoopUtilization()
132+
}, SNAPSHOT_INTERVAL_MS)
133+
snapshot.unref()
134+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* Failure modes guarded here:
3+
* - Next instantiates this file once per bundle graph (instrumentation vs each
4+
* route), so per-module state would leave the telemetry reader seeing an
5+
* empty registry while routes write to their own copy.
6+
* - Request ids repeat when route wrappers nest, so keying entries by request id
7+
* lets the inner settle erase the outer entry.
8+
* - A handler that never settles must not grow the registry without bound.
9+
* - A synchronous burst settles its own request a few milliseconds before the
10+
* stall probe runs, so a near-zero tolerance never reports it.
11+
*/
12+
import { beforeEach, describe, expect, it, vi } from 'vitest'
13+
14+
type InFlightModule = typeof import('@/lib/monitoring/in-flight-requests')
15+
16+
async function loadFreshModuleInstance(): Promise<InFlightModule> {
17+
vi.resetModules()
18+
return import('@/lib/monitoring/in-flight-requests')
19+
}
20+
21+
describe('in-flight request registry', () => {
22+
beforeEach(() => {
23+
for (const key of Object.keys(globalThis)) {
24+
if (key.startsWith('_simInFlight')) Reflect.deleteProperty(globalThis, key)
25+
}
26+
})
27+
28+
it('shares state between separately instantiated copies of the module', async () => {
29+
const routeInstance = await loadFreshModuleInstance()
30+
const telemetryInstance = await loadFreshModuleInstance()
31+
expect(routeInstance).not.toBe(telemetryInstance)
32+
33+
const settle = routeInstance.trackInFlightRequest('req-1', 'POST', '/api/a')
34+
expect(telemetryInstance.getInFlightCount()).toBe(1)
35+
36+
settle()
37+
expect(telemetryInstance.getInFlightCount()).toBe(0)
38+
})
39+
40+
it('keeps an outer entry when a nested wrapper reuses the same request id', async () => {
41+
const registry = await loadFreshModuleInstance()
42+
const settleOuter = registry.trackInFlightRequest('req-1', 'POST', '/api/a')
43+
const settleInner = registry.trackInFlightRequest('req-1', 'POST', '/api/a')
44+
45+
settleInner()
46+
expect(registry.getInFlightCount()).toBe(1)
47+
48+
settleOuter()
49+
expect(registry.getInFlightCount()).toBe(0)
50+
})
51+
52+
it('bounds the registry when handlers never settle', async () => {
53+
const registry = await loadFreshModuleInstance()
54+
for (let i = 0; i < 10_000; i++) registry.trackInFlightRequest(`req-${i}`, 'GET', '/api/hang')
55+
56+
const tracked = registry.getInFlightCount()
57+
expect(tracked).toBeLessThan(10_000)
58+
expect(registry.takeInFlightWindow().dropped).toBe(10_000 - tracked)
59+
})
60+
61+
it('reports a request that settled shortly before the stall was observed', async () => {
62+
const registry = await loadFreshModuleInstance()
63+
const settle = registry.trackInFlightRequest('req-burst', 'POST', '/api/burst')
64+
const stallStart = performance.now()
65+
settle()
66+
const stallEnd = performance.now() + 20
67+
68+
const spanning = registry.findRequestsSpanningStall(stallStart, stallEnd)
69+
expect(spanning.map((entry) => entry.requestId)).toEqual(['req-burst'])
70+
expect(spanning[0].finished).toBe(true)
71+
})
72+
73+
it('reports a request that started just after the probe was due, but not one queued behind the stall', async () => {
74+
vi.useFakeTimers({ toFake: ['performance'] })
75+
try {
76+
const registry = await loadFreshModuleInstance()
77+
const probeDue = performance.now()
78+
vi.advanceTimersByTime(20)
79+
const settleBlocker = registry.trackInFlightRequest('req-blocker', 'POST', '/api/blocker')
80+
vi.advanceTimersByTime(1_500)
81+
settleBlocker()
82+
const settleQueued = registry.trackInFlightRequest('req-queued', 'GET', '/api/queued')
83+
vi.advanceTimersByTime(5)
84+
settleQueued()
85+
vi.advanceTimersByTime(5)
86+
87+
const spanning = registry.findRequestsSpanningStall(probeDue, performance.now())
88+
expect(spanning.map((entry) => entry.requestId)).toEqual(['req-blocker'])
89+
} finally {
90+
vi.useRealTimers()
91+
}
92+
})
93+
94+
it('does not report a request that finished well before the stall ended', async () => {
95+
const registry = await loadFreshModuleInstance()
96+
const settle = registry.trackInFlightRequest('req-early', 'GET', '/api/early')
97+
const stallStart = performance.now()
98+
settle()
99+
100+
expect(registry.findRequestsSpanningStall(stallStart, performance.now() + 500)).toEqual([])
101+
})
102+
})

0 commit comments

Comments
 (0)