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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # node-loop-detective 🔍
2
2
 
3
- Detect event loop blocking & lag in **running** Node.js apps — without code changes or restarts.
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. Captures a CPU profile to identify blocking code
39
- 5. Analyzes the profile for common blocking patterns
40
- 6. Disconnects cleanly minimal impact on your running app
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-loop-detective",
3
- "version": "1.0.2",
3
+ "version": "1.1.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
@@ -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}`);