@naturalcycles/backend-lib 9.84.2 → 9.85.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.
@@ -15,6 +15,12 @@ import type { Integer, NonNegativeInteger, NumberOfMilliseconds, NumberOfPercent
15
15
  * and the interval timer fires late (see `intervalMs`).
16
16
  * Use `pause()` / `resume()` to keep such periods out of the delay samples,
17
17
  * e.g. pause when the last in-flight request finishes and resume when the next one arrives.
18
+ *
19
+ * Besides the delay histogram, a watchdog timer measures the largest stall of each interval
20
+ * together with the CPU the event loop thread consumed across it (`stallMs` / `stallCpuMs`),
21
+ * which tells a stall caused by own work (sync code, GC) from one caused by the thread not
22
+ * being scheduled.
23
+ * The longest single GC pause is reported too (`gcMaxTime` / `gcMaxKind`).
18
24
  */
19
25
  export declare class EventLoopMonitor {
20
26
  constructor(cfg?: EventLoopMonitorCfg);
@@ -23,6 +29,17 @@ export declare class EventLoopMonitor {
23
29
  private lastElu;
24
30
  private lastReportedAt;
25
31
  private paused;
32
+ private readonly watchdogInterval;
33
+ private watchdog?;
34
+ /**
35
+ * performance.now() and process CPU (microseconds) at the last watchdog tick.
36
+ */
37
+ private watchdogAt;
38
+ private watchdogCpuUs;
39
+ private maxStallMs;
40
+ private maxStallCpuMs;
41
+ private gcMaxTime;
42
+ private gcMaxKind?;
26
43
  /**
27
44
  * Undefined until the first interval has completed.
28
45
  */
@@ -32,7 +49,7 @@ export declare class EventLoopMonitor {
32
49
  gcTotalTime: number;
33
50
  stop(): void;
34
51
  /**
35
- * Stops sampling event loop delay until `resume()` is called.
52
+ * Stops sampling event loop delay (and the stall watchdog) until `resume()` is called.
36
53
  * GC and ELU keep being tracked, and the samples recorded so far are kept.
37
54
  *
38
55
  * Meant for periods when the process is known to be CPU-throttled, e.g. a CloudRun instance
@@ -43,16 +60,31 @@ export declare class EventLoopMonitor {
43
60
  /**
44
61
  * Resumes sampling event loop delay after `pause()`.
45
62
  * Safe to call from within the event that ended the throttled period (e.g. the middleware of
46
- * the request that woke the instance): the histogram restarts its clock from this moment,
47
- * so the stall that preceded it in the same loop iteration is not sampled.
63
+ * the request that woke the instance): the histogram and the watchdog restart their clocks
64
+ * from this moment, so the stall that preceded it in the same loop iteration is not sampled.
48
65
  */
49
66
  resume(): void;
67
+ private startWatchdog;
68
+ private stopWatchdog;
69
+ /**
70
+ * A tick that fires late means the loop could not turn in between: a long synchronous callback,
71
+ * a long GC pause, or the process not being scheduled at all. The CPU consumed across the gap
72
+ * tells these apart. Node reschedules a late interval from the actual fire time, so one stall
73
+ * produces exactly one late tick.
74
+ */
75
+ private onWatchdogTick;
50
76
  }
77
+ export type GcKind = 'major' | 'minor' | 'incremental' | 'weakcb' | 'unknown';
51
78
  export interface EventLoopMonitorCfg {
52
79
  /**
53
80
  * Defaults to 60_000 ms
54
81
  */
55
82
  measureInterval?: NumberOfMilliseconds;
83
+ /**
84
+ * Period of the stall watchdog timer, see `EventLoopStats.stallMs`.
85
+ * Defaults to 1000 ms.
86
+ */
87
+ watchdogInterval?: NumberOfMilliseconds;
56
88
  /**
57
89
  * Callback to be invoked with EventLoopStats.
58
90
  * Called every `measureInterval` milliseconds.
@@ -91,6 +123,29 @@ export interface EventLoopStats {
91
123
  * too "lossy")
92
124
  */
93
125
  gcCPU: Integer;
126
+ /**
127
+ * Longest single GC pause in the last interval.
128
+ */
129
+ gcMaxTime: NumberOfMilliseconds;
130
+ /**
131
+ * Kind of the longest GC pause. Undefined when no GC ran in the interval.
132
+ */
133
+ gcMaxKind: GcKind | undefined;
134
+ /**
135
+ * Largest stall of the interval, as seen by the watchdog timer: how late its tick fired,
136
+ * 0 when every tick was on time. Covers anything that kept the loop from turning:
137
+ * a long synchronous callback, a long GC pause, or the process not being scheduled at all.
138
+ * Underestimates the stall by up to `watchdogInterval` (the tick may have been due anywhere
139
+ * within its period when the stall began). Not measured while paused.
140
+ */
141
+ stallMs: NumberOfMilliseconds;
142
+ /**
143
+ * CPU time of the event loop thread consumed between the watchdog tick before the largest
144
+ * stall and the late tick that measured it.
145
+ * Close to `stallMs` means the thread was running (own code or GC, see `gcMaxTime`),
146
+ * close to 0 means it was not scheduled (platform pause, CPU throttling).
147
+ */
148
+ stallCpuMs: NumberOfMilliseconds;
94
149
  /**
95
150
  * Actual wall-clock duration of the interval covered by these stats.
96
151
  * Normally equals `measureInterval`, but is longer when the timer fired late,
@@ -1,4 +1,4 @@
1
- import { monitorEventLoopDelay, performance, PerformanceObserver } from 'node:perf_hooks';
1
+ import { constants, monitorEventLoopDelay, performance, PerformanceObserver } from 'node:perf_hooks';
2
2
  /*
3
3
  Healthy gcCPU Ranges
4
4
  ┌────────┬──────────────────────────────────────────────┐
@@ -31,10 +31,17 @@ import { monitorEventLoopDelay, performance, PerformanceObserver } from 'node:pe
31
31
  * and the interval timer fires late (see `intervalMs`).
32
32
  * Use `pause()` / `resume()` to keep such periods out of the delay samples,
33
33
  * e.g. pause when the last in-flight request finishes and resume when the next one arrives.
34
+ *
35
+ * Besides the delay histogram, a watchdog timer measures the largest stall of each interval
36
+ * together with the CPU the event loop thread consumed across it (`stallMs` / `stallCpuMs`),
37
+ * which tells a stall caused by own work (sync code, GC) from one caused by the thread not
38
+ * being scheduled.
39
+ * The longest single GC pause is reported too (`gcMaxTime` / `gcMaxKind`).
34
40
  */
35
41
  export class EventLoopMonitor {
36
42
  constructor(cfg = {}) {
37
- const { measureInterval = 60_000 } = cfg;
43
+ const { measureInterval = 60_000, watchdogInterval = 1000 } = cfg;
44
+ this.watchdogInterval = watchdogInterval;
38
45
  const eldOptions = {
39
46
  // Node >=24.19 / >=26.5: sample once per event loop iteration (uv_prepare/uv_check hooks),
40
47
  // which measures actual per-iteration delay instead of timer jitter.
@@ -50,12 +57,17 @@ export class EventLoopMonitor {
50
57
  for (const entry of list.getEntries()) {
51
58
  this.gcCount++;
52
59
  this.gcTotalTime += entry.duration;
60
+ if (entry.duration > this.gcMaxTime) {
61
+ this.gcMaxTime = entry.duration;
62
+ this.gcMaxKind = getGcKind(entry);
63
+ }
53
64
  }
54
65
  });
55
66
  this.po.observe({ entryTypes: ['gc'] });
67
+ this.startWatchdog();
56
68
  this.interval = setInterval(() => {
57
69
  // Delay stats are reported in **nanoseconds**
58
- const { eld, gcCount, gcTotalTime } = this;
70
+ const { eld, gcCount, gcTotalTime, gcMaxKind } = this;
59
71
  const p50 = Math.round(eld.percentile(50) / 1e6);
60
72
  const p90 = Math.round(eld.percentile(90) / 1e6);
61
73
  const p99 = Math.round(eld.percentile(99) / 1e6);
@@ -72,6 +84,9 @@ export class EventLoopMonitor {
72
84
  this.lastElu = currentElu;
73
85
  const elu = Math.round(deltaElu.utilization * 100);
74
86
  const gcCPU = Math.round((gcTotalTime / intervalMs) * 10_000);
87
+ const gcMaxTime = Math.round(this.gcMaxTime);
88
+ const stallMs = Math.round(this.maxStallMs);
89
+ const stallCpuMs = Math.round(this.maxStallCpuMs);
75
90
  this.lastStats = {
76
91
  p50,
77
92
  p90,
@@ -82,12 +97,20 @@ export class EventLoopMonitor {
82
97
  gcCount,
83
98
  gcTotalTime,
84
99
  gcCPU,
100
+ gcMaxTime,
101
+ gcMaxKind,
102
+ stallMs,
103
+ stallCpuMs,
85
104
  intervalMs,
86
105
  };
87
106
  cfg.onStats?.(this.lastStats);
88
107
  eld.reset();
89
108
  this.gcCount = 0;
90
109
  this.gcTotalTime = 0;
110
+ this.gcMaxTime = 0;
111
+ this.gcMaxKind = undefined;
112
+ this.maxStallMs = 0;
113
+ this.maxStallCpuMs = 0;
91
114
  }, measureInterval);
92
115
  }
93
116
  interval;
@@ -95,6 +118,17 @@ export class EventLoopMonitor {
95
118
  lastElu;
96
119
  lastReportedAt;
97
120
  paused = false;
121
+ watchdogInterval;
122
+ watchdog;
123
+ /**
124
+ * performance.now() and process CPU (microseconds) at the last watchdog tick.
125
+ */
126
+ watchdogAt = 0;
127
+ watchdogCpuUs = 0;
128
+ maxStallMs = 0;
129
+ maxStallCpuMs = 0;
130
+ gcMaxTime = 0;
131
+ gcMaxKind;
98
132
  /**
99
133
  * Undefined until the first interval has completed.
100
134
  */
@@ -104,11 +138,12 @@ export class EventLoopMonitor {
104
138
  gcTotalTime = 0;
105
139
  stop() {
106
140
  clearInterval(this.interval);
141
+ this.stopWatchdog();
107
142
  this.eld.disable();
108
143
  this.po.disconnect();
109
144
  }
110
145
  /**
111
- * Stops sampling event loop delay until `resume()` is called.
146
+ * Stops sampling event loop delay (and the stall watchdog) until `resume()` is called.
112
147
  * GC and ELU keep being tracked, and the samples recorded so far are kept.
113
148
  *
114
149
  * Meant for periods when the process is known to be CPU-throttled, e.g. a CloudRun instance
@@ -118,17 +153,68 @@ export class EventLoopMonitor {
118
153
  pause() {
119
154
  this.paused = true;
120
155
  this.eld.disable();
156
+ this.stopWatchdog();
121
157
  }
122
158
  /**
123
159
  * Resumes sampling event loop delay after `pause()`.
124
160
  * Safe to call from within the event that ended the throttled period (e.g. the middleware of
125
- * the request that woke the instance): the histogram restarts its clock from this moment,
126
- * so the stall that preceded it in the same loop iteration is not sampled.
161
+ * the request that woke the instance): the histogram and the watchdog restart their clocks
162
+ * from this moment, so the stall that preceded it in the same loop iteration is not sampled.
127
163
  */
128
164
  resume() {
129
165
  if (!this.paused)
130
166
  return;
131
167
  this.paused = false;
132
168
  this.eld.enable();
169
+ this.startWatchdog();
170
+ }
171
+ startWatchdog() {
172
+ this.watchdogAt = performance.now();
173
+ this.watchdogCpuUs = getCpuUs();
174
+ this.watchdog = setInterval(() => this.onWatchdogTick(), this.watchdogInterval);
175
+ // Must not keep the process alive on its own (the main interval already does, until `stop()`)
176
+ this.watchdog.unref();
177
+ }
178
+ stopWatchdog() {
179
+ clearInterval(this.watchdog);
180
+ this.watchdog = undefined;
181
+ }
182
+ /**
183
+ * A tick that fires late means the loop could not turn in between: a long synchronous callback,
184
+ * a long GC pause, or the process not being scheduled at all. The CPU consumed across the gap
185
+ * tells these apart. Node reschedules a late interval from the actual fire time, so one stall
186
+ * produces exactly one late tick.
187
+ */
188
+ onWatchdogTick() {
189
+ const now = performance.now();
190
+ const cpuUs = getCpuUs();
191
+ const stallMs = now - this.watchdogAt - this.watchdogInterval;
192
+ if (stallMs > this.maxStallMs) {
193
+ this.maxStallMs = stallMs;
194
+ this.maxStallCpuMs = (cpuUs - this.watchdogCpuUs) / 1000;
195
+ }
196
+ this.watchdogAt = now;
197
+ this.watchdogCpuUs = cpuUs;
133
198
  }
134
199
  }
200
+ function getGcKind(entry) {
201
+ // `detail` is only declared on Node's PerformanceNodeEntry type, which getEntries() doesn't return
202
+ const kind = entry.detail?.kind;
203
+ if (kind === constants.NODE_PERFORMANCE_GC_MAJOR)
204
+ return 'major';
205
+ if (kind === constants.NODE_PERFORMANCE_GC_MINOR)
206
+ return 'minor';
207
+ if (kind === constants.NODE_PERFORMANCE_GC_INCREMENTAL)
208
+ return 'incremental';
209
+ if (kind === constants.NODE_PERFORMANCE_GC_WEAKCB)
210
+ return 'weakcb';
211
+ return 'unknown';
212
+ }
213
+ /**
214
+ * CPU time of the current (event loop) thread in microseconds, so that worker threads,
215
+ * the libuv threadpool and parallel GC helpers don't count.
216
+ */
217
+ function getCpuUs() {
218
+ const { user, system } = process.threadCpuUsage();
219
+ return user + system;
220
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@naturalcycles/backend-lib",
3
3
  "type": "module",
4
- "version": "9.84.2",
4
+ "version": "9.85.0",
5
5
  "dependencies": {
6
6
  "@naturalcycles/db-lib": "^10",
7
7
  "@naturalcycles/js-lib": "^15",
@@ -1,5 +1,10 @@
1
- import type { ELDHistogram, EventLoopMonitorOptions, EventLoopUtilization } from 'node:perf_hooks'
2
- import { monitorEventLoopDelay, performance, PerformanceObserver } from 'node:perf_hooks'
1
+ import type {
2
+ ELDHistogram,
3
+ EventLoopMonitorOptions,
4
+ EventLoopUtilization,
5
+ PerformanceEntry,
6
+ } from 'node:perf_hooks'
7
+ import { constants, monitorEventLoopDelay, performance, PerformanceObserver } from 'node:perf_hooks'
3
8
  import type {
4
9
  Integer,
5
10
  NonNegativeInteger,
@@ -40,10 +45,17 @@ import type {
40
45
  * and the interval timer fires late (see `intervalMs`).
41
46
  * Use `pause()` / `resume()` to keep such periods out of the delay samples,
42
47
  * e.g. pause when the last in-flight request finishes and resume when the next one arrives.
48
+ *
49
+ * Besides the delay histogram, a watchdog timer measures the largest stall of each interval
50
+ * together with the CPU the event loop thread consumed across it (`stallMs` / `stallCpuMs`),
51
+ * which tells a stall caused by own work (sync code, GC) from one caused by the thread not
52
+ * being scheduled.
53
+ * The longest single GC pause is reported too (`gcMaxTime` / `gcMaxKind`).
43
54
  */
44
55
  export class EventLoopMonitor {
45
56
  constructor(cfg: EventLoopMonitorCfg = {}) {
46
- const { measureInterval = 60_000 } = cfg
57
+ const { measureInterval = 60_000, watchdogInterval = 1000 } = cfg
58
+ this.watchdogInterval = watchdogInterval
47
59
 
48
60
  const eldOptions: EventLoopMonitorOptions = {
49
61
  // Node >=24.19 / >=26.5: sample once per event loop iteration (uv_prepare/uv_check hooks),
@@ -62,14 +74,20 @@ export class EventLoopMonitor {
62
74
  for (const entry of list.getEntries()) {
63
75
  this.gcCount++
64
76
  this.gcTotalTime += entry.duration
77
+ if (entry.duration > this.gcMaxTime) {
78
+ this.gcMaxTime = entry.duration
79
+ this.gcMaxKind = getGcKind(entry)
80
+ }
65
81
  }
66
82
  })
67
83
 
68
84
  this.po.observe({ entryTypes: ['gc'] })
69
85
 
86
+ this.startWatchdog()
87
+
70
88
  this.interval = setInterval(() => {
71
89
  // Delay stats are reported in **nanoseconds**
72
- const { eld, gcCount, gcTotalTime } = this
90
+ const { eld, gcCount, gcTotalTime, gcMaxKind } = this
73
91
  const p50 = Math.round(eld.percentile(50) / 1e6)
74
92
  const p90 = Math.round(eld.percentile(90) / 1e6)
75
93
  const p99 = Math.round(eld.percentile(99) / 1e6)
@@ -89,6 +107,9 @@ export class EventLoopMonitor {
89
107
 
90
108
  const elu = Math.round(deltaElu.utilization * 100)
91
109
  const gcCPU = Math.round((gcTotalTime / intervalMs) * 10_000)
110
+ const gcMaxTime = Math.round(this.gcMaxTime)
111
+ const stallMs = Math.round(this.maxStallMs)
112
+ const stallCpuMs = Math.round(this.maxStallCpuMs)
92
113
 
93
114
  this.lastStats = {
94
115
  p50,
@@ -100,6 +121,10 @@ export class EventLoopMonitor {
100
121
  gcCount,
101
122
  gcTotalTime,
102
123
  gcCPU,
124
+ gcMaxTime,
125
+ gcMaxKind,
126
+ stallMs,
127
+ stallCpuMs,
103
128
  intervalMs,
104
129
  }
105
130
 
@@ -108,6 +133,10 @@ export class EventLoopMonitor {
108
133
  eld.reset()
109
134
  this.gcCount = 0
110
135
  this.gcTotalTime = 0
136
+ this.gcMaxTime = 0
137
+ this.gcMaxKind = undefined
138
+ this.maxStallMs = 0
139
+ this.maxStallCpuMs = 0
111
140
  }, measureInterval)
112
141
  }
113
142
 
@@ -116,6 +145,17 @@ export class EventLoopMonitor {
116
145
  private lastElu: EventLoopUtilization
117
146
  private lastReportedAt: NumberOfMilliseconds
118
147
  private paused = false
148
+ private readonly watchdogInterval: NumberOfMilliseconds
149
+ private watchdog?: NodeJS.Timeout
150
+ /**
151
+ * performance.now() and process CPU (microseconds) at the last watchdog tick.
152
+ */
153
+ private watchdogAt = 0
154
+ private watchdogCpuUs = 0
155
+ private maxStallMs = 0
156
+ private maxStallCpuMs = 0
157
+ private gcMaxTime = 0
158
+ private gcMaxKind?: GcKind
119
159
  /**
120
160
  * Undefined until the first interval has completed.
121
161
  */
@@ -126,12 +166,13 @@ export class EventLoopMonitor {
126
166
 
127
167
  stop(): void {
128
168
  clearInterval(this.interval)
169
+ this.stopWatchdog()
129
170
  this.eld.disable()
130
171
  this.po.disconnect()
131
172
  }
132
173
 
133
174
  /**
134
- * Stops sampling event loop delay until `resume()` is called.
175
+ * Stops sampling event loop delay (and the stall watchdog) until `resume()` is called.
135
176
  * GC and ELU keep being tracked, and the samples recorded so far are kept.
136
177
  *
137
178
  * Meant for periods when the process is known to be CPU-throttled, e.g. a CloudRun instance
@@ -141,28 +182,87 @@ export class EventLoopMonitor {
141
182
  pause(): void {
142
183
  this.paused = true
143
184
  this.eld.disable()
185
+ this.stopWatchdog()
144
186
  }
145
187
 
146
188
  /**
147
189
  * Resumes sampling event loop delay after `pause()`.
148
190
  * Safe to call from within the event that ended the throttled period (e.g. the middleware of
149
- * the request that woke the instance): the histogram restarts its clock from this moment,
150
- * so the stall that preceded it in the same loop iteration is not sampled.
191
+ * the request that woke the instance): the histogram and the watchdog restart their clocks
192
+ * from this moment, so the stall that preceded it in the same loop iteration is not sampled.
151
193
  */
152
194
  resume(): void {
153
195
  if (!this.paused) return
154
196
  this.paused = false
155
197
  this.eld.enable()
198
+ this.startWatchdog()
199
+ }
200
+
201
+ private startWatchdog(): void {
202
+ this.watchdogAt = performance.now()
203
+ this.watchdogCpuUs = getCpuUs()
204
+ this.watchdog = setInterval(() => this.onWatchdogTick(), this.watchdogInterval)
205
+ // Must not keep the process alive on its own (the main interval already does, until `stop()`)
206
+ this.watchdog.unref()
207
+ }
208
+
209
+ private stopWatchdog(): void {
210
+ clearInterval(this.watchdog)
211
+ this.watchdog = undefined
212
+ }
213
+
214
+ /**
215
+ * A tick that fires late means the loop could not turn in between: a long synchronous callback,
216
+ * a long GC pause, or the process not being scheduled at all. The CPU consumed across the gap
217
+ * tells these apart. Node reschedules a late interval from the actual fire time, so one stall
218
+ * produces exactly one late tick.
219
+ */
220
+ private onWatchdogTick(): void {
221
+ const now = performance.now()
222
+ const cpuUs = getCpuUs()
223
+ const stallMs = now - this.watchdogAt - this.watchdogInterval
224
+ if (stallMs > this.maxStallMs) {
225
+ this.maxStallMs = stallMs
226
+ this.maxStallCpuMs = (cpuUs - this.watchdogCpuUs) / 1000
227
+ }
228
+ this.watchdogAt = now
229
+ this.watchdogCpuUs = cpuUs
156
230
  }
157
231
 
158
232
  // cfg: Required<EventLoopMonitorCfg>
159
233
  }
160
234
 
235
+ function getGcKind(entry: PerformanceEntry): GcKind {
236
+ // `detail` is only declared on Node's PerformanceNodeEntry type, which getEntries() doesn't return
237
+ const kind = (entry as { detail?: { kind?: number } }).detail?.kind
238
+ if (kind === constants.NODE_PERFORMANCE_GC_MAJOR) return 'major'
239
+ if (kind === constants.NODE_PERFORMANCE_GC_MINOR) return 'minor'
240
+ if (kind === constants.NODE_PERFORMANCE_GC_INCREMENTAL) return 'incremental'
241
+ if (kind === constants.NODE_PERFORMANCE_GC_WEAKCB) return 'weakcb'
242
+ return 'unknown'
243
+ }
244
+
245
+ /**
246
+ * CPU time of the current (event loop) thread in microseconds, so that worker threads,
247
+ * the libuv threadpool and parallel GC helpers don't count.
248
+ */
249
+ function getCpuUs(): number {
250
+ const { user, system } = process.threadCpuUsage()
251
+ return user + system
252
+ }
253
+
254
+ export type GcKind = 'major' | 'minor' | 'incremental' | 'weakcb' | 'unknown'
255
+
161
256
  export interface EventLoopMonitorCfg {
162
257
  /**
163
258
  * Defaults to 60_000 ms
164
259
  */
165
260
  measureInterval?: NumberOfMilliseconds
261
+ /**
262
+ * Period of the stall watchdog timer, see `EventLoopStats.stallMs`.
263
+ * Defaults to 1000 ms.
264
+ */
265
+ watchdogInterval?: NumberOfMilliseconds
166
266
  /**
167
267
  * Callback to be invoked with EventLoopStats.
168
268
  * Called every `measureInterval` milliseconds.
@@ -202,6 +302,29 @@ export interface EventLoopStats {
202
302
  * too "lossy")
203
303
  */
204
304
  gcCPU: Integer
305
+ /**
306
+ * Longest single GC pause in the last interval.
307
+ */
308
+ gcMaxTime: NumberOfMilliseconds
309
+ /**
310
+ * Kind of the longest GC pause. Undefined when no GC ran in the interval.
311
+ */
312
+ gcMaxKind: GcKind | undefined
313
+ /**
314
+ * Largest stall of the interval, as seen by the watchdog timer: how late its tick fired,
315
+ * 0 when every tick was on time. Covers anything that kept the loop from turning:
316
+ * a long synchronous callback, a long GC pause, or the process not being scheduled at all.
317
+ * Underestimates the stall by up to `watchdogInterval` (the tick may have been due anywhere
318
+ * within its period when the stall began). Not measured while paused.
319
+ */
320
+ stallMs: NumberOfMilliseconds
321
+ /**
322
+ * CPU time of the event loop thread consumed between the watchdog tick before the largest
323
+ * stall and the late tick that measured it.
324
+ * Close to `stallMs` means the thread was running (own code or GC, see `gcMaxTime`),
325
+ * close to 0 means it was not scheduled (platform pause, CPU throttling).
326
+ */
327
+ stallCpuMs: NumberOfMilliseconds
205
328
  /**
206
329
  * Actual wall-clock duration of the interval covered by these stats.
207
330
  * Normally equals `measureInterval`, but is longer when the timer fired late,