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 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.1",
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
  /**
@@ -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 into the target process
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`);