Repository navigation
Record per-worker event loop delay, per-thread duration and whole-request time in analytics - #3110
Conversation
…uest time in analytics Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…d of a socket data listener Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nd sharpen the request timing tests Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…lock Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
@laviniat1996 can you cross-model review? |
|
Yeah, you can not pass CI without cross-model-review: https://github.com/HarperFast/harper/actions/runs/37805216175/job/113407611478?pr=3110 |
|
This will be great to integrate into Studio too! Still waiting on that cross-model review ;D |
…rker-load-signals
…rker-load-signals # Conflicts: # DESIGN.md # resources/analytics/DESIGN.md # resources/analytics/write.ts # unitTests/resources/analytics/aggregationCycle.test.js
…n hook The hook was meant to cover the event-loop wait before the parser ran. Measured with the built helper on a node:http server, it does not: a request sent 150 ms into a 600 ms loop block was stamped 450 ms after it was sent, 1 ms after the block ended and 0 ms before the request event, because a consumed parser runs inside the socket read callback. What it added over a dispatch-entry stamp was the client's header transmission time, 401 ms when headers were trickled over 400 ms, which is network time, not load. It also ran on every HTTP/1 message with analytics off and overwrote an internal parser slot. request-time now starts at a performance.now() taken in dispatchRequest before the throttle, and in the uWS handler before its queue; Bun.serve has no queue and records no request-time, where it would equal duration. The helper and its tests are removed; the design notes carry the measurement. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…togram per report window GET, HEAD and OPTIONS run in the same synchronous call as their dispatch, so their request-time equalled duration and the second record was pure cost on the hottest path; only a request that went through the throttle queue is stamped and recorded now, on the Node and uWS servers. The event-loop-delay histogram started at module load and reset only on a flush, so a worker's first report after boot carried its startup stalls as maxDelay and a stall during an idle gap landed in the next report. It now resets when a report window opens, so each report covers its own window; a test shows a block before the reset is not reported. The design note keeps the invariant and sends the measurement log to the PR, and two narrating comments are gone. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… for queued Fastify requests, and add the request-path benchmark A sample recorded during the flush's per-action yields opens the next report window, which now resets the histogram, so a read after those yields could lose the window it was reporting. The read happens right after the action rotation, before the first yield. A queued request handed to Fastify returned before the HTTP response path's recorder ran, so the operations API, the most POST-heavy queued surface, got duration only; requestTimePlugin.js now records request-time from the stamp dispatchRequest took. A uWS request injected into Fastify keeps no stamp and still records duration only. benchmarks/request-time holds the keep-alive driver and the 2026-10-14 numbers: the unqueued GET path is inside noise against the merge-base, and the queued path's whole analytics cost is about 3%. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…azily, and benchmark against the merge-base With analytics off a queued request paid a performance.now() and a property write for a record that would never be made; the stamp is now taken only when analytics is enabled. The event-loop sampler starts on the first enabled check rather than at module load, so a read-only worker, whose first record disables analytics, never runs it. The benchmark driver counts only 2xx responses as completed, and the baseline is the branch's merge-base with main rather than its fork point: both paths are inside run-to-run noise, with the queued path's analytics-on cost under 2% against analytics off. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…, and measure the uWS and Fastify paths A per-thread rollup key ended in -thread-<id>, so an ordinary duration entry with no path and a method spelling that suffix, which HTTP/2 lets a client send, folded into a per-thread row and corrupted its distribution. The suffix is now a NUL byte and the thread id, which no request path or method can contain; a test seeds both entries and checks they stay apart. A unit test drives the Fastify hook through core's flush and shows request-time emitted for a stamped request and not for an unstamped one. The benchmark driver takes a path, auth and body, and the README adds the uWS HTTP port and the operations API, both inside noise against the merge-base. The design note states that a stall during an idle gap, before a window's first request, is not reported. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…-thread key cannot collide The hook test had replaced the plugin's existing Fastify inject test for the Server-Timing header; both are kept. The per-thread key comment now states the real reason the NUL suffix is safe: no ordinary key ends in NUL and digits, since HTTP methods are token characters and an operation key, whose action name a request body chooses, always ends in -operation. The analytics design note now describes the window-open reset rather than a reset on each flush. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… analytics note, and bound the benchmark driver The hook test required the plugin through #src, which resolves to a .ts path under --conditions=typestrip while the plugin exists only as .js; the #js alias resolves in both modes. The analytics design note had lost the spaces around three code spans. The benchmark driver sends a body only for methods that carry one and gives every request a 10 s deadline, so a stalled target cannot hold a run open. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… covers every throttled request The uWS path read receivedAt off the app-facing request after the app chain had run, so an app middleware that set its own receivedAt would have been recorded; the handler now captures it at entry, as the Node path does. The notes said request-time is recorded only for queued requests; an idle throttle runs its first call synchronously, so it is recorded for every request that goes through the throttle, waited or not, and the notes say so. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
DESIGN.md and server/DESIGN.md conflicted where both sides appended: the request-time note and its index entry sit beside main's pointer-compression guard note and its request-queue throttle index entry. #3200 makes the throttle's cycle-time estimate per throttle and resets it when the queue drains; request-time is stamped before the throttle is entered, so the two do not interact. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rk driver's last requests Every other entry in the design-note index ends with a period. The benchmark driver now caps each request's deadline at the run's remaining time, and exits through process.exitCode when no request succeeded, so its JSON line drains to a piped stdout before the process ends. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n past it is reported The flush read and reset the event-loop-delay histogram in the timer callback for its deadline. When a stall ran past that deadline, any JS timer due before the sampler's next 20 ms firing, the flush included, ran first after the stall; the read missed the stall, and the reset cleared the sampler's previous firing time, so its overdue firing only seeded and the stall was lost from both reports. Measured with Node's monitorEventLoopDelay on Node 24: a 300 ms stall spanning the deadline was lost in 8 of 20 trials when a timer callback stalled and 19 of 20 when a check-phase callback did; deferring the read by one fresh timer turn captured it in 20 of 20 in every case, because libuv runs the sampler's overdue timer before a timer armed after the stall. The deadline's timer now arms the flush with a 0 ms timer and the flush body runs there. A test drives core's real flush with a check-phase stall across the deadline and asserts the stall is in that flush's event-loop-delay entry; it failed 4 of 5 runs on the old ordering. Also: the request-time note says the gap for a request cascaded to Fastify includes the Harper chain before the cascade, the Fastify hook test restores the analytics state it found, and an unused test parameter is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ampler's blind spot The analytics design note now says a stall that begins in the same loop turn as either reset, the flush's or the one that opens a window, is not measured, and that the flush's extra timer turn keeps a stall that ran past the deadline in its report. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dawsontoth
left a comment
There was a problem hiding this comment.
Are the units for the measurements all milliseconds?
server/http.ts conflicted twice. The imports keep checkAnalyticsEnabled and METRIC beside main's new `finished`. In the Node request handler main (#3227) moved the success, response-code and request-log records into recordOutcome, which runs once the response's outcome is known; the resolution takes that and keeps this branch's shared cacheType and its request-time record beside duration. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
setAnalyticsEnabled cancelled the pending flush but kept analyticsStart, so the next window skipped its histogram reset and its period reached back over the cancelled one. Only tests toggle analytics today, so this is a tidy-up the review on the merge found. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@dawsontoth Yes: every time value is milliseconds, and every Lavinia, via Claude 🤖 Generated by Anthropic Claude (Opus 5.5); posted via @ldt1996. |
An aggregation cycle writes its rows one by one, and two tests waited for one row and then read the others immediately. Under LMDB on Node 26 the ordinary duration row was not there yet, and the collision test read count off undefined. Each read now waits for the rows it checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gap, and roll duration-by-thread up once per thread The flush already resets the histogram once a second, so the reset when the next window opens a few milliseconds later only added a second blind spot under steady traffic. It now runs only when more than one flush interval has passed since the last flush, or at boot and after analytics is turned back on, which is the case it was added for; a stall right after a flush is reported in the next window instead of being thrown away. A test stalls the loop between a flush and the next window and fails on the old reset in every run. duration-by-thread was one row per path, method, type and thread, kept for the aggregate retention, so it multiplied duration's rows by the worker count and copied every request-chosen operation name per thread. It is now one row per thread per period, with the count and count-weighted mean of all that thread's duration entries; the per-path detail for a thread stays in the raw reports. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s restart after a quiet gap Follows HarperFast/harper#3110 at 6639a076a: duration-by-thread is now one row per worker thread with the count and count-weighted mean of all its duration entries, and the event-loop histogram restarts with each report and again only when activity resumes after a gap of more than a second. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| const EVENT_LOOP_DELAY_RESOLUTION_MS = 20; | ||
| const NANOSECONDS_PER_MILLISECOND = 1e6; | ||
|
|
||
| export interface EventLoopDelay { |
There was a problem hiding this comment.
Could EventLoopDelay also remain module-private? The constant is now private and the two request-timing interfaces were deleted, addressing three parts of the earlier finding. This interface still has no visible consumer importing its exported name; removing export keeps the named module surface to the helpers callers use.
| @@ -0,0 +1,59 @@ | |||
| import { monitorEventLoopDelay, type IntervalHistogram } from 'node:perf_hooks'; | |||
|
|
|||
| const EVENT_LOOP_DELAY_RESOLUTION_MS = 20; | |||
There was a problem hiding this comment.
Could the helper-only symbols remain module-private? EVENT_LOOP_DELAY_RESOLUTION_MS and EventLoopDelay are used only within this file, while TimedRequest and TimedSocket are used only within server/serverHelpers/requestTiming.ts:1-7. No import in this checkout consumes those exports. Removing their export keywords keeps the module API to the functions callers actually use.
⊙ Problem
Core analytics cannot show one fully busy worker among idle ones.
durationis summed across threads. Nothing records how late a thread's event loop runs. Requests in the HTTP request queue record no wait becausedurationstarts at handler entry.🤔 Is this still needed now that akamai-status reads event-loop utilization?
Yes. The component no longer depends on it, but per-thread lateness and queue wait are signals utilization cannot give: a loop can be busy without being late. Dawson wants them in Studio. Dropping the PR would only remove those signals.
💡 Solution
event-loop-delay: one row per thread and period, how late a 20 ms timer fired, in milliseconds.duration-by-thread: one row per worker thread and period, with the count and mean of all itsdurationentries, inhdb_analyticsonly.durationrows are unchanged.request-time: dispatch to response ready, including the request-queue wait, for requests with a method other thanGET,HEAD, andOPTIONSon Node and uWS. Bun, and a uWS request handed to Fastify, record none.memoryand queue-depth rows now carry the aggregationperiod, and no summedthreadId.⚖️ Alternatives
threadsobject on existing rows — the Grafana datasource has no case for an object attribute, and it would be the first inhdb_analytics.durationrows with athreadattribute — every consumer that sumsdurationwould count twice.Framing-Verdict: chosen-approach-sound (530723b589cc)
🤔 Rows per thread, or a nested
threadsobject?Rows per thread, with a
threadattribute likepathandmethod. The nested form was requested, but Grafana's datasource cannot read an object attribute. Switching later means rewritingaggregateEntryand every consumer of the rows.🔧 Changes
server/eventLoopDelay.tsis new. It starts Node's sampler at 20 ms, and converts a reading to milliseconds past that interval.server/http.ts: a request bound for the request queue is stamped at dispatch while analytics is on, andrequest-timeis recorded besidedurationon the Node and uWS paths. Unqueued requests only find no stamp and skip the record.server/serverHelpers/requestTimePlugin.jsrecordsrequest-timefor a stamped request handed to Fastify.resources/analytics/write.ts: the sampler starts on the first enabled check rather than at module load. The histogram resets at each flush and at a window's open only after an idle gap, at boot, or after analytics is turned back on. The flush runs one timer turn after its deadline, so a stall past the deadline is kept. Per-thread entries aggregate under a NUL-separated key.durationaggregates once per thread, so row counts grow with threads rather than paths. ThebyThreadaggregation keepsperiod.resources/analytics/metadata.tslists the three names as built-in metrics.resources/analytics/DESIGN.md,server/DESIGN.md, and the root index.benchmarks/request-time/drive.mjsis the keep-alive driver, andbenchmarks/request-time/README.mdholds its numbers.✅ Verification
aggregationCycle.test.js,eventLoopDelay.test.jsandrequestTimePlugin.test.jspass 20 of 20 at 5e87ee7, under RocksDB and LMDB. The deadline-stall test fails on most runs of the old flush order, and the idle-gap test fails on the old window-open reset in every run.event-loop-delayand oneduration-by-threadrow per reporting thread, andrequest-timefor PUT and POST but not GET.request-time; the Fastify hook's test calls the hook with a supplied stamp, and the benchmark's queued runs produce the rows.Docs companion: HarperFast/documentation#744.
Smoke rows, unedited
Benchmark, requests per second
Two workers, one exported table, keep-alive, 50 concurrent loops, 20 s after a 5 s warm-up, only 2xx responses counted,
failed: 0in every run, against the then merge base 3fd9ae9.uWS, analytics on: GET 30,359 / 30,302 base against 30,617 / 30,271 branch; queued PUT 25,077 / 26,945 against 25,438 / 24,899. Operations-API
POST describe_allthrough Fastify: 17,214 against 17,320.Stall past the flush deadline, measured before the fix
With Node 24's
monitorEventLoopDelay, a 300 ms stall across the flush deadline was lost in 8 of 20 trials when a timer callback stalled, and 19 of 20 when a check-phase callback did. Arming the flush with a fresh 0 ms timer from the deadline captured it 20 of 20.Lavinia, via Claude
🤖 Generated by Anthropic Claude (Opus 5.5); posted via @ldt1996.
Release target: main — new analytics metrics and an aggregate row fix for 5.4, not a backport
Related PRs: #3234 overlaps (it would send GET, HEAD and OPTIONS through the request queue, which
request-timedoes not stamp today), #3139 overlaps (merged; its measure-count fold is kept in thebyThreadfold), 3 others independentComplexity: complicated
Deferred findings
16d18ee0ef8a27d4minor unclassified —unitTests/server/serverHelpers/requestTimePlugin.test.js— Fastify hook test supplies the stamp; no end-to-end proof http.ts:911 reaches request.raw — NOT FILEDbaacdcc9311f6223minor unclassified —server/http.ts— uWS request injected into Fastify loses its request-time stamp — NOT FILEDf1971fdfa7beb6d2minor unclassified —resources/analytics/write.ts— mandatory per-thread fan-out multiplies audited analytics rows — NOT FILEDcbf5a2b2d17bf71bminor correctness —resources/analytics/write.ts— window-open histogram reset drops a stall begun in the opening turn; past the deadline the report has no event-loop-delay entry — NOT FILEDReview-Coverage: authored=claude; ran=codex,gemini; adjudicated=domain; blocked=claude(fallback)(out-of-budget); declined=cursor-grok,cursor-composer,cursor-kimi,cursor-muse,muse; rounds=17; full=8 @ 5e87ee7
Review-Attention: deep ~50m (critical: write.ts; hot: http.ts; decisions: do-less-alternative, destructive-histogram-reset, always-on-sampler-resolution, request-time-scope, duration-by-thread-no-switch, stamp-as-plain-property, release-target-unclear) @ 5e87ee7