node-loop-detective 1.0.2 → 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 +236 -2
- 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/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
|
/**
|
|
@@ -144,6 +145,224 @@ class Detective extends EventEmitter {
|
|
|
144
145
|
}, 1000);
|
|
145
146
|
}
|
|
146
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
|
+
|
|
147
366
|
/**
|
|
148
367
|
* Take a CPU profile to identify blocking code
|
|
149
368
|
*/
|
|
@@ -161,7 +380,7 @@ class Detective extends EventEmitter {
|
|
|
161
380
|
}
|
|
162
381
|
|
|
163
382
|
/**
|
|
164
|
-
* Clean up the injected lag detector
|
|
383
|
+
* Clean up the injected lag detector and I/O tracker
|
|
165
384
|
*/
|
|
166
385
|
async _cleanupLagDetector() {
|
|
167
386
|
try {
|
|
@@ -172,6 +391,14 @@ class Detective extends EventEmitter {
|
|
|
172
391
|
} catch {
|
|
173
392
|
// Best effort cleanup
|
|
174
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
|
+
}
|
|
175
402
|
}
|
|
176
403
|
|
|
177
404
|
/**
|
|
@@ -198,8 +425,9 @@ class Detective extends EventEmitter {
|
|
|
198
425
|
|
|
199
426
|
async _singleRun() {
|
|
200
427
|
try {
|
|
201
|
-
// Step 3: Start lag detection
|
|
428
|
+
// Step 3: Start lag detection + async I/O tracking
|
|
202
429
|
await this._startLagDetection();
|
|
430
|
+
await this._startAsyncIOTracking();
|
|
203
431
|
|
|
204
432
|
// Step 4: Capture CPU profile
|
|
205
433
|
const profile = await this._captureProfile(this.config.duration);
|
|
@@ -214,6 +442,7 @@ class Detective extends EventEmitter {
|
|
|
214
442
|
|
|
215
443
|
async _watchMode() {
|
|
216
444
|
await this._startLagDetection();
|
|
445
|
+
await this._startAsyncIOTracking();
|
|
217
446
|
|
|
218
447
|
const runCycle = async () => {
|
|
219
448
|
if (!this._running) return;
|
|
@@ -241,6 +470,11 @@ class Detective extends EventEmitter {
|
|
|
241
470
|
this._lagTimer = null;
|
|
242
471
|
}
|
|
243
472
|
|
|
473
|
+
if (this._ioTimer) {
|
|
474
|
+
clearInterval(this._ioTimer);
|
|
475
|
+
this._ioTimer = null;
|
|
476
|
+
}
|
|
477
|
+
|
|
244
478
|
if (this.inspector) {
|
|
245
479
|
await this._cleanupLagDetector();
|
|
246
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() {
|
|
@@ -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}`);
|