Skip to content

Record per-worker event loop delay, per-thread duration and whole-request time in analytics - #3110

Merged
kriszyp merged 24 commits into
mainfrom
feat/analytics-per-worker-load-signals
Oct 21, 2026
Merged

kriszyp merged 24 commits into
mainfrom
feat/analytics-per-worker-load-signals

Conversation

@ldt1996

@ldt1996 ldt1996 commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

⊙ Problem

Core analytics cannot show one fully busy worker among idle ones. duration is summed across threads. Nothing records how late a thread's event loop runs. Requests in the HTTP request queue record no wait because duration starts 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 its duration entries, in hdb_analytics only. duration rows are unchanged.
  • request-time: dispatch to response ready, including the request-queue wait, for requests with a method other than GET, HEAD, and OPTIONS on Node and uWS. Bun, and a uWS request handed to Fastify, record none.
  • Aggregate memory and queue-depth rows now carry the aggregation period, and no summed threadId.

⚖️ Alternatives

  • A nested threads object on existing rows — the Grafana datasource has no case for an object attribute, and it would be the first in hdb_analytics.
  • Extra duration rows with a thread attribute — every consumer that sums duration would count twice.
  • The throttle's cycle-time estimate — it updates only while a call is queued.
  • Stamping at the parser's message-begin callback — measured: it saw no event-loop wait, only the client's header time.

Framing-Verdict: chosen-approach-sound (530723b589cc)

🤔 Rows per thread, or a nested threads object?

Rows per thread, with a thread attribute like path and method. The nested form was requested, but Grafana's datasource cannot read an object attribute. Switching later means rewriting aggregateEntry and every consumer of the rows.

🔧 Changes

✅ Verification

  • aggregationCycle.test.js, eventLoopDelay.test.js and requestTimePlugin.test.js pass 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.
  • A live smoke at 5e87ee7 wrote one event-loop-delay and one duration-by-thread row per reporting thread, and request-time for PUT and POST but not GET.
  • Keep-alive benchmarks on the Node, uWS and Fastify paths stay inside run-to-run noise against the merge base.
  • No unit test drives the Node or uWS path end to end for request-time; the Fastify hook's test calls the hook with a supplied stamp, and the benchmark's queued runs produce the rows.
  • PR QA: skipped @ 5e87ee7 — dispatch is not available to this session.

Docs companion: HarperFast/documentation#744.

Smoke rows, unedited
core 6639a076a
traffic: 40 PUT, 40 GET, 10 POST on /Probe
raw reports: 1; aggregate rows: 91
raw event-loop-delay: 1 entries, e.g. {"metric": "event-loop-delay", "threadId": 2, "perThread": true, "mean": 1.098317913043477, "maxDelay": 2.7082230000000003, "count": 46}
raw request-time: 2 entries, e.g. {"metric": "request-time", "path": "Probe", "method": "PUT", "mean": 0.36905937500002894, "count": 40}
raw duration-by-thread: none
raw duration: 3 entries, e.g. {"metric": "duration", "path": "Probe", "method": "GET", "mean": 0.29544221951230765, "count": 41}
aggregate event-loop-delay: 1 rows, keys [(None, None, None, 2)]
   e.g. {"id": [1792586572118.3213, -1347326683], "period": 10000, "metric": "event-loop-delay", "mean": 1.098317913043477, "maxDelay": 2.7082230000000003, "count": 46, "thread": 2, "time": 1792586567105.4475}
aggregate request-time: 2 rows, keys [('Probe', 'POST', None, None), ('Probe', 'PUT', None, None)]
   e.g. {"id": [1792586572117.088, -1347326683], "period": 10000, "metric": "request-time", "path": "Probe", "method": "PUT", "mean": 0.36905937500002894, "count": 40, "median": 0.23127855584025384, "time": 1792586567105.4475}
aggregate duration-by-thread: 1 rows, keys [(None, None, None, 2)]
   e.g. {"id": [1792586572114.9182, -1347326683], "period": 10000, "metric": "duration-by-thread", "count": 91, "mean": 0.3209721538462043, "thread": 2, "time": 1792586567105.4475}
aggregate duration: 3 rows, keys [('Probe', 'GET', None, None), ('Probe', 'POST', None, None), ('Probe', 'PUT', None, None)]
   e.g. {"id": [1792586572113.6365, -1347326683], "period": 10000, "metric": "duration", "path": "Probe", "method": "GET", "mean": 0.29544221951230765, "count": 41, "median": 0.11176719814538956, "time": 1792586567105.4475}
stopped
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: 0 in every run, against the then merge base 3fd9ae9.

build, analytics GET req/s GET p50 ms PUT (queued) req/s PUT p50 ms
merge base 3fd9ae9, on 30,069 / 31,034 1.40 / 1.40 24,901 / 24,744 1.78 / 1.78
branch, on 31,026 / 31,774 1.25 / 1.25 24,851 / 25,105 1.78 / 1.78
branch, off 31,186 1.20 25,321 1.75

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_all through 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-time does not stamp today), #3139 overlaps (merged; its measure-count fold is kept in the byThread fold), 3 others independent

Complexity: complicated

Deferred findings

  • 16d18ee0ef8a27d4 minor 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 FILED
  • baacdcc9311f6223 minor unclassified — server/http.ts — uWS request injected into Fastify loses its request-time stamp — NOT FILED
  • f1971fdfa7beb6d2 minor unclassified — resources/analytics/write.ts — mandatory per-thread fan-out multiplies audited analytics rows — NOT FILED
  • cbf5a2b2d17bf71b minor 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 FILED

Review-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

Lavinia and others added 3 commits October 8, 2026 13:03
…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>
@ldt1996
ldt1996 requested review from dawsontoth and kriszyp October 8, 2026 10:42
gemini-code-assist[bot]

This comment was marked as resolved.

Lavinia and others added 2 commits October 8, 2026 13:46
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…lock

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@ldt1996
ldt1996 marked this pull request as ready for review October 8, 2026 15:59
@dawsontoth

Copy link
Copy Markdown
Contributor

@laviniat1996 can you cross-model review?

@kriszyp

kriszyp commented Oct 8, 2026

Copy link
Copy Markdown
Member

Yeah, you can not pass CI without cross-model-review: https://github.com/HarperFast/harper/actions/runs/37805216175/job/113407611478?pr=3110

@dawsontoth

Copy link
Copy Markdown
Contributor

This will be great to integrate into Studio too! Still waiting on that cross-model review ;D

…rker-load-signals

# Conflicts:
#	DESIGN.md
#	resources/analytics/DESIGN.md
#	resources/analytics/write.ts
#	unitTests/resources/analytics/aggregationCycle.test.js
ldt1996 and others added 8 commits October 14, 2026 15:53
…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>
@ldt1996 ldt1996 added this to the v5.4 milestone Oct 14, 2026
ldt1996 and others added 2 commits October 15, 2026 10:55
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>
ldt1996 and others added 2 commits October 15, 2026 11:42
…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 dawsontoth 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.

Are the units for the measurements all milliseconds?

ldt1996 and others added 2 commits October 21, 2026 14:18
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>
@ldt1996

ldt1996 commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

@dawsontoth Yes: every time value is milliseconds, and every count is a plain count. event-loop-delay reports mean and maxDelay as lateness past the 20 ms sampling interval, so an idle loop reads about 0, and its count is sampler firings. request-time and duration-by-thread report handler-side milliseconds, with count as requests and responses. The docs companion is HarperFast/documentation#744.

Lavinia, via Claude

🤖 Generated by Anthropic Claude (Opus 5.5); posted via @ldt1996.

ldt1996 and others added 3 commits October 21, 2026 15:07
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>
ldt1996 added a commit to HarperFast/documentation that referenced this pull request Oct 21, 2026
…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>
@ldt1996
ldt1996 marked this pull request as ready for review October 21, 2026 13:28

@kriszyp kriszyp left a comment

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.

🤖 Reviewed with Codex

Comment thread server/eventLoopDelay.ts
const EVENT_LOOP_DELAY_RESOLUTION_MS = 20;
const NANOSECONDS_PER_MILLISECOND = 1e6;

export interface EventLoopDelay {

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.

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.

Comment thread server/eventLoopDelay.ts
@@ -0,0 +1,59 @@
import { monitorEventLoopDelay, type IntervalHistogram } from 'node:perf_hooks';

const EVENT_LOOP_DELAY_RESOLUTION_MS = 20;

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.

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.

@kriszyp
kriszyp merged commit 78231e4 into main Oct 21, 2026
64 checks passed
@kriszyp
kriszyp deleted the feat/analytics-per-worker-load-signals branch October 21, 2026 15:40
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