node-loop-detective 1.0.2 → 1.2.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.
- package/README.md +37 -5
- package/bin/cli.js +5 -0
- package/package.json +1 -1
- package/src/analyzer.js +13 -39
- package/src/detective.js +264 -28
- package/src/inspector.js +11 -6
- package/src/reporter.js +70 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# node-loop-detective 🔍
|
|
2
2
|
|
|
3
|
-
Detect event loop blocking
|
|
3
|
+
Detect event loop blocking, lag, and slow async I/O in **running** Node.js apps — without code changes or restarts.
|
|
4
4
|
|
|
5
5
|
```
|
|
6
6
|
$ loop-detective 12345
|
|
@@ -10,6 +10,8 @@ $ loop-detective 12345
|
|
|
10
10
|
|
|
11
11
|
⚠ Event loop lag: 312ms at 2025-01-15T10:23:45.123Z
|
|
12
12
|
⚠ Event loop lag: 156ms at 2025-01-15T10:23:48.456Z
|
|
13
|
+
🌐 Slow HTTP: 2340ms GET api.example.com/users → 200
|
|
14
|
+
🔌 Slow TCP: 1520ms db-server:3306
|
|
13
15
|
|
|
14
16
|
────────────────────────────────────────────────────────────
|
|
15
17
|
Event Loop Detective Report
|
|
@@ -28,6 +30,17 @@ $ loop-detective 12345
|
|
|
28
30
|
1. heavyComputation
|
|
29
31
|
██████████████░░░░░░ 6245ms (62.3%)
|
|
30
32
|
/app/server.js:42:1
|
|
33
|
+
|
|
34
|
+
⚠ Slow Async I/O Summary
|
|
35
|
+
Total slow ops: 3
|
|
36
|
+
|
|
37
|
+
🌐 HTTP — 2 slow ops, avg 1800ms, max 2340ms
|
|
38
|
+
GET api.example.com/users
|
|
39
|
+
2 calls, total 3600ms, avg 1800ms, max 2340ms
|
|
40
|
+
|
|
41
|
+
🔌 TCP — 1 slow ops, avg 1520ms, max 1520ms
|
|
42
|
+
db-server:3306
|
|
43
|
+
1 calls, total 1520ms, max 1520ms
|
|
31
44
|
```
|
|
32
45
|
|
|
33
46
|
## How It Works
|
|
@@ -35,9 +48,10 @@ $ loop-detective 12345
|
|
|
35
48
|
1. Sends `SIGUSR1` to activate the Node.js built-in inspector (or connects to `--port`)
|
|
36
49
|
2. Connects via Chrome DevTools Protocol (CDP)
|
|
37
50
|
3. Injects a lightweight event loop lag monitor
|
|
38
|
-
4.
|
|
39
|
-
5.
|
|
40
|
-
6.
|
|
51
|
+
4. Tracks slow async I/O (HTTP, DNS, TCP) via monkey-patching
|
|
52
|
+
5. Captures a CPU profile to identify blocking code
|
|
53
|
+
6. Analyzes the profile for common blocking patterns
|
|
54
|
+
7. Disconnects cleanly — minimal impact on your running app
|
|
41
55
|
|
|
42
56
|
## Install
|
|
43
57
|
|
|
@@ -57,6 +71,9 @@ loop-detective --port 9229
|
|
|
57
71
|
# Profile for 30 seconds with 100ms lag threshold
|
|
58
72
|
loop-detective -p 12345 -d 30 -t 100
|
|
59
73
|
|
|
74
|
+
# Detect slow I/O with a 1-second threshold
|
|
75
|
+
loop-detective -p 12345 --io-threshold 1000
|
|
76
|
+
|
|
60
77
|
# Continuous monitoring mode
|
|
61
78
|
loop-detective -p 12345 --watch
|
|
62
79
|
|
|
@@ -73,11 +90,14 @@ loop-detective -p 12345 --json
|
|
|
73
90
|
| `-d, --duration <sec>` | Profiling duration in seconds | 10 |
|
|
74
91
|
| `-t, --threshold <ms>` | Event loop lag threshold | 50 |
|
|
75
92
|
| `-i, --interval <ms>` | Lag sampling interval | 100 |
|
|
93
|
+
| `--io-threshold <ms>` | Slow I/O threshold | 500 |
|
|
76
94
|
| `-j, --json` | Output as JSON | false |
|
|
77
95
|
| `-w, --watch` | Continuous monitoring | false |
|
|
78
96
|
|
|
79
97
|
## What It Detects
|
|
80
98
|
|
|
99
|
+
### CPU / Event Loop Blocking
|
|
100
|
+
|
|
81
101
|
| Pattern | Description |
|
|
82
102
|
|---------|-------------|
|
|
83
103
|
| `cpu-hog` | Single function consuming >50% CPU |
|
|
@@ -87,6 +107,16 @@ loop-detective -p 12345 --json
|
|
|
87
107
|
| `sync-io` | Synchronous file I/O calls |
|
|
88
108
|
| `crypto-heavy` | CPU-intensive crypto operations |
|
|
89
109
|
|
|
110
|
+
### Slow Async I/O
|
|
111
|
+
|
|
112
|
+
| Type | What It Tracks |
|
|
113
|
+
|------|---------------|
|
|
114
|
+
| 🌐 HTTP/HTTPS | Outgoing HTTP requests — method, target, status code, duration |
|
|
115
|
+
| 🔍 DNS | DNS lookups — hostname, resolution time |
|
|
116
|
+
| 🔌 TCP | TCP connections — target host:port, connect time (covers databases, Redis, etc.) |
|
|
117
|
+
|
|
118
|
+
Each slow I/O event includes the caller stack trace, so you know exactly which code initiated the slow operation.
|
|
119
|
+
|
|
90
120
|
## Programmatic API
|
|
91
121
|
|
|
92
122
|
```js
|
|
@@ -97,9 +127,11 @@ const detective = new Detective({
|
|
|
97
127
|
duration: 10000,
|
|
98
128
|
threshold: 50,
|
|
99
129
|
interval: 100,
|
|
130
|
+
ioThreshold: 500,
|
|
100
131
|
});
|
|
101
132
|
|
|
102
133
|
detective.on('lag', (data) => console.log('Lag:', data.lag, 'ms'));
|
|
134
|
+
detective.on('slowIO', (data) => console.log('Slow I/O:', data.type, data.target, data.duration, 'ms'));
|
|
103
135
|
detective.on('profile', (analysis) => {
|
|
104
136
|
console.log('Heavy functions:', analysis.heavyFunctions);
|
|
105
137
|
console.log('Patterns:', analysis.blockingPatterns);
|
|
@@ -117,7 +149,7 @@ await detective.start();
|
|
|
117
149
|
|
|
118
150
|
## How is this different from clinic.js / 0x?
|
|
119
151
|
|
|
120
|
-
Those are great tools, but they require you to **start** your app through them. `loop-detective` attaches to an **already running** process — perfect for production debugging.
|
|
152
|
+
Those are great tools, but they require you to **start** your app through them. `loop-detective` attaches to an **already running** process — perfect for production debugging. It also tracks slow async I/O (HTTP, DNS, TCP) which those tools don't focus on.
|
|
121
153
|
|
|
122
154
|
## License
|
|
123
155
|
|
package/bin/cli.js
CHANGED
|
@@ -14,6 +14,7 @@ function parseCliArgs(argv) {
|
|
|
14
14
|
duration: '10',
|
|
15
15
|
threshold: '50',
|
|
16
16
|
interval: '100',
|
|
17
|
+
'io-threshold': '500',
|
|
17
18
|
json: false,
|
|
18
19
|
watch: false,
|
|
19
20
|
help: false,
|
|
@@ -27,6 +28,7 @@ function parseCliArgs(argv) {
|
|
|
27
28
|
'-d': 'duration', '--duration': 'duration',
|
|
28
29
|
'-t': 'threshold', '--threshold': 'threshold',
|
|
29
30
|
'-i': 'interval', '--interval': 'interval',
|
|
31
|
+
'--io-threshold': 'io-threshold',
|
|
30
32
|
};
|
|
31
33
|
const boolMap = {
|
|
32
34
|
'-j': 'json', '--json': 'json',
|
|
@@ -90,6 +92,7 @@ function printUsage() {
|
|
|
90
92
|
-d, --duration <sec> Profiling duration in seconds (default: 10)
|
|
91
93
|
-t, --threshold <ms> Event loop lag threshold in ms (default: 50)
|
|
92
94
|
-i, --interval <ms> Sampling interval in ms (default: 100)
|
|
95
|
+
--io-threshold <ms> Slow I/O threshold in ms (default: 500)
|
|
93
96
|
-j, --json Output results as JSON
|
|
94
97
|
-w, --watch Continuous monitoring mode
|
|
95
98
|
-h, --help Show this help
|
|
@@ -117,6 +120,7 @@ async function main() {
|
|
|
117
120
|
duration: parseInt(values.duration, 10) * 1000,
|
|
118
121
|
threshold: parseInt(values.threshold, 10),
|
|
119
122
|
interval: parseInt(values.interval, 10),
|
|
123
|
+
ioThreshold: parseInt(values['io-threshold'], 10),
|
|
120
124
|
watch: values.watch,
|
|
121
125
|
json: values.json,
|
|
122
126
|
};
|
|
@@ -126,6 +130,7 @@ async function main() {
|
|
|
126
130
|
|
|
127
131
|
detective.on('connected', () => reporter.onConnected());
|
|
128
132
|
detective.on('lag', (data) => reporter.onLag(data));
|
|
133
|
+
detective.on('slowIO', (data) => reporter.onSlowIO(data));
|
|
129
134
|
detective.on('profile', (data) => reporter.onProfile(data));
|
|
130
135
|
detective.on('error', (err) => reporter.onError(err));
|
|
131
136
|
detective.on('disconnected', () => reporter.onDisconnected());
|
package/package.json
CHANGED
package/src/analyzer.js
CHANGED
|
@@ -7,12 +7,6 @@ class Analyzer {
|
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Analyze a V8 CPU profile to find blocking functions
|
|
10
|
-
*
|
|
11
|
-
* V8 CPU profile format:
|
|
12
|
-
* - nodes: array of { id, callFrame: { functionName, url, lineNumber, columnNumber }, hitCount, children }
|
|
13
|
-
* - startTime, endTime: microseconds
|
|
14
|
-
* - samples: array of node IDs (sampled at each tick)
|
|
15
|
-
* - timeDeltas: array of time deltas between samples (microseconds)
|
|
16
10
|
*/
|
|
17
11
|
analyzeProfile(profile) {
|
|
18
12
|
const { nodes, samples, timeDeltas, startTime, endTime } = profile;
|
|
@@ -22,7 +16,7 @@ class Analyzer {
|
|
|
22
16
|
nodeMap.set(node.id, node);
|
|
23
17
|
}
|
|
24
18
|
|
|
25
|
-
// Calculate total time per node
|
|
19
|
+
// Calculate total time per node (keyed by node ID)
|
|
26
20
|
const timings = new Map();
|
|
27
21
|
for (let i = 0; i < samples.length; i++) {
|
|
28
22
|
const nodeId = samples[i];
|
|
@@ -30,7 +24,6 @@ class Analyzer {
|
|
|
30
24
|
timings.set(nodeId, (timings.get(nodeId) || 0) + delta);
|
|
31
25
|
}
|
|
32
26
|
|
|
33
|
-
// Build results: find heavy functions
|
|
34
27
|
const totalDuration = endTime - startTime; // microseconds
|
|
35
28
|
const heavyFunctions = [];
|
|
36
29
|
|
|
@@ -40,35 +33,30 @@ class Analyzer {
|
|
|
40
33
|
|
|
41
34
|
const { functionName, url, lineNumber, columnNumber } = node.callFrame;
|
|
42
35
|
|
|
43
|
-
// Skip internal/idle nodes
|
|
44
36
|
if (!url && !functionName) continue;
|
|
45
37
|
if (functionName === '(idle)' || functionName === '(program)') continue;
|
|
46
|
-
if (functionName === '(garbage collector)') {
|
|
47
|
-
// GC is interesting — keep it
|
|
48
|
-
}
|
|
49
38
|
|
|
50
39
|
const selfTimeMs = selfTime / 1000;
|
|
51
40
|
const percentage = totalDuration > 0 ? (selfTime / totalDuration) * 100 : 0;
|
|
52
41
|
|
|
53
|
-
if (selfTimeMs < 1) continue;
|
|
42
|
+
if (selfTimeMs < 1) continue;
|
|
54
43
|
|
|
55
44
|
heavyFunctions.push({
|
|
45
|
+
nodeId, // Fix #5: carry node ID for accurate call stack building
|
|
56
46
|
functionName: functionName || '(anonymous)',
|
|
57
47
|
url: url || '(native)',
|
|
58
|
-
lineNumber: lineNumber + 1,
|
|
48
|
+
lineNumber: lineNumber + 1,
|
|
59
49
|
columnNumber: columnNumber + 1,
|
|
60
50
|
selfTimeMs: Math.round(selfTimeMs * 100) / 100,
|
|
61
51
|
percentage: Math.round(percentage * 100) / 100,
|
|
62
52
|
});
|
|
63
53
|
}
|
|
64
54
|
|
|
65
|
-
// Sort by self time descending
|
|
66
55
|
heavyFunctions.sort((a, b) => b.selfTimeMs - a.selfTimeMs);
|
|
67
56
|
|
|
68
|
-
//
|
|
69
|
-
const callStacks = this._buildCallStacks(nodeMap,
|
|
57
|
+
// Fix #5: Pass node IDs to _buildCallStacks instead of matching by name
|
|
58
|
+
const callStacks = this._buildCallStacks(nodeMap, heavyFunctions.slice(0, 5));
|
|
70
59
|
|
|
71
|
-
// Detect event loop blocking patterns
|
|
72
60
|
const blockingPatterns = this._detectPatterns(heavyFunctions, profile);
|
|
73
61
|
|
|
74
62
|
return {
|
|
@@ -77,7 +65,8 @@ class Analyzer {
|
|
|
77
65
|
samplesCount: samples.length,
|
|
78
66
|
heavyFunctionCount: heavyFunctions.length,
|
|
79
67
|
},
|
|
80
|
-
|
|
68
|
+
// Strip nodeId from public output
|
|
69
|
+
heavyFunctions: heavyFunctions.slice(0, 20).map(({ nodeId, ...rest }) => rest),
|
|
81
70
|
callStacks,
|
|
82
71
|
blockingPatterns,
|
|
83
72
|
timestamp: Date.now(),
|
|
@@ -85,9 +74,11 @@ class Analyzer {
|
|
|
85
74
|
}
|
|
86
75
|
|
|
87
76
|
/**
|
|
88
|
-
* Build call stacks for the heaviest functions
|
|
77
|
+
* Build call stacks for the heaviest functions.
|
|
78
|
+
* Fix for Issue #5: Uses node ID directly instead of matching by function name,
|
|
79
|
+
* which avoids incorrect matches for same-named functions or minified code.
|
|
89
80
|
*/
|
|
90
|
-
_buildCallStacks(nodeMap,
|
|
81
|
+
_buildCallStacks(nodeMap, topFunctions) {
|
|
91
82
|
const stacks = [];
|
|
92
83
|
|
|
93
84
|
// Build parent map
|
|
@@ -101,18 +92,7 @@ class Analyzer {
|
|
|
101
92
|
}
|
|
102
93
|
|
|
103
94
|
for (const fn of topFunctions) {
|
|
104
|
-
|
|
105
|
-
let targetNode = null;
|
|
106
|
-
for (const node of nodeMap.values()) {
|
|
107
|
-
const cf = node.callFrame;
|
|
108
|
-
if (cf.functionName === fn.functionName &&
|
|
109
|
-
cf.url === (fn.url === '(native)' ? '' : fn.url) &&
|
|
110
|
-
cf.lineNumber === fn.lineNumber - 1) {
|
|
111
|
-
targetNode = node;
|
|
112
|
-
break;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
95
|
+
const targetNode = nodeMap.get(fn.nodeId);
|
|
116
96
|
if (!targetNode) continue;
|
|
117
97
|
|
|
118
98
|
// Walk up the call stack
|
|
@@ -149,7 +129,6 @@ class Analyzer {
|
|
|
149
129
|
const patterns = [];
|
|
150
130
|
const totalMs = (profile.endTime - profile.startTime) / 1000;
|
|
151
131
|
|
|
152
|
-
// Pattern: Single function dominating CPU
|
|
153
132
|
const topFn = heavyFunctions[0];
|
|
154
133
|
if (topFn && topFn.percentage > 50) {
|
|
155
134
|
patterns.push({
|
|
@@ -161,7 +140,6 @@ class Analyzer {
|
|
|
161
140
|
});
|
|
162
141
|
}
|
|
163
142
|
|
|
164
|
-
// Pattern: JSON parsing / serialization
|
|
165
143
|
const jsonFns = heavyFunctions.filter(
|
|
166
144
|
(f) => f.functionName.includes('JSON') || f.functionName.includes('parse') || f.functionName.includes('stringify')
|
|
167
145
|
);
|
|
@@ -175,7 +153,6 @@ class Analyzer {
|
|
|
175
153
|
});
|
|
176
154
|
}
|
|
177
155
|
|
|
178
|
-
// Pattern: RegExp heavy
|
|
179
156
|
const regexFns = heavyFunctions.filter(
|
|
180
157
|
(f) => f.functionName.includes('RegExp') || f.functionName.includes('exec') || f.functionName.includes('match')
|
|
181
158
|
);
|
|
@@ -189,7 +166,6 @@ class Analyzer {
|
|
|
189
166
|
});
|
|
190
167
|
}
|
|
191
168
|
|
|
192
|
-
// Pattern: GC pressure
|
|
193
169
|
const gcFns = heavyFunctions.filter((f) => f.functionName.includes('garbage collector'));
|
|
194
170
|
const gcTime = gcFns.reduce((sum, f) => sum + f.selfTimeMs, 0);
|
|
195
171
|
if (gcTime > totalMs * 0.05) {
|
|
@@ -201,7 +177,6 @@ class Analyzer {
|
|
|
201
177
|
});
|
|
202
178
|
}
|
|
203
179
|
|
|
204
|
-
// Pattern: Synchronous file I/O
|
|
205
180
|
const syncFns = heavyFunctions.filter(
|
|
206
181
|
(f) => f.functionName.includes('Sync') || f.url.includes('fs.js') || f.url.includes('node:fs')
|
|
207
182
|
);
|
|
@@ -215,7 +190,6 @@ class Analyzer {
|
|
|
215
190
|
});
|
|
216
191
|
}
|
|
217
192
|
|
|
218
|
-
// Pattern: Crypto operations
|
|
219
193
|
const cryptoFns = heavyFunctions.filter(
|
|
220
194
|
(f) => f.url.includes('crypto') || f.functionName.includes('pbkdf') || f.functionName.includes('hash')
|
|
221
195
|
);
|
package/src/detective.js
CHANGED
|
@@ -11,20 +11,22 @@ class Detective extends EventEmitter {
|
|
|
11
11
|
this.inspector = null;
|
|
12
12
|
this.analyzer = new Analyzer(config);
|
|
13
13
|
this._running = false;
|
|
14
|
+
this._stopping = false;
|
|
14
15
|
this._lagTimer = null;
|
|
16
|
+
this._ioTimer = null;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
/**
|
|
18
20
|
* Activate the inspector on the target process via SIGUSR1
|
|
19
21
|
*/
|
|
20
22
|
_activateInspector() {
|
|
21
|
-
if (this.config.inspectorPort) return;
|
|
23
|
+
if (this.config.inspectorPort) return;
|
|
22
24
|
|
|
23
25
|
const pid = this.config.pid;
|
|
24
26
|
if (!pid) throw new Error('No PID provided');
|
|
25
27
|
|
|
26
28
|
try {
|
|
27
|
-
process.kill(pid, 0);
|
|
29
|
+
process.kill(pid, 0);
|
|
28
30
|
} catch (err) {
|
|
29
31
|
throw new Error(`Process ${pid} not found or not accessible: ${err.message}`);
|
|
30
32
|
}
|
|
@@ -44,19 +46,21 @@ class Detective extends EventEmitter {
|
|
|
44
46
|
*/
|
|
45
47
|
async _findInspectorPort() {
|
|
46
48
|
if (this.config.inspectorPort) return this.config.inspectorPort;
|
|
47
|
-
|
|
48
|
-
// After SIGUSR1, Node.js opens inspector on 9229 by default
|
|
49
|
-
// Give it a moment to start
|
|
50
49
|
await this._sleep(1000);
|
|
51
50
|
return 9229;
|
|
52
51
|
}
|
|
53
52
|
|
|
54
53
|
/**
|
|
55
54
|
* Start event loop lag detection via CDP Runtime.evaluate
|
|
56
|
-
*
|
|
55
|
+
*
|
|
56
|
+
* Note on stack traces (Issue #6): The setInterval callback fires AFTER
|
|
57
|
+
* blocking code has finished, so captureStack() captures the timer's own
|
|
58
|
+
* stack, not the blocking code's stack. The lag event stacks are best-effort
|
|
59
|
+
* context. For accurate blocking code identification, use the CPU profile
|
|
60
|
+
* analysis (heavyFunctions + callStacks) which is based on V8 sampling and
|
|
61
|
+
* reliably identifies the actual blocking functions.
|
|
57
62
|
*/
|
|
58
63
|
async _startLagDetection() {
|
|
59
|
-
// Inject a lag detector that also captures stack traces
|
|
60
64
|
const script = `
|
|
61
65
|
(function() {
|
|
62
66
|
if (globalThis.__loopDetective) {
|
|
@@ -66,13 +70,11 @@ class Detective extends EventEmitter {
|
|
|
66
70
|
let lastTime = Date.now();
|
|
67
71
|
const threshold = ${this.config.threshold};
|
|
68
72
|
|
|
69
|
-
// Capture stack trace at the point of lag detection
|
|
70
73
|
function captureStack() {
|
|
71
74
|
const orig = Error.stackTraceLimit;
|
|
72
75
|
Error.stackTraceLimit = 20;
|
|
73
76
|
const err = new Error();
|
|
74
77
|
Error.stackTraceLimit = orig;
|
|
75
|
-
// Parse the stack into structured frames
|
|
76
78
|
const frames = (err.stack || '').split('\\n').slice(2).map(line => {
|
|
77
79
|
const m = line.match(/at\\s+(?:(.+?)\\s+\\()?(.+?):(\\d+):(\\d+)\\)?/);
|
|
78
80
|
if (m) return { fn: m[1] || '(anonymous)', file: m[2], line: +m[3], col: +m[4] };
|
|
@@ -98,7 +100,6 @@ class Detective extends EventEmitter {
|
|
|
98
100
|
lastTime = now;
|
|
99
101
|
}, ${this.config.interval});
|
|
100
102
|
|
|
101
|
-
// Make sure our timer doesn't keep the process alive
|
|
102
103
|
if (timer.unref) timer.unref();
|
|
103
104
|
|
|
104
105
|
globalThis.__loopDetective = {
|
|
@@ -126,7 +127,6 @@ class Detective extends EventEmitter {
|
|
|
126
127
|
throw new Error(`Failed to inject lag detector: ${JSON.stringify(result.exceptionDetails)}`);
|
|
127
128
|
}
|
|
128
129
|
|
|
129
|
-
// Poll for lag events
|
|
130
130
|
this._lagTimer = setInterval(async () => {
|
|
131
131
|
if (!this._running) return;
|
|
132
132
|
try {
|
|
@@ -144,6 +144,224 @@ class Detective extends EventEmitter {
|
|
|
144
144
|
}, 1000);
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Start slow async I/O detection via CDP Runtime.evaluate
|
|
149
|
+
* Monkey-patches http, https, net, dns to track slow operations
|
|
150
|
+
*
|
|
151
|
+
* Fix for Issue #1: Original functions are stored and restored on cleanup.
|
|
152
|
+
* Fix for Issue #7: http.get is wrapped around the original http.get,
|
|
153
|
+
* not reimplemented via mod.request + req.end().
|
|
154
|
+
*/
|
|
155
|
+
async _startAsyncIOTracking() {
|
|
156
|
+
const ioThreshold = this.config.ioThreshold || 500;
|
|
157
|
+
|
|
158
|
+
const script = `
|
|
159
|
+
(function() {
|
|
160
|
+
if (globalThis.__loopDetectiveIO) {
|
|
161
|
+
return { alreadyRunning: true };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const slowOps = [];
|
|
165
|
+
const threshold = ${ioThreshold};
|
|
166
|
+
const originals = {};
|
|
167
|
+
|
|
168
|
+
function captureCallerStack() {
|
|
169
|
+
const origLimit = Error.stackTraceLimit;
|
|
170
|
+
Error.stackTraceLimit = 10;
|
|
171
|
+
const stackErr = new Error();
|
|
172
|
+
Error.stackTraceLimit = origLimit;
|
|
173
|
+
return (stackErr.stack || '').split('\\n').slice(2, 6).map(l => l.trim());
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function recordSlowOp(op) {
|
|
177
|
+
slowOps.push(op);
|
|
178
|
+
if (slowOps.length > 200) slowOps.shift();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// --- Track outgoing HTTP/HTTPS requests ---
|
|
182
|
+
function patchHttp(modName) {
|
|
183
|
+
let mod;
|
|
184
|
+
try { mod = require(modName); } catch { return; }
|
|
185
|
+
const origRequest = mod.request;
|
|
186
|
+
const origGet = mod.get;
|
|
187
|
+
originals[modName + '.request'] = { mod, key: 'request', fn: origRequest };
|
|
188
|
+
originals[modName + '.get'] = { mod, key: 'get', fn: origGet };
|
|
189
|
+
|
|
190
|
+
mod.request = function patchedRequest(...args) {
|
|
191
|
+
const startTime = Date.now();
|
|
192
|
+
const opts = typeof args[0] === 'string' ? { href: args[0] } : (args[0] || {});
|
|
193
|
+
const target = opts.href || opts.hostname || opts.host || 'unknown';
|
|
194
|
+
const method = (opts.method || 'GET').toUpperCase();
|
|
195
|
+
const callerStack = captureCallerStack();
|
|
196
|
+
|
|
197
|
+
const req = origRequest.apply(this, args);
|
|
198
|
+
|
|
199
|
+
req.on('response', (res) => {
|
|
200
|
+
const duration = Date.now() - startTime;
|
|
201
|
+
if (duration >= threshold) {
|
|
202
|
+
recordSlowOp({
|
|
203
|
+
type: 'http', protocol: modName, method, target,
|
|
204
|
+
statusCode: res.statusCode, duration, timestamp: Date.now(), stack: callerStack,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
req.on('error', (err) => {
|
|
210
|
+
const duration = Date.now() - startTime;
|
|
211
|
+
if (duration >= threshold) {
|
|
212
|
+
recordSlowOp({
|
|
213
|
+
type: 'http', protocol: modName, method, target,
|
|
214
|
+
error: err.message, duration, timestamp: Date.now(), stack: callerStack,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
return req;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
// Fix #7: Wrap original http.get instead of reimplementing
|
|
223
|
+
mod.get = function patchedGet(...args) {
|
|
224
|
+
return origGet.apply(this, args);
|
|
225
|
+
};
|
|
226
|
+
// Add timing to get as well
|
|
227
|
+
const wrappedGet = mod.get;
|
|
228
|
+
mod.get = function patchedGetWithTiming(...args) {
|
|
229
|
+
const startTime = Date.now();
|
|
230
|
+
const opts = typeof args[0] === 'string' ? { href: args[0] } : (args[0] || {});
|
|
231
|
+
const target = opts.href || opts.hostname || opts.host || 'unknown';
|
|
232
|
+
const callerStack = captureCallerStack();
|
|
233
|
+
|
|
234
|
+
const req = origGet.apply(this, args);
|
|
235
|
+
|
|
236
|
+
req.on('response', (res) => {
|
|
237
|
+
const duration = Date.now() - startTime;
|
|
238
|
+
if (duration >= threshold) {
|
|
239
|
+
recordSlowOp({
|
|
240
|
+
type: 'http', protocol: modName, method: 'GET', target,
|
|
241
|
+
statusCode: res.statusCode, duration, timestamp: Date.now(), stack: callerStack,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
req.on('error', (err) => {
|
|
247
|
+
const duration = Date.now() - startTime;
|
|
248
|
+
if (duration >= threshold) {
|
|
249
|
+
recordSlowOp({
|
|
250
|
+
type: 'http', protocol: modName, method: 'GET', target,
|
|
251
|
+
error: err.message, duration, timestamp: Date.now(), stack: callerStack,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
return req;
|
|
257
|
+
};
|
|
258
|
+
originals[modName + '.get'].fn = origGet;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
patchHttp('http');
|
|
262
|
+
patchHttp('https');
|
|
263
|
+
|
|
264
|
+
// --- Track DNS lookups ---
|
|
265
|
+
(function patchDns() {
|
|
266
|
+
let dns;
|
|
267
|
+
try { dns = require('dns'); } catch { return; }
|
|
268
|
+
const origLookup = dns.lookup;
|
|
269
|
+
originals['dns.lookup'] = { mod: dns, key: 'lookup', fn: origLookup };
|
|
270
|
+
|
|
271
|
+
dns.lookup = function patchedLookup(hostname, options, callback) {
|
|
272
|
+
const startTime = Date.now();
|
|
273
|
+
if (typeof options === 'function') {
|
|
274
|
+
callback = options;
|
|
275
|
+
options = {};
|
|
276
|
+
}
|
|
277
|
+
const callerStack = captureCallerStack();
|
|
278
|
+
|
|
279
|
+
return origLookup.call(dns, hostname, options, function(err, address, family) {
|
|
280
|
+
const duration = Date.now() - startTime;
|
|
281
|
+
if (duration >= threshold) {
|
|
282
|
+
recordSlowOp({
|
|
283
|
+
type: 'dns', target: hostname, duration,
|
|
284
|
+
error: err ? err.message : null, timestamp: Date.now(), stack: callerStack,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
if (callback) callback(err, address, family);
|
|
288
|
+
});
|
|
289
|
+
};
|
|
290
|
+
})();
|
|
291
|
+
|
|
292
|
+
// --- Track TCP socket connections ---
|
|
293
|
+
(function patchNet() {
|
|
294
|
+
let net;
|
|
295
|
+
try { net = require('net'); } catch { return; }
|
|
296
|
+
const origConnect = net.Socket.prototype.connect;
|
|
297
|
+
originals['net.Socket.connect'] = { mod: net.Socket.prototype, key: 'connect', fn: origConnect };
|
|
298
|
+
|
|
299
|
+
net.Socket.prototype.connect = function patchedConnect(...args) {
|
|
300
|
+
const startTime = Date.now();
|
|
301
|
+
const opts = typeof args[0] === 'object' ? args[0] : { port: args[0], host: args[1] };
|
|
302
|
+
const target = (opts.host || '127.0.0.1') + ':' + (opts.port || '?');
|
|
303
|
+
const callerStack = captureCallerStack();
|
|
304
|
+
|
|
305
|
+
this.once('connect', () => {
|
|
306
|
+
const duration = Date.now() - startTime;
|
|
307
|
+
if (duration >= threshold) {
|
|
308
|
+
recordSlowOp({ type: 'tcp', target, duration, timestamp: Date.now(), stack: callerStack });
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
this.once('error', (err) => {
|
|
313
|
+
const duration = Date.now() - startTime;
|
|
314
|
+
if (duration >= threshold) {
|
|
315
|
+
recordSlowOp({ type: 'tcp', target, error: err.message, duration, timestamp: Date.now(), stack: callerStack });
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
return origConnect.apply(this, args);
|
|
320
|
+
};
|
|
321
|
+
})();
|
|
322
|
+
|
|
323
|
+
globalThis.__loopDetectiveIO = {
|
|
324
|
+
getSlowOps: () => slowOps.splice(0),
|
|
325
|
+
cleanup: () => {
|
|
326
|
+
// Fix #1: Restore all original functions
|
|
327
|
+
for (const entry of Object.values(originals)) {
|
|
328
|
+
entry.mod[entry.key] = entry.fn;
|
|
329
|
+
}
|
|
330
|
+
delete globalThis.__loopDetectiveIO;
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
return { started: true };
|
|
335
|
+
})()
|
|
336
|
+
`;
|
|
337
|
+
|
|
338
|
+
const result = await this.inspector.send('Runtime.evaluate', {
|
|
339
|
+
expression: script,
|
|
340
|
+
returnByValue: true,
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
if (result.exceptionDetails) {
|
|
344
|
+
this.emit('error', new Error(`Failed to inject I/O tracker (non-fatal): ${JSON.stringify(result.exceptionDetails)}`));
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
this._ioTimer = setInterval(async () => {
|
|
349
|
+
if (!this._running) return;
|
|
350
|
+
try {
|
|
351
|
+
const pollResult = await this.inspector.send('Runtime.evaluate', {
|
|
352
|
+
expression: 'globalThis.__loopDetectiveIO ? globalThis.__loopDetectiveIO.getSlowOps() : []',
|
|
353
|
+
returnByValue: true,
|
|
354
|
+
});
|
|
355
|
+
const ops = pollResult.result?.value || [];
|
|
356
|
+
for (const op of ops) {
|
|
357
|
+
this.emit('slowIO', op);
|
|
358
|
+
}
|
|
359
|
+
} catch {
|
|
360
|
+
// Inspector may have disconnected
|
|
361
|
+
}
|
|
362
|
+
}, 1000);
|
|
363
|
+
}
|
|
364
|
+
|
|
147
365
|
/**
|
|
148
366
|
* Take a CPU profile to identify blocking code
|
|
149
367
|
*/
|
|
@@ -161,17 +379,21 @@ class Detective extends EventEmitter {
|
|
|
161
379
|
}
|
|
162
380
|
|
|
163
381
|
/**
|
|
164
|
-
* Clean up the injected lag detector
|
|
382
|
+
* Clean up the injected lag detector and I/O tracker
|
|
165
383
|
*/
|
|
166
|
-
async
|
|
384
|
+
async _cleanupInjectedCode() {
|
|
167
385
|
try {
|
|
168
386
|
await this.inspector.send('Runtime.evaluate', {
|
|
169
387
|
expression: 'globalThis.__loopDetective && globalThis.__loopDetective.cleanup()',
|
|
170
388
|
returnByValue: true,
|
|
171
389
|
});
|
|
172
|
-
} catch {
|
|
173
|
-
|
|
174
|
-
|
|
390
|
+
} catch { /* best effort */ }
|
|
391
|
+
try {
|
|
392
|
+
await this.inspector.send('Runtime.evaluate', {
|
|
393
|
+
expression: 'globalThis.__loopDetectiveIO && globalThis.__loopDetectiveIO.cleanup()',
|
|
394
|
+
returnByValue: true,
|
|
395
|
+
});
|
|
396
|
+
} catch { /* best effort */ }
|
|
175
397
|
}
|
|
176
398
|
|
|
177
399
|
/**
|
|
@@ -179,12 +401,11 @@ class Detective extends EventEmitter {
|
|
|
179
401
|
*/
|
|
180
402
|
async start() {
|
|
181
403
|
this._running = true;
|
|
404
|
+
this._stopping = false;
|
|
182
405
|
|
|
183
|
-
// Step 1: Activate inspector
|
|
184
406
|
this._activateInspector();
|
|
185
407
|
const port = await this._findInspectorPort();
|
|
186
408
|
|
|
187
|
-
// Step 2: Connect
|
|
188
409
|
this.inspector = new Inspector({ port });
|
|
189
410
|
await this.inspector.connect();
|
|
190
411
|
this.emit('connected');
|
|
@@ -198,13 +419,10 @@ class Detective extends EventEmitter {
|
|
|
198
419
|
|
|
199
420
|
async _singleRun() {
|
|
200
421
|
try {
|
|
201
|
-
// Step 3: Start lag detection
|
|
202
422
|
await this._startLagDetection();
|
|
423
|
+
await this._startAsyncIOTracking();
|
|
203
424
|
|
|
204
|
-
// Step 4: Capture CPU profile
|
|
205
425
|
const profile = await this._captureProfile(this.config.duration);
|
|
206
|
-
|
|
207
|
-
// Step 5: Analyze
|
|
208
426
|
const analysis = this.analyzer.analyzeProfile(profile);
|
|
209
427
|
this.emit('profile', analysis);
|
|
210
428
|
} finally {
|
|
@@ -212,28 +430,41 @@ class Detective extends EventEmitter {
|
|
|
212
430
|
}
|
|
213
431
|
}
|
|
214
432
|
|
|
433
|
+
/**
|
|
434
|
+
* Fix for Issue #2: Wrap runCycle in try/catch, emit errors,
|
|
435
|
+
* and continue the watch loop.
|
|
436
|
+
*/
|
|
215
437
|
async _watchMode() {
|
|
216
438
|
await this._startLagDetection();
|
|
439
|
+
await this._startAsyncIOTracking();
|
|
217
440
|
|
|
218
441
|
const runCycle = async () => {
|
|
219
442
|
if (!this._running) return;
|
|
220
443
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
444
|
+
try {
|
|
445
|
+
const profile = await this._captureProfile(this.config.duration);
|
|
446
|
+
const analysis = this.analyzer.analyzeProfile(profile);
|
|
447
|
+
this.emit('profile', analysis);
|
|
448
|
+
} catch (err) {
|
|
449
|
+
this.emit('error', err);
|
|
450
|
+
}
|
|
224
451
|
|
|
225
452
|
if (this._running) {
|
|
226
453
|
setTimeout(runCycle, 1000);
|
|
227
454
|
}
|
|
228
455
|
};
|
|
229
456
|
|
|
230
|
-
|
|
457
|
+
// Await the first cycle and catch its errors
|
|
458
|
+
await runCycle();
|
|
231
459
|
}
|
|
232
460
|
|
|
233
461
|
/**
|
|
234
|
-
* Stop the detective and clean up
|
|
462
|
+
* Stop the detective and clean up.
|
|
463
|
+
* Fix for Issue #3: Idempotent — safe to call multiple times.
|
|
235
464
|
*/
|
|
236
465
|
async stop() {
|
|
466
|
+
if (this._stopping) return;
|
|
467
|
+
this._stopping = true;
|
|
237
468
|
this._running = false;
|
|
238
469
|
|
|
239
470
|
if (this._lagTimer) {
|
|
@@ -241,8 +472,13 @@ class Detective extends EventEmitter {
|
|
|
241
472
|
this._lagTimer = null;
|
|
242
473
|
}
|
|
243
474
|
|
|
475
|
+
if (this._ioTimer) {
|
|
476
|
+
clearInterval(this._ioTimer);
|
|
477
|
+
this._ioTimer = null;
|
|
478
|
+
}
|
|
479
|
+
|
|
244
480
|
if (this.inspector) {
|
|
245
|
-
await this.
|
|
481
|
+
await this._cleanupInjectedCode();
|
|
246
482
|
await this.inspector.disconnect();
|
|
247
483
|
this.inspector = null;
|
|
248
484
|
}
|
package/src/inspector.js
CHANGED
|
@@ -64,7 +64,8 @@ class Inspector extends EventEmitter {
|
|
|
64
64
|
this.ws.on('message', (data) => {
|
|
65
65
|
const msg = JSON.parse(data.toString());
|
|
66
66
|
if (msg.id !== undefined && this._callbacks.has(msg.id)) {
|
|
67
|
-
const { resolve, reject } = this._callbacks.get(msg.id);
|
|
67
|
+
const { resolve, reject, timer } = this._callbacks.get(msg.id);
|
|
68
|
+
clearTimeout(timer);
|
|
68
69
|
this._callbacks.delete(msg.id);
|
|
69
70
|
if (msg.error) {
|
|
70
71
|
reject(new Error(msg.error.message));
|
|
@@ -98,16 +99,15 @@ class Inspector extends EventEmitter {
|
|
|
98
99
|
const id = ++this._id;
|
|
99
100
|
|
|
100
101
|
return new Promise((resolve, reject) => {
|
|
101
|
-
|
|
102
|
-
this.ws.send(JSON.stringify({ id, method, params }));
|
|
103
|
-
|
|
104
|
-
// Timeout for individual commands
|
|
105
|
-
setTimeout(() => {
|
|
102
|
+
const timer = setTimeout(() => {
|
|
106
103
|
if (this._callbacks.has(id)) {
|
|
107
104
|
this._callbacks.delete(id);
|
|
108
105
|
reject(new Error(`CDP command timeout: ${method}`));
|
|
109
106
|
}
|
|
110
107
|
}, 30000);
|
|
108
|
+
|
|
109
|
+
this._callbacks.set(id, { resolve, reject, timer });
|
|
110
|
+
this.ws.send(JSON.stringify({ id, method, params }));
|
|
111
111
|
});
|
|
112
112
|
}
|
|
113
113
|
|
|
@@ -116,6 +116,11 @@ class Inspector extends EventEmitter {
|
|
|
116
116
|
*/
|
|
117
117
|
async disconnect() {
|
|
118
118
|
if (this.ws) {
|
|
119
|
+
// Clear all pending timeouts and reject pending callbacks
|
|
120
|
+
for (const { reject, timer } of this._callbacks.values()) {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
try { reject(new Error('Inspector disconnected')); } catch {}
|
|
123
|
+
}
|
|
119
124
|
this._callbacks.clear();
|
|
120
125
|
this.ws.close();
|
|
121
126
|
this.ws = null;
|
package/src/reporter.js
CHANGED
|
@@ -20,6 +20,7 @@ class Reporter {
|
|
|
20
20
|
constructor(config) {
|
|
21
21
|
this.config = config;
|
|
22
22
|
this.lagEvents = [];
|
|
23
|
+
this.slowIOEvents = [];
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
onConnected() {
|
|
@@ -40,10 +41,29 @@ class Reporter {
|
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
onSlowIO(data) {
|
|
45
|
+
this.slowIOEvents.push(data);
|
|
46
|
+
if (this.config.json) return;
|
|
47
|
+
const severity = data.duration > 5000 ? COLORS.red : data.duration > 2000 ? COLORS.yellow : COLORS.magenta;
|
|
48
|
+
const icon = data.type === 'http' ? '🌐' : data.type === 'dns' ? '🔍' : '🔌';
|
|
49
|
+
const detail = data.type === 'http'
|
|
50
|
+
? `${data.method} ${data.target} → ${data.statusCode || data.error || '?'}`
|
|
51
|
+
: data.type === 'dns'
|
|
52
|
+
? `lookup ${data.target}${data.error ? ' (' + data.error + ')' : ''}`
|
|
53
|
+
: `connect ${data.target}${data.error ? ' (' + data.error + ')' : ''}`;
|
|
54
|
+
this._print(`${severity}${icon} Slow ${data.type.toUpperCase()}: ${data.duration}ms${COLORS.reset} ${detail}`);
|
|
55
|
+
if (data.stack && data.stack.length > 0) {
|
|
56
|
+
for (const line of data.stack.slice(0, 3)) {
|
|
57
|
+
this._print(` ${COLORS.dim} ${line}${COLORS.reset}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
43
62
|
onProfile(analysis) {
|
|
44
63
|
if (this.config.json) {
|
|
45
|
-
this._print(JSON.stringify({ ...analysis, lagEvents: this.lagEvents }, null, 2));
|
|
64
|
+
this._print(JSON.stringify({ ...analysis, lagEvents: this.lagEvents, slowIOEvents: this.slowIOEvents }, null, 2));
|
|
46
65
|
this.lagEvents = [];
|
|
66
|
+
this.slowIOEvents = [];
|
|
47
67
|
return;
|
|
48
68
|
}
|
|
49
69
|
|
|
@@ -51,9 +71,11 @@ class Reporter {
|
|
|
51
71
|
this._printPatterns(analysis.blockingPatterns);
|
|
52
72
|
this._printHeavyFunctions(analysis.heavyFunctions);
|
|
53
73
|
this._printCallStacks(analysis.callStacks);
|
|
74
|
+
this._printSlowIOSummary();
|
|
54
75
|
this._printLagSummary();
|
|
55
76
|
|
|
56
77
|
this.lagEvents = [];
|
|
78
|
+
this.slowIOEvents = [];
|
|
57
79
|
}
|
|
58
80
|
|
|
59
81
|
onError(err) {
|
|
@@ -138,6 +160,53 @@ class Reporter {
|
|
|
138
160
|
}
|
|
139
161
|
}
|
|
140
162
|
|
|
163
|
+
_printSlowIOSummary() {
|
|
164
|
+
if (this.slowIOEvents.length === 0) {
|
|
165
|
+
this._print(`\n ${COLORS.green}✔ No slow I/O operations detected${COLORS.reset}`);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
this._print(`\n ${COLORS.magenta}⚠ Slow Async I/O Summary${COLORS.reset}`);
|
|
170
|
+
this._print(` Total slow ops: ${this.slowIOEvents.length}`);
|
|
171
|
+
|
|
172
|
+
// Group by type
|
|
173
|
+
const byType = {};
|
|
174
|
+
for (const op of this.slowIOEvents) {
|
|
175
|
+
if (!byType[op.type]) byType[op.type] = [];
|
|
176
|
+
byType[op.type].push(op);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
for (const [type, ops] of Object.entries(byType)) {
|
|
180
|
+
const icon = type === 'http' ? '🌐' : type === 'dns' ? '🔍' : '🔌';
|
|
181
|
+
const maxDur = Math.max(...ops.map(o => o.duration));
|
|
182
|
+
const avgDur = Math.round(ops.reduce((s, o) => s + o.duration, 0) / ops.length);
|
|
183
|
+
this._print(`\n ${icon} ${COLORS.bold}${type.toUpperCase()}${COLORS.reset} — ${ops.length} slow ops, avg ${avgDur}ms, max ${maxDur}ms`);
|
|
184
|
+
|
|
185
|
+
// Group by target
|
|
186
|
+
const byTarget = {};
|
|
187
|
+
for (const op of ops) {
|
|
188
|
+
const key = op.type === 'http' ? `${op.method} ${op.target}` : op.target;
|
|
189
|
+
if (!byTarget[key]) byTarget[key] = { count: 0, totalDuration: 0, maxDuration: 0, errors: 0, stack: op.stack };
|
|
190
|
+
byTarget[key].count++;
|
|
191
|
+
byTarget[key].totalDuration += op.duration;
|
|
192
|
+
byTarget[key].maxDuration = Math.max(byTarget[key].maxDuration, op.duration);
|
|
193
|
+
if (op.error) byTarget[key].errors++;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const sorted = Object.entries(byTarget).sort((a, b) => b[1].totalDuration - a[1].totalDuration);
|
|
197
|
+
for (const [target, stats] of sorted.slice(0, 5)) {
|
|
198
|
+
const errStr = stats.errors > 0 ? ` ${COLORS.red}(${stats.errors} errors)${COLORS.reset}` : '';
|
|
199
|
+
this._print(` ${COLORS.yellow}${target}${COLORS.reset}${errStr}`);
|
|
200
|
+
this._print(` ${stats.count} calls, total ${stats.totalDuration}ms, avg ${Math.round(stats.totalDuration / stats.count)}ms, max ${stats.maxDuration}ms`);
|
|
201
|
+
if (stats.stack && stats.stack.length > 0) {
|
|
202
|
+
for (const line of stats.stack.slice(0, 2)) {
|
|
203
|
+
this._print(` ${COLORS.dim}${line}${COLORS.reset}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
141
210
|
_printLagSummary() {
|
|
142
211
|
if (this.lagEvents.length === 0) {
|
|
143
212
|
this._print(`\n ${COLORS.green}✔ No event loop lag detected above threshold${COLORS.reset}`);
|