node-loop-detective 1.3.0 → 1.5.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 +6 -1
- package/bin/cli.js +24 -1
- package/package.json +1 -1
- package/src/detective.js +91 -4
- package/src/reporter.js +4 -4
package/README.md
CHANGED
|
@@ -77,6 +77,9 @@ loop-detective -p 12345 --io-threshold 1000
|
|
|
77
77
|
# Connect to a remote inspector (Docker, K8s, remote server)
|
|
78
78
|
loop-detective --host 192.168.1.100 --port 9229
|
|
79
79
|
|
|
80
|
+
# Save raw CPU profile for Chrome DevTools / speedscope
|
|
81
|
+
loop-detective -p 12345 --save-profile ./profile.cpuprofile
|
|
82
|
+
|
|
80
83
|
# Continuous monitoring mode
|
|
81
84
|
loop-detective -p 12345 --watch
|
|
82
85
|
|
|
@@ -95,6 +98,7 @@ loop-detective -p 12345 --json
|
|
|
95
98
|
| `-t, --threshold <ms>` | Event loop lag threshold | 50 |
|
|
96
99
|
| `-i, --interval <ms>` | Lag sampling interval | 100 |
|
|
97
100
|
| `--io-threshold <ms>` | Slow I/O threshold | 500 |
|
|
101
|
+
| `--save-profile <path>` | Save raw CPU profile to file | — |
|
|
98
102
|
| `-j, --json` | Output as JSON | false |
|
|
99
103
|
| `-w, --watch` | Continuous monitoring | false |
|
|
100
104
|
|
|
@@ -116,7 +120,8 @@ loop-detective -p 12345 --json
|
|
|
116
120
|
| Type | What It Tracks |
|
|
117
121
|
|------|---------------|
|
|
118
122
|
| 🌐 HTTP/HTTPS | Outgoing HTTP requests — method, target, status code, duration |
|
|
119
|
-
|
|
|
123
|
+
| 🌐 Fetch | Global `fetch()` calls (Node.js 18+) — method, target, status, duration |
|
|
124
|
+
| 🔍 DNS | DNS lookups — callback and promise API (`dns.lookup` + `dns.promises.lookup`) |
|
|
120
125
|
| 🔌 TCP | TCP connections — target host:port, connect time (covers databases, Redis, etc.) |
|
|
121
126
|
|
|
122
127
|
Each slow I/O event includes the caller stack trace, so you know exactly which code initiated the slow operation.
|
package/bin/cli.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
const { Detective } = require('../src/detective');
|
|
6
6
|
const { Reporter } = require('../src/reporter');
|
|
7
|
+
const fs = require('node:fs');
|
|
8
|
+
const path = require('node:path');
|
|
7
9
|
|
|
8
10
|
// Simple arg parser compatible with Node.js 16+
|
|
9
11
|
function parseCliArgs(argv) {
|
|
@@ -16,6 +18,7 @@ function parseCliArgs(argv) {
|
|
|
16
18
|
threshold: '50',
|
|
17
19
|
interval: '100',
|
|
18
20
|
'io-threshold': '500',
|
|
21
|
+
'save-profile': null,
|
|
19
22
|
json: false,
|
|
20
23
|
watch: false,
|
|
21
24
|
help: false,
|
|
@@ -31,6 +34,7 @@ function parseCliArgs(argv) {
|
|
|
31
34
|
'-t': 'threshold', '--threshold': 'threshold',
|
|
32
35
|
'-i': 'interval', '--interval': 'interval',
|
|
33
36
|
'--io-threshold': 'io-threshold',
|
|
37
|
+
'--save-profile': 'save-profile',
|
|
34
38
|
};
|
|
35
39
|
const boolMap = {
|
|
36
40
|
'-j': 'json', '--json': 'json',
|
|
@@ -97,6 +101,7 @@ function printUsage() {
|
|
|
97
101
|
-t, --threshold <ms> Event loop lag threshold in ms (default: 50)
|
|
98
102
|
-i, --interval <ms> Sampling interval in ms (default: 100)
|
|
99
103
|
--io-threshold <ms> Slow I/O threshold in ms (default: 500)
|
|
104
|
+
--save-profile <path> Save raw CPU profile to .cpuprofile file
|
|
100
105
|
-j, --json Output results as JSON
|
|
101
106
|
-w, --watch Continuous monitoring mode
|
|
102
107
|
-h, --help Show this help
|
|
@@ -127,6 +132,7 @@ async function main() {
|
|
|
127
132
|
threshold: parseInt(values.threshold, 10),
|
|
128
133
|
interval: parseInt(values.interval, 10),
|
|
129
134
|
ioThreshold: parseInt(values['io-threshold'], 10),
|
|
135
|
+
saveProfile: values['save-profile'],
|
|
130
136
|
watch: values.watch,
|
|
131
137
|
json: values.json,
|
|
132
138
|
};
|
|
@@ -142,7 +148,24 @@ async function main() {
|
|
|
142
148
|
detective.on('connected', () => reporter.onConnected());
|
|
143
149
|
detective.on('lag', (data) => reporter.onLag(data));
|
|
144
150
|
detective.on('slowIO', (data) => reporter.onSlowIO(data));
|
|
145
|
-
detective.on('profile', (
|
|
151
|
+
detective.on('profile', (analysis, rawProfile) => {
|
|
152
|
+
reporter.onProfile(analysis);
|
|
153
|
+
|
|
154
|
+
// Save raw CPU profile if requested
|
|
155
|
+
if (config.saveProfile && rawProfile) {
|
|
156
|
+
try {
|
|
157
|
+
const filePath = path.resolve(config.saveProfile);
|
|
158
|
+
fs.writeFileSync(filePath, JSON.stringify(rawProfile));
|
|
159
|
+
if (!config.json) {
|
|
160
|
+
console.log(`\n \x1b[32m✔\x1b[0m CPU profile saved to ${filePath}`);
|
|
161
|
+
console.log(` Open in Chrome DevTools: Performance tab → Load profile`);
|
|
162
|
+
console.log(` Or visit https://www.speedscope.app\n`);
|
|
163
|
+
}
|
|
164
|
+
} catch (err) {
|
|
165
|
+
console.error(`\n \x1b[31m✖ Failed to save profile: ${err.message}\x1b[0m\n`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
});
|
|
146
169
|
detective.on('error', (err) => reporter.onError(err));
|
|
147
170
|
detective.on('disconnected', () => reporter.onDisconnected());
|
|
148
171
|
|
package/package.json
CHANGED
package/src/detective.js
CHANGED
|
@@ -261,7 +261,7 @@ class Detective extends EventEmitter {
|
|
|
261
261
|
patchHttp('http');
|
|
262
262
|
patchHttp('https');
|
|
263
263
|
|
|
264
|
-
// --- Track DNS lookups ---
|
|
264
|
+
// --- Track DNS lookups (callback API) ---
|
|
265
265
|
(function patchDns() {
|
|
266
266
|
let dns;
|
|
267
267
|
try { dns = require('dns'); } catch { return; }
|
|
@@ -287,6 +287,40 @@ class Detective extends EventEmitter {
|
|
|
287
287
|
if (callback) callback(err, address, family);
|
|
288
288
|
});
|
|
289
289
|
};
|
|
290
|
+
|
|
291
|
+
// --- Track DNS lookups (promise API, Node.js 10.6+) ---
|
|
292
|
+
if (dns.promises && dns.promises.lookup) {
|
|
293
|
+
const origPromiseLookup = dns.promises.lookup;
|
|
294
|
+
originals['dns.promises.lookup'] = { mod: dns.promises, key: 'lookup', fn: origPromiseLookup };
|
|
295
|
+
|
|
296
|
+
dns.promises.lookup = function patchedPromiseLookup(hostname, options) {
|
|
297
|
+
const startTime = Date.now();
|
|
298
|
+
const callerStack = captureCallerStack();
|
|
299
|
+
|
|
300
|
+
return origPromiseLookup.call(dns.promises, hostname, options).then(
|
|
301
|
+
(result) => {
|
|
302
|
+
const duration = Date.now() - startTime;
|
|
303
|
+
if (duration >= threshold) {
|
|
304
|
+
recordSlowOp({
|
|
305
|
+
type: 'dns', target: hostname, duration,
|
|
306
|
+
timestamp: Date.now(), stack: callerStack,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
return result;
|
|
310
|
+
},
|
|
311
|
+
(err) => {
|
|
312
|
+
const duration = Date.now() - startTime;
|
|
313
|
+
if (duration >= threshold) {
|
|
314
|
+
recordSlowOp({
|
|
315
|
+
type: 'dns', target: hostname, duration,
|
|
316
|
+
error: err.message, timestamp: Date.now(), stack: callerStack,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
throw err;
|
|
320
|
+
}
|
|
321
|
+
);
|
|
322
|
+
};
|
|
323
|
+
}
|
|
290
324
|
})();
|
|
291
325
|
|
|
292
326
|
// --- Track TCP socket connections ---
|
|
@@ -320,10 +354,63 @@ class Detective extends EventEmitter {
|
|
|
320
354
|
};
|
|
321
355
|
})();
|
|
322
356
|
|
|
357
|
+
// --- Track global fetch() (Node.js 18+) ---
|
|
358
|
+
(function patchFetch() {
|
|
359
|
+
if (typeof globalThis.fetch !== 'function') return;
|
|
360
|
+
const origFetch = globalThis.fetch;
|
|
361
|
+
originals['globalThis.fetch'] = { mod: globalThis, key: 'fetch', fn: origFetch };
|
|
362
|
+
|
|
363
|
+
globalThis.fetch = function patchedFetch(input, init) {
|
|
364
|
+
const startTime = Date.now();
|
|
365
|
+
const callerStack = captureCallerStack();
|
|
366
|
+
|
|
367
|
+
// Extract target URL
|
|
368
|
+
let target = 'unknown';
|
|
369
|
+
let method = 'GET';
|
|
370
|
+
if (typeof input === 'string') {
|
|
371
|
+
target = input;
|
|
372
|
+
} else if (input && typeof input === 'object') {
|
|
373
|
+
target = input.url || input.href || String(input);
|
|
374
|
+
method = (input.method || 'GET').toUpperCase();
|
|
375
|
+
}
|
|
376
|
+
if (init && init.method) {
|
|
377
|
+
method = init.method.toUpperCase();
|
|
378
|
+
}
|
|
379
|
+
// Shorten target for display
|
|
380
|
+
try {
|
|
381
|
+
const u = new URL(target);
|
|
382
|
+
target = u.host + u.pathname;
|
|
383
|
+
} catch {}
|
|
384
|
+
|
|
385
|
+
return origFetch.call(this, input, init).then(
|
|
386
|
+
(res) => {
|
|
387
|
+
const duration = Date.now() - startTime;
|
|
388
|
+
if (duration >= threshold) {
|
|
389
|
+
recordSlowOp({
|
|
390
|
+
type: 'fetch', method, target,
|
|
391
|
+
statusCode: res.status, duration, timestamp: Date.now(), stack: callerStack,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
return res;
|
|
395
|
+
},
|
|
396
|
+
(err) => {
|
|
397
|
+
const duration = Date.now() - startTime;
|
|
398
|
+
if (duration >= threshold) {
|
|
399
|
+
recordSlowOp({
|
|
400
|
+
type: 'fetch', method, target,
|
|
401
|
+
error: err.message, duration, timestamp: Date.now(), stack: callerStack,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
throw err;
|
|
405
|
+
}
|
|
406
|
+
);
|
|
407
|
+
};
|
|
408
|
+
})();
|
|
409
|
+
|
|
323
410
|
globalThis.__loopDetectiveIO = {
|
|
324
411
|
getSlowOps: () => slowOps.splice(0),
|
|
325
412
|
cleanup: () => {
|
|
326
|
-
//
|
|
413
|
+
// Restore all original functions
|
|
327
414
|
for (const entry of Object.values(originals)) {
|
|
328
415
|
entry.mod[entry.key] = entry.fn;
|
|
329
416
|
}
|
|
@@ -424,7 +511,7 @@ class Detective extends EventEmitter {
|
|
|
424
511
|
|
|
425
512
|
const profile = await this._captureProfile(this.config.duration);
|
|
426
513
|
const analysis = this.analyzer.analyzeProfile(profile);
|
|
427
|
-
this.emit('profile', analysis);
|
|
514
|
+
this.emit('profile', analysis, profile);
|
|
428
515
|
} finally {
|
|
429
516
|
await this.stop();
|
|
430
517
|
}
|
|
@@ -444,7 +531,7 @@ class Detective extends EventEmitter {
|
|
|
444
531
|
try {
|
|
445
532
|
const profile = await this._captureProfile(this.config.duration);
|
|
446
533
|
const analysis = this.analyzer.analyzeProfile(profile);
|
|
447
|
-
this.emit('profile', analysis);
|
|
534
|
+
this.emit('profile', analysis, profile);
|
|
448
535
|
} catch (err) {
|
|
449
536
|
this.emit('error', err);
|
|
450
537
|
}
|
package/src/reporter.js
CHANGED
|
@@ -45,8 +45,8 @@ class Reporter {
|
|
|
45
45
|
this.slowIOEvents.push(data);
|
|
46
46
|
if (this.config.json) return;
|
|
47
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'
|
|
48
|
+
const icon = data.type === 'http' ? '🌐' : data.type === 'fetch' ? '🌐' : data.type === 'dns' ? '🔍' : '🔌';
|
|
49
|
+
const detail = data.type === 'http' || data.type === 'fetch'
|
|
50
50
|
? `${data.method} ${data.target} → ${data.statusCode || data.error || '?'}`
|
|
51
51
|
: data.type === 'dns'
|
|
52
52
|
? `lookup ${data.target}${data.error ? ' (' + data.error + ')' : ''}`
|
|
@@ -177,7 +177,7 @@ class Reporter {
|
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
for (const [type, ops] of Object.entries(byType)) {
|
|
180
|
-
const icon = type === 'http' ? '🌐' : type === 'dns' ? '🔍' : '🔌';
|
|
180
|
+
const icon = type === 'http' ? '🌐' : type === 'fetch' ? '🌐' : type === 'dns' ? '🔍' : '🔌';
|
|
181
181
|
const maxDur = Math.max(...ops.map(o => o.duration));
|
|
182
182
|
const avgDur = Math.round(ops.reduce((s, o) => s + o.duration, 0) / ops.length);
|
|
183
183
|
this._print(`\n ${icon} ${COLORS.bold}${type.toUpperCase()}${COLORS.reset} — ${ops.length} slow ops, avg ${avgDur}ms, max ${maxDur}ms`);
|
|
@@ -185,7 +185,7 @@ class Reporter {
|
|
|
185
185
|
// Group by target
|
|
186
186
|
const byTarget = {};
|
|
187
187
|
for (const op of ops) {
|
|
188
|
-
const key = op.type === 'http' ? `${op.method} ${op.target}` : op.target;
|
|
188
|
+
const key = (op.type === 'http' || op.type === 'fetch') ? `${op.method} ${op.target}` : op.target;
|
|
189
189
|
if (!byTarget[key]) byTarget[key] = { count: 0, totalDuration: 0, maxDuration: 0, errors: 0, stack: op.stack };
|
|
190
190
|
byTarget[key].count++;
|
|
191
191
|
byTarget[key].totalDuration += op.duration;
|