node-loop-detective 1.0.1 → 1.1.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/detective.js +259 -4
- package/src/reporter.js +98 -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/detective.js
CHANGED
|
@@ -12,6 +12,7 @@ class Detective extends EventEmitter {
|
|
|
12
12
|
this.analyzer = new Analyzer(config);
|
|
13
13
|
this._running = false;
|
|
14
14
|
this._lagTimer = null;
|
|
15
|
+
this._ioTimer = null;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
/**
|
|
@@ -56,7 +57,7 @@ class Detective extends EventEmitter {
|
|
|
56
57
|
* We inject a tiny lag-measuring snippet into the target process
|
|
57
58
|
*/
|
|
58
59
|
async _startLagDetection() {
|
|
59
|
-
// Inject a lag detector
|
|
60
|
+
// Inject a lag detector that also captures stack traces
|
|
60
61
|
const script = `
|
|
61
62
|
(function() {
|
|
62
63
|
if (globalThis.__loopDetective) {
|
|
@@ -66,12 +67,33 @@ class Detective extends EventEmitter {
|
|
|
66
67
|
let lastTime = Date.now();
|
|
67
68
|
const threshold = ${this.config.threshold};
|
|
68
69
|
|
|
70
|
+
// Capture stack trace at the point of lag detection
|
|
71
|
+
function captureStack() {
|
|
72
|
+
const orig = Error.stackTraceLimit;
|
|
73
|
+
Error.stackTraceLimit = 20;
|
|
74
|
+
const err = new Error();
|
|
75
|
+
Error.stackTraceLimit = orig;
|
|
76
|
+
// Parse the stack into structured frames
|
|
77
|
+
const frames = (err.stack || '').split('\\n').slice(2).map(line => {
|
|
78
|
+
const m = line.match(/at\\s+(?:(.+?)\\s+\\()?(.+?):(\\d+):(\\d+)\\)?/);
|
|
79
|
+
if (m) return { fn: m[1] || '(anonymous)', file: m[2], line: +m[3], col: +m[4] };
|
|
80
|
+
const m2 = line.match(/at\\s+(.+)/);
|
|
81
|
+
if (m2) return { fn: m2[1], file: '', line: 0, col: 0 };
|
|
82
|
+
return null;
|
|
83
|
+
}).filter(Boolean).filter(f =>
|
|
84
|
+
!f.file.includes('loopDetective') &&
|
|
85
|
+
!f.fn.includes('Timeout.') &&
|
|
86
|
+
!f.file.includes('node:internal')
|
|
87
|
+
);
|
|
88
|
+
return frames;
|
|
89
|
+
}
|
|
90
|
+
|
|
69
91
|
const timer = setInterval(() => {
|
|
70
92
|
const now = Date.now();
|
|
71
93
|
const delta = now - lastTime;
|
|
72
94
|
const lag = delta - ${this.config.interval};
|
|
73
95
|
if (lag > threshold) {
|
|
74
|
-
lags.push({ lag, timestamp: now });
|
|
96
|
+
lags.push({ lag, timestamp: now, stack: captureStack() });
|
|
75
97
|
if (lags.length > 100) lags.shift();
|
|
76
98
|
}
|
|
77
99
|
lastTime = now;
|
|
@@ -123,6 +145,224 @@ class Detective extends EventEmitter {
|
|
|
123
145
|
}, 1000);
|
|
124
146
|
}
|
|
125
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Start slow async I/O detection via CDP Runtime.evaluate
|
|
150
|
+
* Monkey-patches http, https, net, dns to track slow operations
|
|
151
|
+
*/
|
|
152
|
+
async _startAsyncIOTracking() {
|
|
153
|
+
const ioThreshold = this.config.ioThreshold || 500;
|
|
154
|
+
|
|
155
|
+
const script = `
|
|
156
|
+
(function() {
|
|
157
|
+
if (globalThis.__loopDetectiveIO) {
|
|
158
|
+
return { alreadyRunning: true };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const slowOps = [];
|
|
162
|
+
const threshold = ${ioThreshold};
|
|
163
|
+
|
|
164
|
+
// --- Track outgoing HTTP/HTTPS requests ---
|
|
165
|
+
function patchHttp(modName) {
|
|
166
|
+
let mod;
|
|
167
|
+
try { mod = require(modName); } catch { return; }
|
|
168
|
+
const origRequest = mod.request;
|
|
169
|
+
const origGet = mod.get;
|
|
170
|
+
|
|
171
|
+
mod.request = function patchedRequest(...args) {
|
|
172
|
+
const startTime = Date.now();
|
|
173
|
+
const opts = typeof args[0] === 'string' ? { href: args[0] } : (args[0] || {});
|
|
174
|
+
const target = opts.href || opts.hostname || opts.host || 'unknown';
|
|
175
|
+
const method = (opts.method || 'GET').toUpperCase();
|
|
176
|
+
const label = modName + ' ' + method + ' ' + target;
|
|
177
|
+
|
|
178
|
+
// Capture caller stack
|
|
179
|
+
const origLimit = Error.stackTraceLimit;
|
|
180
|
+
Error.stackTraceLimit = 10;
|
|
181
|
+
const stackErr = new Error();
|
|
182
|
+
Error.stackTraceLimit = origLimit;
|
|
183
|
+
const callerStack = (stackErr.stack || '').split('\\n').slice(2, 6).map(l => l.trim());
|
|
184
|
+
|
|
185
|
+
const req = origRequest.apply(this, args);
|
|
186
|
+
|
|
187
|
+
req.on('response', (res) => {
|
|
188
|
+
const duration = Date.now() - startTime;
|
|
189
|
+
if (duration >= threshold) {
|
|
190
|
+
slowOps.push({
|
|
191
|
+
type: 'http',
|
|
192
|
+
protocol: modName,
|
|
193
|
+
method,
|
|
194
|
+
target,
|
|
195
|
+
statusCode: res.statusCode,
|
|
196
|
+
duration,
|
|
197
|
+
timestamp: Date.now(),
|
|
198
|
+
stack: callerStack,
|
|
199
|
+
});
|
|
200
|
+
if (slowOps.length > 200) slowOps.shift();
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
req.on('error', (err) => {
|
|
205
|
+
const duration = Date.now() - startTime;
|
|
206
|
+
if (duration >= threshold) {
|
|
207
|
+
slowOps.push({
|
|
208
|
+
type: 'http',
|
|
209
|
+
protocol: modName,
|
|
210
|
+
method,
|
|
211
|
+
target,
|
|
212
|
+
error: err.message,
|
|
213
|
+
duration,
|
|
214
|
+
timestamp: Date.now(),
|
|
215
|
+
stack: callerStack,
|
|
216
|
+
});
|
|
217
|
+
if (slowOps.length > 200) slowOps.shift();
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
return req;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
mod.get = function patchedGet(...args) {
|
|
225
|
+
const req = mod.request(...args);
|
|
226
|
+
req.end();
|
|
227
|
+
return req;
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
patchHttp('http');
|
|
232
|
+
patchHttp('https');
|
|
233
|
+
|
|
234
|
+
// --- Track DNS lookups ---
|
|
235
|
+
(function patchDns() {
|
|
236
|
+
let dns;
|
|
237
|
+
try { dns = require('dns'); } catch { return; }
|
|
238
|
+
const origLookup = dns.lookup;
|
|
239
|
+
|
|
240
|
+
dns.lookup = function patchedLookup(hostname, options, callback) {
|
|
241
|
+
const startTime = Date.now();
|
|
242
|
+
if (typeof options === 'function') {
|
|
243
|
+
callback = options;
|
|
244
|
+
options = {};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const origLimit = Error.stackTraceLimit;
|
|
248
|
+
Error.stackTraceLimit = 10;
|
|
249
|
+
const stackErr = new Error();
|
|
250
|
+
Error.stackTraceLimit = origLimit;
|
|
251
|
+
const callerStack = (stackErr.stack || '').split('\\n').slice(2, 6).map(l => l.trim());
|
|
252
|
+
|
|
253
|
+
return origLookup.call(dns, hostname, options, function(err, address, family) {
|
|
254
|
+
const duration = Date.now() - startTime;
|
|
255
|
+
if (duration >= threshold) {
|
|
256
|
+
slowOps.push({
|
|
257
|
+
type: 'dns',
|
|
258
|
+
target: hostname,
|
|
259
|
+
duration,
|
|
260
|
+
error: err ? err.message : null,
|
|
261
|
+
timestamp: Date.now(),
|
|
262
|
+
stack: callerStack,
|
|
263
|
+
});
|
|
264
|
+
if (slowOps.length > 200) slowOps.shift();
|
|
265
|
+
}
|
|
266
|
+
if (callback) callback(err, address, family);
|
|
267
|
+
});
|
|
268
|
+
};
|
|
269
|
+
})();
|
|
270
|
+
|
|
271
|
+
// --- Track TCP socket connections ---
|
|
272
|
+
(function patchNet() {
|
|
273
|
+
let net;
|
|
274
|
+
try { net = require('net'); } catch { return; }
|
|
275
|
+
const origConnect = net.Socket.prototype.connect;
|
|
276
|
+
|
|
277
|
+
net.Socket.prototype.connect = function patchedConnect(...args) {
|
|
278
|
+
const startTime = Date.now();
|
|
279
|
+
const opts = typeof args[0] === 'object' ? args[0] : { port: args[0], host: args[1] };
|
|
280
|
+
const target = (opts.host || '127.0.0.1') + ':' + (opts.port || '?');
|
|
281
|
+
|
|
282
|
+
const origLimit = Error.stackTraceLimit;
|
|
283
|
+
Error.stackTraceLimit = 10;
|
|
284
|
+
const stackErr = new Error();
|
|
285
|
+
Error.stackTraceLimit = origLimit;
|
|
286
|
+
const callerStack = (stackErr.stack || '').split('\\n').slice(2, 6).map(l => l.trim());
|
|
287
|
+
|
|
288
|
+
this.once('connect', () => {
|
|
289
|
+
const duration = Date.now() - startTime;
|
|
290
|
+
if (duration >= threshold) {
|
|
291
|
+
slowOps.push({
|
|
292
|
+
type: 'tcp',
|
|
293
|
+
target,
|
|
294
|
+
duration,
|
|
295
|
+
timestamp: Date.now(),
|
|
296
|
+
stack: callerStack,
|
|
297
|
+
});
|
|
298
|
+
if (slowOps.length > 200) slowOps.shift();
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
this.once('error', (err) => {
|
|
303
|
+
const duration = Date.now() - startTime;
|
|
304
|
+
if (duration >= threshold) {
|
|
305
|
+
slowOps.push({
|
|
306
|
+
type: 'tcp',
|
|
307
|
+
target,
|
|
308
|
+
error: err.message,
|
|
309
|
+
duration,
|
|
310
|
+
timestamp: Date.now(),
|
|
311
|
+
stack: callerStack,
|
|
312
|
+
});
|
|
313
|
+
if (slowOps.length > 200) slowOps.shift();
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
return origConnect.apply(this, args);
|
|
318
|
+
};
|
|
319
|
+
})();
|
|
320
|
+
|
|
321
|
+
globalThis.__loopDetectiveIO = {
|
|
322
|
+
getSlowOps: () => {
|
|
323
|
+
const result = slowOps.splice(0);
|
|
324
|
+
return result;
|
|
325
|
+
},
|
|
326
|
+
cleanup: () => {
|
|
327
|
+
// Note: we don't restore originals to avoid complexity.
|
|
328
|
+
// The patches become no-ops once threshold filtering removes them.
|
|
329
|
+
delete globalThis.__loopDetectiveIO;
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
return { started: true };
|
|
334
|
+
})()
|
|
335
|
+
`;
|
|
336
|
+
|
|
337
|
+
const result = await this.inspector.send('Runtime.evaluate', {
|
|
338
|
+
expression: script,
|
|
339
|
+
returnByValue: true,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
if (result.exceptionDetails) {
|
|
343
|
+
// Non-fatal: IO tracking is optional
|
|
344
|
+
this.emit('error', new Error(`Failed to inject I/O tracker (non-fatal): ${JSON.stringify(result.exceptionDetails)}`));
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Poll for slow I/O events
|
|
349
|
+
this._ioTimer = setInterval(async () => {
|
|
350
|
+
if (!this._running) return;
|
|
351
|
+
try {
|
|
352
|
+
const pollResult = await this.inspector.send('Runtime.evaluate', {
|
|
353
|
+
expression: 'globalThis.__loopDetectiveIO ? globalThis.__loopDetectiveIO.getSlowOps() : []',
|
|
354
|
+
returnByValue: true,
|
|
355
|
+
});
|
|
356
|
+
const ops = pollResult.result?.value || [];
|
|
357
|
+
for (const op of ops) {
|
|
358
|
+
this.emit('slowIO', op);
|
|
359
|
+
}
|
|
360
|
+
} catch {
|
|
361
|
+
// Inspector may have disconnected
|
|
362
|
+
}
|
|
363
|
+
}, 1000);
|
|
364
|
+
}
|
|
365
|
+
|
|
126
366
|
/**
|
|
127
367
|
* Take a CPU profile to identify blocking code
|
|
128
368
|
*/
|
|
@@ -140,7 +380,7 @@ class Detective extends EventEmitter {
|
|
|
140
380
|
}
|
|
141
381
|
|
|
142
382
|
/**
|
|
143
|
-
* Clean up the injected lag detector
|
|
383
|
+
* Clean up the injected lag detector and I/O tracker
|
|
144
384
|
*/
|
|
145
385
|
async _cleanupLagDetector() {
|
|
146
386
|
try {
|
|
@@ -151,6 +391,14 @@ class Detective extends EventEmitter {
|
|
|
151
391
|
} catch {
|
|
152
392
|
// Best effort cleanup
|
|
153
393
|
}
|
|
394
|
+
try {
|
|
395
|
+
await this.inspector.send('Runtime.evaluate', {
|
|
396
|
+
expression: 'globalThis.__loopDetectiveIO && globalThis.__loopDetectiveIO.cleanup()',
|
|
397
|
+
returnByValue: true,
|
|
398
|
+
});
|
|
399
|
+
} catch {
|
|
400
|
+
// Best effort cleanup
|
|
401
|
+
}
|
|
154
402
|
}
|
|
155
403
|
|
|
156
404
|
/**
|
|
@@ -177,8 +425,9 @@ class Detective extends EventEmitter {
|
|
|
177
425
|
|
|
178
426
|
async _singleRun() {
|
|
179
427
|
try {
|
|
180
|
-
// Step 3: Start lag detection
|
|
428
|
+
// Step 3: Start lag detection + async I/O tracking
|
|
181
429
|
await this._startLagDetection();
|
|
430
|
+
await this._startAsyncIOTracking();
|
|
182
431
|
|
|
183
432
|
// Step 4: Capture CPU profile
|
|
184
433
|
const profile = await this._captureProfile(this.config.duration);
|
|
@@ -193,6 +442,7 @@ class Detective extends EventEmitter {
|
|
|
193
442
|
|
|
194
443
|
async _watchMode() {
|
|
195
444
|
await this._startLagDetection();
|
|
445
|
+
await this._startAsyncIOTracking();
|
|
196
446
|
|
|
197
447
|
const runCycle = async () => {
|
|
198
448
|
if (!this._running) return;
|
|
@@ -220,6 +470,11 @@ class Detective extends EventEmitter {
|
|
|
220
470
|
this._lagTimer = null;
|
|
221
471
|
}
|
|
222
472
|
|
|
473
|
+
if (this._ioTimer) {
|
|
474
|
+
clearInterval(this._ioTimer);
|
|
475
|
+
this._ioTimer = null;
|
|
476
|
+
}
|
|
477
|
+
|
|
223
478
|
if (this.inspector) {
|
|
224
479
|
await this._cleanupLagDetector();
|
|
225
480
|
await this.inspector.disconnect();
|
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() {
|
|
@@ -33,12 +34,36 @@ class Reporter {
|
|
|
33
34
|
if (this.config.json) return;
|
|
34
35
|
const severity = data.lag > 500 ? COLORS.red : data.lag > 200 ? COLORS.yellow : COLORS.cyan;
|
|
35
36
|
this._print(`${severity}⚠ Event loop lag: ${data.lag}ms${COLORS.reset} ${COLORS.dim}at ${new Date(data.timestamp).toISOString()}${COLORS.reset}`);
|
|
37
|
+
if (data.stack && data.stack.length > 0) {
|
|
38
|
+
for (const frame of data.stack.slice(0, 5)) {
|
|
39
|
+
this._print(` ${COLORS.dim} → ${frame.fn} ${frame.file}:${frame.line}:${frame.col}${COLORS.reset}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
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
|
+
}
|
|
36
60
|
}
|
|
37
61
|
|
|
38
62
|
onProfile(analysis) {
|
|
39
63
|
if (this.config.json) {
|
|
40
|
-
this._print(JSON.stringify({ ...analysis, lagEvents: this.lagEvents }, null, 2));
|
|
64
|
+
this._print(JSON.stringify({ ...analysis, lagEvents: this.lagEvents, slowIOEvents: this.slowIOEvents }, null, 2));
|
|
41
65
|
this.lagEvents = [];
|
|
66
|
+
this.slowIOEvents = [];
|
|
42
67
|
return;
|
|
43
68
|
}
|
|
44
69
|
|
|
@@ -46,9 +71,11 @@ class Reporter {
|
|
|
46
71
|
this._printPatterns(analysis.blockingPatterns);
|
|
47
72
|
this._printHeavyFunctions(analysis.heavyFunctions);
|
|
48
73
|
this._printCallStacks(analysis.callStacks);
|
|
74
|
+
this._printSlowIOSummary();
|
|
49
75
|
this._printLagSummary();
|
|
50
76
|
|
|
51
77
|
this.lagEvents = [];
|
|
78
|
+
this.slowIOEvents = [];
|
|
52
79
|
}
|
|
53
80
|
|
|
54
81
|
onError(err) {
|
|
@@ -133,6 +160,53 @@ class Reporter {
|
|
|
133
160
|
}
|
|
134
161
|
}
|
|
135
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
|
+
|
|
136
210
|
_printLagSummary() {
|
|
137
211
|
if (this.lagEvents.length === 0) {
|
|
138
212
|
this._print(`\n ${COLORS.green}✔ No event loop lag detected above threshold${COLORS.reset}`);
|
|
@@ -143,6 +217,29 @@ class Reporter {
|
|
|
143
217
|
this._print(` Events: ${this.lagEvents.length}`);
|
|
144
218
|
this._print(` Max: ${maxLag}ms`);
|
|
145
219
|
this._print(` Avg: ${avgLag}ms`);
|
|
220
|
+
|
|
221
|
+
// Aggregate lag by code location
|
|
222
|
+
const locationMap = new Map();
|
|
223
|
+
for (const evt of this.lagEvents) {
|
|
224
|
+
if (evt.stack && evt.stack.length > 0) {
|
|
225
|
+
const top = evt.stack[0];
|
|
226
|
+
const key = `${top.fn} ${top.file}:${top.line}`;
|
|
227
|
+
const entry = locationMap.get(key) || { count: 0, totalLag: 0, maxLag: 0, frame: top };
|
|
228
|
+
entry.count++;
|
|
229
|
+
entry.totalLag += evt.lag;
|
|
230
|
+
entry.maxLag = Math.max(entry.maxLag, evt.lag);
|
|
231
|
+
locationMap.set(key, entry);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (locationMap.size > 0) {
|
|
236
|
+
this._print(`\n ${COLORS.bold}Lag by Code Location:${COLORS.reset}`);
|
|
237
|
+
const sorted = [...locationMap.values()].sort((a, b) => b.totalLag - a.totalLag);
|
|
238
|
+
for (const loc of sorted.slice(0, 5)) {
|
|
239
|
+
this._print(` ${COLORS.yellow}${loc.frame.fn}${COLORS.reset} ${COLORS.dim}${loc.frame.file}:${loc.frame.line}${COLORS.reset}`);
|
|
240
|
+
this._print(` ${loc.count} events, total ${loc.totalLag}ms, max ${loc.maxLag}ms`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
146
243
|
}
|
|
147
244
|
|
|
148
245
|
this._print(`\n${'─'.repeat(60)}\n`);
|