@naturalcycles/backend-lib 9.84.1 → 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.
|
@@ -11,8 +11,16 @@ import type { Integer, NonNegativeInteger, NumberOfMilliseconds, NumberOfPercent
|
|
|
11
11
|
* reported by older interval-based sampling.
|
|
12
12
|
*
|
|
13
13
|
* Beware of CPU-throttled processes (e.g. idle CloudRun instances with request-based billing):
|
|
14
|
-
* the
|
|
15
|
-
*
|
|
14
|
+
* the time the process spent runnable-but-unscheduled is recorded as event loop delay,
|
|
15
|
+
* and the interval timer fires late (see `intervalMs`).
|
|
16
|
+
* Use `pause()` / `resume()` to keep such periods out of the delay samples,
|
|
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`).
|
|
16
24
|
*/
|
|
17
25
|
export declare class EventLoopMonitor {
|
|
18
26
|
constructor(cfg?: EventLoopMonitorCfg);
|
|
@@ -20,6 +28,18 @@ export declare class EventLoopMonitor {
|
|
|
20
28
|
private eld;
|
|
21
29
|
private lastElu;
|
|
22
30
|
private lastReportedAt;
|
|
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?;
|
|
23
43
|
/**
|
|
24
44
|
* Undefined until the first interval has completed.
|
|
25
45
|
*/
|
|
@@ -28,12 +48,43 @@ export declare class EventLoopMonitor {
|
|
|
28
48
|
gcCount: number;
|
|
29
49
|
gcTotalTime: number;
|
|
30
50
|
stop(): void;
|
|
51
|
+
/**
|
|
52
|
+
* Stops sampling event loop delay (and the stall watchdog) until `resume()` is called.
|
|
53
|
+
* GC and ELU keep being tracked, and the samples recorded so far are kept.
|
|
54
|
+
*
|
|
55
|
+
* Meant for periods when the process is known to be CPU-throttled, e.g. a CloudRun instance
|
|
56
|
+
* (request-based billing) with no request in flight: a delay recorded there measures
|
|
57
|
+
* the throttling, not the application.
|
|
58
|
+
*/
|
|
59
|
+
pause(): void;
|
|
60
|
+
/**
|
|
61
|
+
* Resumes sampling event loop delay after `pause()`.
|
|
62
|
+
* Safe to call from within the event that ended the throttled period (e.g. the middleware of
|
|
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.
|
|
65
|
+
*/
|
|
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;
|
|
31
76
|
}
|
|
77
|
+
export type GcKind = 'major' | 'minor' | 'incremental' | 'weakcb' | 'unknown';
|
|
32
78
|
export interface EventLoopMonitorCfg {
|
|
33
79
|
/**
|
|
34
80
|
* Defaults to 60_000 ms
|
|
35
81
|
*/
|
|
36
82
|
measureInterval?: NumberOfMilliseconds;
|
|
83
|
+
/**
|
|
84
|
+
* Period of the stall watchdog timer, see `EventLoopStats.stallMs`.
|
|
85
|
+
* Defaults to 1000 ms.
|
|
86
|
+
*/
|
|
87
|
+
watchdogInterval?: NumberOfMilliseconds;
|
|
37
88
|
/**
|
|
38
89
|
* Callback to be invoked with EventLoopStats.
|
|
39
90
|
* Called every `measureInterval` milliseconds.
|
|
@@ -72,6 +123,29 @@ export interface EventLoopStats {
|
|
|
72
123
|
* too "lossy")
|
|
73
124
|
*/
|
|
74
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;
|
|
75
149
|
/**
|
|
76
150
|
* Actual wall-clock duration of the interval covered by these stats.
|
|
77
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
|
┌────────┬──────────────────────────────────────────────┐
|
|
@@ -27,12 +27,21 @@ import { monitorEventLoopDelay, performance, PerformanceObserver } from 'node:pe
|
|
|
27
27
|
* reported by older interval-based sampling.
|
|
28
28
|
*
|
|
29
29
|
* Beware of CPU-throttled processes (e.g. idle CloudRun instances with request-based billing):
|
|
30
|
-
* the
|
|
31
|
-
*
|
|
30
|
+
* the time the process spent runnable-but-unscheduled is recorded as event loop delay,
|
|
31
|
+
* and the interval timer fires late (see `intervalMs`).
|
|
32
|
+
* Use `pause()` / `resume()` to keep such periods out of the delay samples,
|
|
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`).
|
|
32
40
|
*/
|
|
33
41
|
export class EventLoopMonitor {
|
|
34
42
|
constructor(cfg = {}) {
|
|
35
|
-
const { measureInterval = 60_000 } = cfg;
|
|
43
|
+
const { measureInterval = 60_000, watchdogInterval = 1000 } = cfg;
|
|
44
|
+
this.watchdogInterval = watchdogInterval;
|
|
36
45
|
const eldOptions = {
|
|
37
46
|
// Node >=24.19 / >=26.5: sample once per event loop iteration (uv_prepare/uv_check hooks),
|
|
38
47
|
// which measures actual per-iteration delay instead of timer jitter.
|
|
@@ -48,12 +57,17 @@ export class EventLoopMonitor {
|
|
|
48
57
|
for (const entry of list.getEntries()) {
|
|
49
58
|
this.gcCount++;
|
|
50
59
|
this.gcTotalTime += entry.duration;
|
|
60
|
+
if (entry.duration > this.gcMaxTime) {
|
|
61
|
+
this.gcMaxTime = entry.duration;
|
|
62
|
+
this.gcMaxKind = getGcKind(entry);
|
|
63
|
+
}
|
|
51
64
|
}
|
|
52
65
|
});
|
|
53
66
|
this.po.observe({ entryTypes: ['gc'] });
|
|
67
|
+
this.startWatchdog();
|
|
54
68
|
this.interval = setInterval(() => {
|
|
55
69
|
// Delay stats are reported in **nanoseconds**
|
|
56
|
-
const { eld, gcCount, gcTotalTime } = this;
|
|
70
|
+
const { eld, gcCount, gcTotalTime, gcMaxKind } = this;
|
|
57
71
|
const p50 = Math.round(eld.percentile(50) / 1e6);
|
|
58
72
|
const p90 = Math.round(eld.percentile(90) / 1e6);
|
|
59
73
|
const p99 = Math.round(eld.percentile(99) / 1e6);
|
|
@@ -70,6 +84,9 @@ export class EventLoopMonitor {
|
|
|
70
84
|
this.lastElu = currentElu;
|
|
71
85
|
const elu = Math.round(deltaElu.utilization * 100);
|
|
72
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);
|
|
73
90
|
this.lastStats = {
|
|
74
91
|
p50,
|
|
75
92
|
p90,
|
|
@@ -80,18 +97,38 @@ export class EventLoopMonitor {
|
|
|
80
97
|
gcCount,
|
|
81
98
|
gcTotalTime,
|
|
82
99
|
gcCPU,
|
|
100
|
+
gcMaxTime,
|
|
101
|
+
gcMaxKind,
|
|
102
|
+
stallMs,
|
|
103
|
+
stallCpuMs,
|
|
83
104
|
intervalMs,
|
|
84
105
|
};
|
|
85
106
|
cfg.onStats?.(this.lastStats);
|
|
86
107
|
eld.reset();
|
|
87
108
|
this.gcCount = 0;
|
|
88
109
|
this.gcTotalTime = 0;
|
|
110
|
+
this.gcMaxTime = 0;
|
|
111
|
+
this.gcMaxKind = undefined;
|
|
112
|
+
this.maxStallMs = 0;
|
|
113
|
+
this.maxStallCpuMs = 0;
|
|
89
114
|
}, measureInterval);
|
|
90
115
|
}
|
|
91
116
|
interval;
|
|
92
117
|
eld;
|
|
93
118
|
lastElu;
|
|
94
119
|
lastReportedAt;
|
|
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;
|
|
95
132
|
/**
|
|
96
133
|
* Undefined until the first interval has completed.
|
|
97
134
|
*/
|
|
@@ -101,7 +138,83 @@ export class EventLoopMonitor {
|
|
|
101
138
|
gcTotalTime = 0;
|
|
102
139
|
stop() {
|
|
103
140
|
clearInterval(this.interval);
|
|
141
|
+
this.stopWatchdog();
|
|
104
142
|
this.eld.disable();
|
|
105
143
|
this.po.disconnect();
|
|
106
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* Stops sampling event loop delay (and the stall watchdog) until `resume()` is called.
|
|
147
|
+
* GC and ELU keep being tracked, and the samples recorded so far are kept.
|
|
148
|
+
*
|
|
149
|
+
* Meant for periods when the process is known to be CPU-throttled, e.g. a CloudRun instance
|
|
150
|
+
* (request-based billing) with no request in flight: a delay recorded there measures
|
|
151
|
+
* the throttling, not the application.
|
|
152
|
+
*/
|
|
153
|
+
pause() {
|
|
154
|
+
this.paused = true;
|
|
155
|
+
this.eld.disable();
|
|
156
|
+
this.stopWatchdog();
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Resumes sampling event loop delay after `pause()`.
|
|
160
|
+
* Safe to call from within the event that ended the throttled period (e.g. the middleware of
|
|
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.
|
|
163
|
+
*/
|
|
164
|
+
resume() {
|
|
165
|
+
if (!this.paused)
|
|
166
|
+
return;
|
|
167
|
+
this.paused = false;
|
|
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;
|
|
198
|
+
}
|
|
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;
|
|
107
220
|
}
|
package/package.json
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
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,
|
|
@@ -36,12 +41,21 @@ import type {
|
|
|
36
41
|
* reported by older interval-based sampling.
|
|
37
42
|
*
|
|
38
43
|
* Beware of CPU-throttled processes (e.g. idle CloudRun instances with request-based billing):
|
|
39
|
-
* the
|
|
40
|
-
*
|
|
44
|
+
* the time the process spent runnable-but-unscheduled is recorded as event loop delay,
|
|
45
|
+
* and the interval timer fires late (see `intervalMs`).
|
|
46
|
+
* Use `pause()` / `resume()` to keep such periods out of the delay samples,
|
|
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`).
|
|
41
54
|
*/
|
|
42
55
|
export class EventLoopMonitor {
|
|
43
56
|
constructor(cfg: EventLoopMonitorCfg = {}) {
|
|
44
|
-
const { measureInterval = 60_000 } = cfg
|
|
57
|
+
const { measureInterval = 60_000, watchdogInterval = 1000 } = cfg
|
|
58
|
+
this.watchdogInterval = watchdogInterval
|
|
45
59
|
|
|
46
60
|
const eldOptions: EventLoopMonitorOptions = {
|
|
47
61
|
// Node >=24.19 / >=26.5: sample once per event loop iteration (uv_prepare/uv_check hooks),
|
|
@@ -60,14 +74,20 @@ export class EventLoopMonitor {
|
|
|
60
74
|
for (const entry of list.getEntries()) {
|
|
61
75
|
this.gcCount++
|
|
62
76
|
this.gcTotalTime += entry.duration
|
|
77
|
+
if (entry.duration > this.gcMaxTime) {
|
|
78
|
+
this.gcMaxTime = entry.duration
|
|
79
|
+
this.gcMaxKind = getGcKind(entry)
|
|
80
|
+
}
|
|
63
81
|
}
|
|
64
82
|
})
|
|
65
83
|
|
|
66
84
|
this.po.observe({ entryTypes: ['gc'] })
|
|
67
85
|
|
|
86
|
+
this.startWatchdog()
|
|
87
|
+
|
|
68
88
|
this.interval = setInterval(() => {
|
|
69
89
|
// Delay stats are reported in **nanoseconds**
|
|
70
|
-
const { eld, gcCount, gcTotalTime } = this
|
|
90
|
+
const { eld, gcCount, gcTotalTime, gcMaxKind } = this
|
|
71
91
|
const p50 = Math.round(eld.percentile(50) / 1e6)
|
|
72
92
|
const p90 = Math.round(eld.percentile(90) / 1e6)
|
|
73
93
|
const p99 = Math.round(eld.percentile(99) / 1e6)
|
|
@@ -87,6 +107,9 @@ export class EventLoopMonitor {
|
|
|
87
107
|
|
|
88
108
|
const elu = Math.round(deltaElu.utilization * 100)
|
|
89
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)
|
|
90
113
|
|
|
91
114
|
this.lastStats = {
|
|
92
115
|
p50,
|
|
@@ -98,6 +121,10 @@ export class EventLoopMonitor {
|
|
|
98
121
|
gcCount,
|
|
99
122
|
gcTotalTime,
|
|
100
123
|
gcCPU,
|
|
124
|
+
gcMaxTime,
|
|
125
|
+
gcMaxKind,
|
|
126
|
+
stallMs,
|
|
127
|
+
stallCpuMs,
|
|
101
128
|
intervalMs,
|
|
102
129
|
}
|
|
103
130
|
|
|
@@ -106,6 +133,10 @@ export class EventLoopMonitor {
|
|
|
106
133
|
eld.reset()
|
|
107
134
|
this.gcCount = 0
|
|
108
135
|
this.gcTotalTime = 0
|
|
136
|
+
this.gcMaxTime = 0
|
|
137
|
+
this.gcMaxKind = undefined
|
|
138
|
+
this.maxStallMs = 0
|
|
139
|
+
this.maxStallCpuMs = 0
|
|
109
140
|
}, measureInterval)
|
|
110
141
|
}
|
|
111
142
|
|
|
@@ -113,6 +144,18 @@ export class EventLoopMonitor {
|
|
|
113
144
|
private eld: ELDHistogram
|
|
114
145
|
private lastElu: EventLoopUtilization
|
|
115
146
|
private lastReportedAt: NumberOfMilliseconds
|
|
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
|
|
116
159
|
/**
|
|
117
160
|
* Undefined until the first interval has completed.
|
|
118
161
|
*/
|
|
@@ -123,18 +166,103 @@ export class EventLoopMonitor {
|
|
|
123
166
|
|
|
124
167
|
stop(): void {
|
|
125
168
|
clearInterval(this.interval)
|
|
169
|
+
this.stopWatchdog()
|
|
126
170
|
this.eld.disable()
|
|
127
171
|
this.po.disconnect()
|
|
128
172
|
}
|
|
129
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Stops sampling event loop delay (and the stall watchdog) until `resume()` is called.
|
|
176
|
+
* GC and ELU keep being tracked, and the samples recorded so far are kept.
|
|
177
|
+
*
|
|
178
|
+
* Meant for periods when the process is known to be CPU-throttled, e.g. a CloudRun instance
|
|
179
|
+
* (request-based billing) with no request in flight: a delay recorded there measures
|
|
180
|
+
* the throttling, not the application.
|
|
181
|
+
*/
|
|
182
|
+
pause(): void {
|
|
183
|
+
this.paused = true
|
|
184
|
+
this.eld.disable()
|
|
185
|
+
this.stopWatchdog()
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Resumes sampling event loop delay after `pause()`.
|
|
190
|
+
* Safe to call from within the event that ended the throttled period (e.g. the middleware of
|
|
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.
|
|
193
|
+
*/
|
|
194
|
+
resume(): void {
|
|
195
|
+
if (!this.paused) return
|
|
196
|
+
this.paused = false
|
|
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
|
|
230
|
+
}
|
|
231
|
+
|
|
130
232
|
// cfg: Required<EventLoopMonitorCfg>
|
|
131
233
|
}
|
|
132
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
|
+
|
|
133
256
|
export interface EventLoopMonitorCfg {
|
|
134
257
|
/**
|
|
135
258
|
* Defaults to 60_000 ms
|
|
136
259
|
*/
|
|
137
260
|
measureInterval?: NumberOfMilliseconds
|
|
261
|
+
/**
|
|
262
|
+
* Period of the stall watchdog timer, see `EventLoopStats.stallMs`.
|
|
263
|
+
* Defaults to 1000 ms.
|
|
264
|
+
*/
|
|
265
|
+
watchdogInterval?: NumberOfMilliseconds
|
|
138
266
|
/**
|
|
139
267
|
* Callback to be invoked with EventLoopStats.
|
|
140
268
|
* Called every `measureInterval` milliseconds.
|
|
@@ -174,6 +302,29 @@ export interface EventLoopStats {
|
|
|
174
302
|
* too "lossy")
|
|
175
303
|
*/
|
|
176
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
|
|
177
328
|
/**
|
|
178
329
|
* Actual wall-clock duration of the interval covered by these stats.
|
|
179
330
|
* Normally equals `measureInterval`, but is longer when the timer fired late,
|