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 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
- | 🔍 DNS | DNS lookupshostname, resolution time |
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', (data) => reporter.onProfile(data));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-loop-detective",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Detect event loop blocking & lag in running Node.js apps without code changes or restarts",
5
5
  "main": "src/index.js",
6
6
  "bin": {
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
- // Fix #1: Restore all original functions
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;