@nxtedition/nxt-undici 7.4.3 → 7.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  import net from 'node:net'
2
2
  import * as dns from 'node:dns'
3
3
  import { DecoratorHandler, getFastNow } from '../utils.js'
4
+ import { traceWrite, traceSafe, traceErr, traceUrl } from '../trace.js'
4
5
  import xxhash from 'xxhash-wasm'
5
6
 
6
7
  let HASHER
@@ -119,8 +120,16 @@ export default () => (dispatch) => {
119
120
  function resolve(hostname, { ttl, negativeTTL, lookup }) {
120
121
  let promise = promises.get(hostname)
121
122
  if (!promise) {
123
+ // A synchronous lookup callback (custom resolvers answering from a local
124
+ // cache) runs inside the Promise executor, BEFORE the `promises.set`
125
+ // below — its cleanup delete would be a no-op and the settled promise
126
+ // would be retained forever, turning every later resolve() for the
127
+ // hostname (misses and pre-emptive refreshes alike) into a stale no-op.
128
+ // Track settlement so an already-settled promise is never registered.
129
+ let settled = false
122
130
  promise = new Promise((resolve) => {
123
131
  lookup(hostname, { all: true }, (err, records) => {
132
+ settled = true
124
133
  promises.delete(hostname)
125
134
 
126
135
  if (err) {
@@ -153,7 +162,9 @@ export default () => (dispatch) => {
153
162
  }
154
163
  })
155
164
  })
156
- promises.set(hostname, promise)
165
+ if (!settled) {
166
+ promises.set(hostname, promise)
167
+ }
157
168
  }
158
169
  return promise
159
170
  }
@@ -190,10 +201,47 @@ export default () => (dispatch) => {
190
201
  // served from cache.
191
202
  const negative = negatives.get(hostname)
192
203
  if (negative != null && negative.expires >= now) {
204
+ // Cold path (fail-fast) — resolve the writer per emission, like
205
+ // response-retry's traceRetry.
206
+ const write = traceWrite(opts.trace)
207
+ if (write !== null) {
208
+ traceSafe(
209
+ write,
210
+ {
211
+ id: opts.id ?? null,
212
+ url: traceUrl(opts),
213
+ source: 'negative',
214
+ durationMs: 0,
215
+ records: null,
216
+ err: traceErr(negative.err),
217
+ },
218
+ 'undici:dns',
219
+ )
220
+ }
193
221
  throw makeLookupError(negative.err)
194
222
  }
195
223
 
224
+ // Cold path (cache miss) — this request synchronously awaits the
225
+ // (possibly shared in-flight) resolution, so attribute the wait to it;
226
+ // concurrent awaiters each emit their own doc. The url tag is taken
227
+ // from the logical opts, before the origin is rewritten to the IP.
228
+ const write = traceWrite(opts.trace)
229
+ const started = write !== null ? performance.now() : 0
196
230
  const [err, val] = await resolve(hostname, { ttl, negativeTTL, lookup })
231
+ if (write !== null) {
232
+ traceSafe(
233
+ write,
234
+ {
235
+ id: opts.id ?? null,
236
+ url: traceUrl(opts),
237
+ source: 'miss',
238
+ durationMs: Math.round(performance.now() - started),
239
+ records: err ? null : val.length,
240
+ err: err ? traceErr(err) : null,
241
+ },
242
+ 'undici:dns',
243
+ )
244
+ }
197
245
  if (err) {
198
246
  throw makeLookupError(err)
199
247
  }
@@ -242,7 +290,35 @@ export default () => (dispatch) => {
242
290
  // refreshed records land in cache for the next request, smoothing
243
291
  // out DNS lookup latency. `resolve()` dedupes via `promises`.
244
292
  if (records.some((x) => x.expires < now + ttl / 2)) {
245
- resolve(hostname, { ttl, negativeTTL, lookup })
293
+ // Only the request that actually initiates the refresh observes it
294
+ // (checked against `promises` BEFORE resolve() registers the new
295
+ // in-flight promise): concurrent requests in the half-TTL window join
296
+ // the same deduped lookup and would otherwise emit one doc each, with
297
+ // attach-relative durations — the doc denotes the background lookup
298
+ // itself, not a per-request wait (nothing awaits a refresh).
299
+ const write = traceWrite(opts.trace)
300
+ const initiated = write !== null && !promises.has(hostname)
301
+ const promise = resolve(hostname, { ttl, negativeTTL, lookup })
302
+ if (initiated) {
303
+ // Side-observe a copy of the chain: resolve() never rejects (it
304
+ // settles with an [err, val] tuple) and .then returns a new promise,
305
+ // so the shared in-flight promise's other consumers are unaffected.
306
+ const started = performance.now()
307
+ promise.then(([err, val]) => {
308
+ traceSafe(
309
+ write,
310
+ {
311
+ id: opts.id ?? null,
312
+ url: traceUrl(opts),
313
+ source: 'refresh',
314
+ durationMs: Math.round(performance.now() - started),
315
+ records: err ? null : val.length,
316
+ err: err ? traceErr(err) : null,
317
+ },
318
+ 'undici:dns',
319
+ )
320
+ })
321
+ }
246
322
  }
247
323
 
248
324
  url.hostname = net.isIPv6(record.address) ? `[${record.address}]` : record.address
@@ -265,6 +341,29 @@ export default () => (dispatch) => {
265
341
  if (err.code != null && CONNECTION_ERROR_CODES.has(err.code)) {
266
342
  // The IP is bad/unreachable — drop it from rotation immediately.
267
343
  record.expires = 0
344
+
345
+ // Cold path (connection error) — opts is the dispatch closure's;
346
+ // eviction stays allocation-free while tracing is off.
347
+ const write = traceWrite(opts.trace)
348
+ if (write !== null) {
349
+ const evictedAt = getFastNow()
350
+ let siblings = 0
351
+ for (const x of records) {
352
+ if (x.expires >= evictedAt) {
353
+ siblings++
354
+ }
355
+ }
356
+ traceSafe(
357
+ write,
358
+ {
359
+ hostname: hostname.slice(0, 256),
360
+ address: String(record.address).slice(0, 256),
361
+ err: traceErr(err),
362
+ siblings,
363
+ },
364
+ 'undici:dns-evict',
365
+ )
366
+ }
268
367
  } else {
269
368
  // Reachable IP, request failed for an unrelated reason (timeout
270
369
  // mid-stream, size mismatch, ...) — penalize softly, don't evict.
@@ -1,4 +1,5 @@
1
1
  import { DecoratorHandler, parseHeaders } from '../utils.js'
2
+ import { traceWrite, traceSafe, traceErr, traceUrl } from '../trace.js'
2
3
 
3
4
  const kGlobalIndex = Symbol.for('@nxtedition/nxt-undici#globalIndex')
4
5
  const kGlobalArray = Symbol.for('@nxtedition/nxt-undici#globalArray')
@@ -141,6 +142,18 @@ class Handler extends DecoratorHandler {
141
142
  #ureq
142
143
  #logger
143
144
 
145
+ // Trace emission (op 'undici:request') lives in this handler alongside
146
+ // logging: both observe the same lifecycle (start, status, bytes, terminal
147
+ // event, sync-dispatch-throw finalization), so a second decorator layer
148
+ // would duplicate the bookkeeping. `#write` is the trace fn resolved once
149
+ // per request (capture-once: the same fn emits both the start and the end
150
+ // doc, so a writer flipping mid-request cannot break the pairing) and null
151
+ // when tracing is off. Logging and tracing are independently optional; the
152
+ // dispatch entry only constructs this handler when at least one is active.
153
+ #write
154
+ #traceUrl
155
+ #upgraded = false
156
+
144
157
  #abort
145
158
  #aborted = false
146
159
  #pos = 0
@@ -153,20 +166,39 @@ class Handler extends DecoratorHandler {
153
166
  end: -1,
154
167
  }
155
168
 
169
+ #opts
156
170
  #statusCode
157
171
  #headers
158
172
 
159
- constructor(logOpts, opts, { handler }) {
173
+ constructor(write, logOpts, opts, { handler }) {
160
174
  super(handler)
161
175
 
162
- this.#ureq = sanitizeRequest(opts)
163
- this.#logger = opts.logger.child({ ureq: this.#ureq })
176
+ this.#opts = opts
177
+ this.#write = write
178
+
179
+ if (write !== null) {
180
+ this.#traceUrl = traceUrl(opts)
164
181
 
165
- if (logOpts?.bindings) {
166
- this.#logger = this.#logger.child(logOpts?.bindings)
182
+ traceSafe(
183
+ write,
184
+ { phase: 'start', id: opts.id ?? null, method: opts.method ?? null, url: this.#traceUrl },
185
+ 'undici:request',
186
+ )
187
+ }
188
+
189
+ if (opts.logger) {
190
+ this.#ureq = sanitizeRequest(opts)
191
+ this.#logger = opts.logger.child({ ureq: this.#ureq })
192
+
193
+ if (logOpts?.bindings) {
194
+ this.#logger = this.#logger.child(logOpts?.bindings)
195
+ }
196
+
197
+ this.#logger.debug('upstream request started')
198
+ } else {
199
+ this.#logger = null
167
200
  }
168
201
 
169
- this.#logger.debug('upstream request started')
170
202
  this.#timing.created = this.#created + performance.timeOrigin
171
203
 
172
204
  this[kGlobalArray] = globalThis[kGlobalArray] ??= []
@@ -190,8 +222,13 @@ class Handler extends DecoratorHandler {
190
222
 
191
223
  onUpgrade(statusCode, headers, socket) {
192
224
  this.#timing.headers = performance.now() - this.#created
225
+ this.#statusCode = statusCode
226
+ // After an upgrade the socket is handed over and no onComplete/onError
227
+ // will ever arrive — close of the upgraded socket is the end of the
228
+ // request. Bytes are not tracked on an upgraded socket.
229
+ this.#upgraded = true
193
230
 
194
- this.#logger.debug(
231
+ this.#logger?.debug(
195
232
  {
196
233
  ures: { statusCode, headers: sanitizeHeaders(headers) },
197
234
  elapsedTime: this.#timing.headers,
@@ -200,8 +237,8 @@ class Handler extends DecoratorHandler {
200
237
  )
201
238
 
202
239
  socket.on('close', () => {
203
- this.#logger.debug('upstream request socket closed')
204
- this.onDone()
240
+ this.#logger?.debug('upstream request socket closed')
241
+ this.onDone(null)
205
242
  })
206
243
 
207
244
  super.onUpgrade(statusCode, headers, socket)
@@ -230,30 +267,32 @@ class Handler extends DecoratorHandler {
230
267
  onComplete(trailers) {
231
268
  this.#timing.end = performance.now() - this.#created
232
269
 
233
- const data = {
234
- ureq: this.#ureq,
235
- ures: {
236
- statusCode: this.#statusCode,
237
- headers: this.#headers,
238
- timing: this.#timing,
239
- bytesRead: this.#pos,
240
- bytesReadPerSecond:
241
- this.#timing.data >= 0 && this.#timing.end > this.#timing.data
242
- ? (this.#pos * 1e3) / (this.#timing.end - this.#timing.data)
243
- : 0,
244
- },
245
- elapsedTime: this.#timing.end,
246
- }
270
+ if (this.#logger) {
271
+ const data = {
272
+ ureq: this.#ureq,
273
+ ures: {
274
+ statusCode: this.#statusCode,
275
+ headers: this.#headers,
276
+ timing: this.#timing,
277
+ bytesRead: this.#pos,
278
+ bytesReadPerSecond:
279
+ this.#timing.data >= 0 && this.#timing.end > this.#timing.data
280
+ ? (this.#pos * 1e3) / (this.#timing.end - this.#timing.data)
281
+ : 0,
282
+ },
283
+ elapsedTime: this.#timing.end,
284
+ }
247
285
 
248
- if (this.#statusCode >= 500) {
249
- this.#logger.error(data, 'upstream request completed')
250
- } else if (this.#statusCode >= 400) {
251
- this.#logger.warn(data, 'upstream request completed')
252
- } else {
253
- this.#logger.debug(data, 'upstream request completed')
286
+ if (this.#statusCode >= 500) {
287
+ this.#logger.error(data, 'upstream request completed')
288
+ } else if (this.#statusCode >= 400) {
289
+ this.#logger.warn(data, 'upstream request completed')
290
+ } else {
291
+ this.#logger.debug(data, 'upstream request completed')
292
+ }
254
293
  }
255
294
 
256
- this.onDone()
295
+ this.onDone(null)
257
296
 
258
297
  super.onComplete(trailers)
259
298
  }
@@ -261,33 +300,40 @@ class Handler extends DecoratorHandler {
261
300
  onError(err) {
262
301
  this.#timing.end = performance.now() - this.#created
263
302
 
264
- const data = {
265
- ures: {
266
- statusCode: this.#statusCode || undefined,
267
- headers: this.#headers,
268
- timing: this.#timing,
269
- bytesRead: this.#pos,
270
- bytesReadPerSecond:
271
- this.#timing.data >= 0 && this.#timing.end > this.#timing.data
272
- ? (this.#pos * 1e3) / (this.#timing.end - this.#timing.data)
273
- : 0,
274
- },
275
- elapsedTime: this.#timing.end,
276
- err,
277
- }
303
+ if (this.#logger) {
304
+ const data = {
305
+ ures: {
306
+ statusCode: this.#statusCode || undefined,
307
+ headers: this.#headers,
308
+ timing: this.#timing,
309
+ bytesRead: this.#pos,
310
+ bytesReadPerSecond:
311
+ this.#timing.data >= 0 && this.#timing.end > this.#timing.data
312
+ ? (this.#pos * 1e3) / (this.#timing.end - this.#timing.data)
313
+ : 0,
314
+ },
315
+ elapsedTime: this.#timing.end,
316
+ err,
317
+ }
278
318
 
279
- if (this.#aborted) {
280
- this.#logger.debug(data, 'upstream request aborted')
281
- } else {
282
- this.#logger.error(data, 'upstream request failed')
319
+ if (this.#aborted) {
320
+ this.#logger.debug(data, 'upstream request aborted')
321
+ } else {
322
+ this.#logger.error(data, 'upstream request failed')
323
+ }
283
324
  }
284
325
 
285
- this.onDone()
326
+ this.onDone(err)
286
327
 
287
328
  super.onError(err)
288
329
  }
289
330
 
290
- onDone() {
331
+ // Terminal finalization, reached exactly once per request from every end
332
+ // path (onComplete, onError, upgraded-socket close, onDispatchError): the
333
+ // in-flight registry entry is the once-guard. Deregistration happens BEFORE
334
+ // the trace end doc is emitted so a misbehaving writer can never observe —
335
+ // or re-enter — a handler that still looks in flight.
336
+ onDone(err) {
291
337
  if (this[kGlobalIndex] !== -1) {
292
338
  const tmp = this[kGlobalArray].pop()
293
339
  if (tmp !== this) {
@@ -295,16 +341,33 @@ class Handler extends DecoratorHandler {
295
341
  tmp[kGlobalIndex] = this[kGlobalIndex]
296
342
  }
297
343
  this[kGlobalIndex] = -1
344
+
345
+ if (this.#write !== null) {
346
+ traceSafe(
347
+ this.#write,
348
+ {
349
+ phase: 'end',
350
+ id: this.#opts.id ?? null,
351
+ method: this.#opts.method ?? null,
352
+ url: this.#traceUrl,
353
+ statusCode: this.#statusCode ?? null,
354
+ durationMs: Math.round(performance.now() - this.#created),
355
+ bytes: this.#upgraded ? null : this.#pos,
356
+ err: err != null ? traceErr(err) : null,
357
+ },
358
+ 'undici:request',
359
+ )
360
+ }
298
361
  }
299
362
  }
300
363
 
301
364
  // Finalization for a request whose inner dispatch threw synchronously:
302
365
  // undici never took ownership of the handler, so no terminal callback
303
- // (onError/onComplete) will ever arrive. Log the failure and deregister
304
- // from the in-flight registry. Deliberately does NOT forward onError —
305
- // the dispatch entry below rethrows and an outer interceptor (lookup)
306
- // delivers the error to the original handler chain, so forwarding here
307
- // would double-deliver it.
366
+ // (onError/onComplete) will ever arrive. Log the failure, emit the trace
367
+ // end doc and deregister from the in-flight registry. Deliberately does
368
+ // NOT forward onError — the dispatch entry below rethrows and an outer
369
+ // interceptor (lookup) delivers the error to the original handler chain,
370
+ // so forwarding here would double-deliver it.
308
371
  onDispatchError(err) {
309
372
  if (this[kGlobalIndex] === -1) {
310
373
  // A terminal callback already ran before the error escaped dispatch
@@ -315,18 +378,22 @@ class Handler extends DecoratorHandler {
315
378
 
316
379
  this.#timing.end = performance.now() - this.#created
317
380
 
318
- this.#logger.error({ err, elapsedTime: this.#timing.end }, 'upstream request failed')
381
+ this.#logger?.error({ err, elapsedTime: this.#timing.end }, 'upstream request failed')
319
382
 
320
- this.onDone()
383
+ this.onDone(err)
321
384
  }
322
385
  }
323
386
 
324
387
  export default (logOpts) => (dispatch) => (opts, handler) => {
325
- if (!opts.logger) {
388
+ // Capture-once per request (see Handler#write). Resolution cost when both
389
+ // logging and tracing are off is one property read plus a typeof check.
390
+ const write = traceWrite(opts.trace)
391
+
392
+ if (!opts.logger && write === null) {
326
393
  return dispatch(opts, handler)
327
394
  }
328
395
 
329
- const logHandler = new Handler(logOpts, opts, { handler })
396
+ const logHandler = new Handler(write, logOpts, opts, { handler })
330
397
 
331
398
  try {
332
399
  return dispatch(opts, logHandler)
@@ -1,4 +1,24 @@
1
1
  import { DecoratorHandler } from '../utils.js'
2
+ import { traceWrite, traceSafe, traceErr, traceUrl } from '../trace.js'
3
+
4
+ // Emit the single `undici:lookup` doc for an origin resolution: an async
5
+ // success carries the resolved origin, a failure carries the error tag. `url`
6
+ // is the requested origin+path; `resolved` is bounded like every other
7
+ // caller-influenced string.
8
+ function traceLookup(write, opts, start, resolved, err) {
9
+ traceSafe(
10
+ write,
11
+ {
12
+ id: opts.id ?? null,
13
+ method: opts.method ?? null,
14
+ url: traceUrl(opts),
15
+ resolved,
16
+ durationMs: Math.round(performance.now() - start),
17
+ err,
18
+ },
19
+ 'undici:lookup',
20
+ )
21
+ }
2
22
 
3
23
  export default () => (dispatch) => async (opts, handler) => {
4
24
  const lookup = opts.lookup
@@ -7,6 +27,16 @@ export default () => (dispatch) => async (opts, handler) => {
7
27
  return dispatch(opts, handler)
8
28
  }
9
29
 
30
+ // Per-request, resolved after the passthrough return; used at most once
31
+ // (one success OR failure doc per resolution).
32
+ const write = traceWrite(opts.trace)
33
+ const start = write !== null ? performance.now() : 0
34
+ // A success doc is only worth emitting when the callback fired
35
+ // asynchronously (service discovery): the default lookup calls back
36
+ // synchronously and would produce a ~0ms doc per request.
37
+ let resolvedAsync = false
38
+ let dispatched = false
39
+
10
40
  // Wrap so the catch below can't deliver a second onError: if a downstream
11
41
  // layer already reported a terminal callback and then let an error escape
12
42
  // dispatch synchronously, DecoratorHandler's #errored/#completed guards
@@ -15,16 +45,28 @@ export default () => (dispatch) => async (opts, handler) => {
15
45
 
16
46
  try {
17
47
  const origin = await new Promise((resolve, reject) => {
48
+ let sync = true
18
49
  const thenable = lookup(opts.origin, { signal: opts.signal ?? undefined }, (err, val) => {
50
+ if (!sync) {
51
+ resolvedAsync = true
52
+ }
19
53
  if (err) {
20
54
  reject(err)
21
55
  } else {
22
56
  resolve(val)
23
57
  }
24
58
  })
59
+ sync = false
25
60
 
26
61
  if (thenable != null) {
27
- Promise.resolve(thenable).then(resolve, reject)
62
+ // A promise-returning lookup is always asynchronous (`.then` callbacks
63
+ // never run on the current stack), so its success doc must be emitted
64
+ // like the async-callback shape's — only the sync callback path is
65
+ // suppressed as noise.
66
+ Promise.resolve(thenable).then((val) => {
67
+ resolvedAsync = true
68
+ resolve(val)
69
+ }, reject)
28
70
  }
29
71
  })
30
72
 
@@ -32,8 +74,18 @@ export default () => (dispatch) => async (opts, handler) => {
32
74
  throw new Error('invalid origin: ' + origin)
33
75
  }
34
76
 
77
+ if (write !== null && resolvedAsync) {
78
+ traceLookup(write, opts, start, String(origin).slice(0, 256), null)
79
+ }
80
+
81
+ dispatched = true
35
82
  return dispatch({ ...opts, origin }, wrapped)
36
83
  } catch (err) {
84
+ // The try also covers the inner dispatch call — a sync throw escaping the
85
+ // inner chain is not a lookup failure and must not be attributed to one.
86
+ if (write !== null && !dispatched) {
87
+ traceLookup(write, opts, start, null, traceErr(err))
88
+ }
37
89
  wrapped.onError(err)
38
90
  }
39
91
  }
@@ -1,5 +1,6 @@
1
1
  import { parsePriority, Scheduler } from '@nxtedition/scheduler'
2
2
  import { DecoratorHandler } from '../utils.js'
3
+ import { traceSafe, traceUrl, traceWrite, validateTrace } from '../trace.js'
3
4
 
4
5
  // Reconstruct a PSI-style pressure signal per origin from the request lifecycle
5
6
  // this interceptor observes, then expose latched flags so producers can back
@@ -67,6 +68,13 @@ class PressureMonitor {
67
68
  /** @type {ReturnType<typeof setInterval> | null} */
68
69
  #timer = null
69
70
 
71
+ // Optional trace writer for saturation-episode docs (op 'undici:pressure'):
72
+ // undefined defers to the per-thread global writer at each emission site,
73
+ // null disables. Sampling ticks in a timer with no request opts in scope, so
74
+ // the writer comes from the factory opts (like the scheduler limiter's
75
+ // #trace). All emission sites are cold — hysteresis latch transitions.
76
+ #trace
77
+
70
78
  #sampleInterval
71
79
  #tau
72
80
  #someHi
@@ -85,7 +93,9 @@ class PressureMonitor {
85
93
  fullLo = 0.1,
86
94
  errHi = 0.5,
87
95
  errLo = 0.2,
96
+ trace,
88
97
  } = {}) {
98
+ this.#trace = validateTrace(trace)
89
99
  this.#sampleInterval = sampleInterval
90
100
  this.#tau = tau
91
101
  this.#someHi = someHi
@@ -120,6 +130,7 @@ class PressureMonitor {
120
130
  shed: false, // latched: shed discretionary work
121
131
  paused: false, // latched: pause the producer
122
132
  degraded: false, // latched: error rate too high
133
+ episodes: null, // trace pairing: { some, full, error } of { write, start }, allocated on first engage
123
134
  lastSample: performance.now(),
124
135
  }
125
136
  this.#origins.set(key, rec)
@@ -132,7 +143,7 @@ class PressureMonitor {
132
143
  // loadavg-shaped EWMAs with a dt-aware gain (keeps the time-constant honest
133
144
  // under a jittery loop). Counters (completed) carry the `full` decision so a
134
145
  // burst that fills and drains between two samples can't alias it away.
135
- #sample(rec, now) {
146
+ #sample(key, rec, now) {
136
147
  const dt = now - rec.lastSample
137
148
  if (dt <= 0) {
138
149
  return
@@ -155,21 +166,80 @@ class PressureMonitor {
155
166
  rec.full += a * ((fullNow ? 1 : 0) - rec.full)
156
167
  rec.errorRate += a * (errNow - rec.errorRate)
157
168
 
158
- // Hysteresis: engage high, release low.
169
+ // Hysteresis: engage high, release low. Each latch brackets a saturation
170
+ // episode, traced as an 'undici:pressure' start/end pair.
159
171
  if (!rec.shed && rec.some > this.#someHi) {
160
172
  rec.shed = true
173
+ this.#traceEngage(key, rec, 'some', rec.some, now)
161
174
  } else if (rec.shed && rec.some < this.#someLo) {
162
175
  rec.shed = false
176
+ this.#traceRelease(key, rec, 'some', rec.some, now)
163
177
  }
164
178
  if (!rec.paused && rec.full > this.#fullHi) {
165
179
  rec.paused = true
180
+ this.#traceEngage(key, rec, 'full', rec.full, now)
166
181
  } else if (rec.paused && rec.full < this.#fullLo) {
167
182
  rec.paused = false
183
+ this.#traceRelease(key, rec, 'full', rec.full, now)
168
184
  }
169
185
  if (!rec.degraded && rec.errorRate > this.#errHi) {
170
186
  rec.degraded = true
187
+ this.#traceEngage(key, rec, 'error', rec.errorRate, now)
171
188
  } else if (rec.degraded && rec.errorRate < this.#errLo) {
172
189
  rec.degraded = false
190
+ this.#traceRelease(key, rec, 'error', rec.errorRate, now)
191
+ }
192
+ }
193
+
194
+ // Saturation-episode tracing (op 'undici:pressure'): one start/end doc pair
195
+ // per (origin, level) while a hysteresis latch is engaged — the scheduler
196
+ // limiter's limiter:backlog pattern, because an episode that can hang (an
197
+ // origin wedged under pressure) is exactly when in-progress visibility
198
+ // matters. `level` names the signal each latch trips on rather than the
199
+ // latch itself: shed -> 'some', paused -> 'full', degraded -> 'error' (the
200
+ // errorRate EWMA); `signal` is that EWMA at the transition. The write fn is
201
+ // captured once at engage and the SAME fn emits the release doc, so a writer
202
+ // toggling mid-episode can neither orphan nor split the pair — and a
203
+ // toggle-on mid-episode stays silent (nothing captured, no unpaired end).
204
+ #traceEngage(key, rec, level, signal, now) {
205
+ const write = traceWrite(this.#trace)
206
+ if (write !== null) {
207
+ rec.episodes ??= { some: null, full: null, error: null }
208
+ rec.episodes[level] = { write, start: now }
209
+ traceSafe(
210
+ write,
211
+ {
212
+ origin: traceUrl({ origin: key }),
213
+ level,
214
+ phase: 'start',
215
+ pending: rec.pending,
216
+ running: rec.running,
217
+ signal: Math.round(signal * 1000) / 1000,
218
+ },
219
+ 'undici:pressure',
220
+ )
221
+ }
222
+ }
223
+
224
+ #traceRelease(key, rec, level, signal, now) {
225
+ const episode = rec.episodes?.[level]
226
+ if (episode != null) {
227
+ // Clear the pairing state BEFORE invoking the writer (traceSafe only
228
+ // contains throws) so a fresh episode's capture can't be clobbered.
229
+ rec.episodes[level] = null
230
+ traceSafe(
231
+ episode.write,
232
+ {
233
+ origin: traceUrl({ origin: key }),
234
+ level,
235
+ phase: 'end',
236
+ pending: rec.pending,
237
+ running: rec.running,
238
+ signal: Math.round(signal * 1000) / 1000,
239
+ durationMs: Math.round(now - episode.start),
240
+ },
241
+ 'undici:pressure',
242
+ )
173
243
  }
174
244
  }
175
245
 
@@ -179,7 +249,7 @@ class PressureMonitor {
179
249
  #tick() {
180
250
  const now = performance.now()
181
251
  for (const [key, rec] of this.#origins) {
182
- this.#sample(rec, now)
252
+ this.#sample(key, rec, now)
183
253
  if (
184
254
  rec.pending === 0 &&
185
255
  rec.running === 0 &&
@@ -280,6 +350,19 @@ class PressureMonitor {
280
350
  clearInterval(this.#timer)
281
351
  this.#timer = null
282
352
  }
353
+ // Close still-engaged episodes before dropping the records: a monitor
354
+ // teardown is not a hang, and a start doc with no end would read as one
355
+ // forever (the pairing is exactly what open-episode alerting keys on).
356
+ // Emitted through each episode's captured fn, like any release.
357
+ const now = performance.now()
358
+ for (const [key, rec] of this.#origins) {
359
+ if (rec.episodes != null) {
360
+ // #traceRelease no-ops per level unless an episode was captured.
361
+ this.#traceRelease(key, rec, 'some', rec.some, now)
362
+ this.#traceRelease(key, rec, 'full', rec.full, now)
363
+ this.#traceRelease(key, rec, 'error', rec.errorRate, now)
364
+ }
365
+ }
283
366
  this.#origins.clear()
284
367
  }
285
368