node-loop-detective 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,124 @@
1
+ # node-loop-detective 🔍
2
+
3
+ Detect event loop blocking & lag in **running** Node.js apps — without code changes or restarts.
4
+
5
+ ```
6
+ $ loop-detective 12345
7
+
8
+ ✔ Connected to Node.js process
9
+ Profiling for 10s with 50ms lag threshold...
10
+
11
+ ⚠ Event loop lag: 312ms at 2025-01-15T10:23:45.123Z
12
+ ⚠ Event loop lag: 156ms at 2025-01-15T10:23:48.456Z
13
+
14
+ ────────────────────────────────────────────────────────────
15
+ Event Loop Detective Report
16
+ ────────────────────────────────────────────────────────────
17
+ Duration: 10023ms
18
+ Samples: 4521
19
+ Hot funcs: 12
20
+
21
+ Diagnosis
22
+ ────────────────────────────────────────────────────────────
23
+ HIGH cpu-hog
24
+ Function "heavyComputation" consumed 62.3% of CPU time (6245ms)
25
+ at /app/server.js:42
26
+ → Consider breaking this into smaller async chunks or moving to a worker thread
27
+
28
+ 1. heavyComputation
29
+ ██████████████░░░░░░ 6245ms (62.3%)
30
+ /app/server.js:42:1
31
+ ```
32
+
33
+ ## How It Works
34
+
35
+ 1. Sends `SIGUSR1` to activate the Node.js built-in inspector (or connects to `--port`)
36
+ 2. Connects via Chrome DevTools Protocol (CDP)
37
+ 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
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ npm install -g node-loop-detective
46
+ ```
47
+
48
+ ## Usage
49
+
50
+ ```bash
51
+ # Basic: profile a running Node.js process by PID
52
+ loop-detective <pid>
53
+
54
+ # Connect to an already-open inspector port
55
+ loop-detective --port 9229
56
+
57
+ # Profile for 30 seconds with 100ms lag threshold
58
+ loop-detective -p 12345 -d 30 -t 100
59
+
60
+ # Continuous monitoring mode
61
+ loop-detective -p 12345 --watch
62
+
63
+ # JSON output (for piping to other tools)
64
+ loop-detective -p 12345 --json
65
+ ```
66
+
67
+ ## Options
68
+
69
+ | Flag | Description | Default |
70
+ |------|-------------|---------|
71
+ | `-p, --pid <pid>` | Target Node.js process ID | — |
72
+ | `-P, --port <port>` | Inspector port (skip SIGUSR1) | — |
73
+ | `-d, --duration <sec>` | Profiling duration in seconds | 10 |
74
+ | `-t, --threshold <ms>` | Event loop lag threshold | 50 |
75
+ | `-i, --interval <ms>` | Lag sampling interval | 100 |
76
+ | `-j, --json` | Output as JSON | false |
77
+ | `-w, --watch` | Continuous monitoring | false |
78
+
79
+ ## What It Detects
80
+
81
+ | Pattern | Description |
82
+ |---------|-------------|
83
+ | `cpu-hog` | Single function consuming >50% CPU |
84
+ | `json-heavy` | Excessive JSON parse/stringify |
85
+ | `regex-heavy` | RegExp backtracking |
86
+ | `gc-pressure` | High garbage collection time |
87
+ | `sync-io` | Synchronous file I/O calls |
88
+ | `crypto-heavy` | CPU-intensive crypto operations |
89
+
90
+ ## Programmatic API
91
+
92
+ ```js
93
+ const { Detective } = require('node-loop-detective');
94
+
95
+ const detective = new Detective({
96
+ pid: 12345,
97
+ duration: 10000,
98
+ threshold: 50,
99
+ interval: 100,
100
+ });
101
+
102
+ detective.on('lag', (data) => console.log('Lag:', data.lag, 'ms'));
103
+ detective.on('profile', (analysis) => {
104
+ console.log('Heavy functions:', analysis.heavyFunctions);
105
+ console.log('Patterns:', analysis.blockingPatterns);
106
+ });
107
+
108
+ await detective.start();
109
+ ```
110
+
111
+ ## Requirements
112
+
113
+ - Node.js >= 16
114
+ - Target process must be running Node.js
115
+ - On Linux/macOS: permission to send signals to the target process
116
+ - On Windows: target must be started with `--inspect` flag (SIGUSR1 not available)
117
+
118
+ ## How is this different from clinic.js / 0x?
119
+
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.
121
+
122
+ ## License
123
+
124
+ MIT
package/bin/cli.js ADDED
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+
3
+ 'use strict';
4
+
5
+ const { parseArgs } = require('node:util');
6
+ const { Detective } = require('../src/detective');
7
+ const { Reporter } = require('../src/reporter');
8
+
9
+ const options = {
10
+ pid: { type: 'string', short: 'p' },
11
+ port: { type: 'string', short: 'P', default: '' },
12
+ duration: { type: 'string', short: 'd', default: '10' },
13
+ threshold: { type: 'string', short: 't', default: '50' },
14
+ interval: { type: 'string', short: 'i', default: '100' },
15
+ json: { type: 'boolean', short: 'j', default: false },
16
+ watch: { type: 'boolean', short: 'w', default: false },
17
+ help: { type: 'boolean', short: 'h', default: false },
18
+ version: { type: 'boolean', short: 'v', default: false },
19
+ };
20
+
21
+ let parsed;
22
+ try {
23
+ parsed = parseArgs({ options, allowPositionals: true });
24
+ } catch (err) {
25
+ console.error(`Error: ${err.message}\n`);
26
+ printUsage();
27
+ process.exit(1);
28
+ }
29
+
30
+ const { values, positionals } = parsed;
31
+
32
+ if (values.version) {
33
+ const pkg = require('../package.json');
34
+ console.log(pkg.version);
35
+ process.exit(0);
36
+ }
37
+
38
+ if (values.help) {
39
+ printUsage();
40
+ process.exit(0);
41
+ }
42
+
43
+ const pid = values.pid || positionals[0];
44
+ const inspectorPort = values.port ? parseInt(values.port, 10) : null;
45
+
46
+ if (!pid && !inspectorPort) {
47
+ console.error('Error: Please provide a target PID or --port\n');
48
+ printUsage();
49
+ process.exit(1);
50
+ }
51
+
52
+ function printUsage() {
53
+ console.log(`
54
+ loop-detective - Detect event loop blocking in running Node.js apps
55
+
56
+ USAGE
57
+ loop-detective <pid>
58
+ loop-detective --pid <pid>
59
+ loop-detective --port <inspector-port>
60
+
61
+ OPTIONS
62
+ -p, --pid <pid> Target Node.js process ID
63
+ -P, --port <port> Connect to an already-open inspector port
64
+ -d, --duration <sec> Profiling duration in seconds (default: 10)
65
+ -t, --threshold <ms> Event loop lag threshold in ms (default: 50)
66
+ -i, --interval <ms> Sampling interval in ms (default: 100)
67
+ -j, --json Output results as JSON
68
+ -w, --watch Continuous monitoring mode
69
+ -h, --help Show this help
70
+ -v, --version Show version
71
+
72
+ EXAMPLES
73
+ loop-detective 12345
74
+ loop-detective --pid 12345 --duration 30 --threshold 100
75
+ loop-detective --port 9229 --watch
76
+ loop-detective -p 12345 -d 5 -j
77
+
78
+ HOW IT WORKS
79
+ 1. Sends SIGUSR1 to activate the Node.js inspector (or connects to --port)
80
+ 2. Connects via Chrome DevTools Protocol (CDP)
81
+ 3. Profiles CPU usage and monitors event loop lag
82
+ 4. Reports blocking functions with file locations and durations
83
+ 5. Disconnects cleanly — zero impact on your running app
84
+ `);
85
+ }
86
+
87
+ async function main() {
88
+ const config = {
89
+ pid: pid ? parseInt(pid, 10) : null,
90
+ inspectorPort,
91
+ duration: parseInt(values.duration, 10) * 1000,
92
+ threshold: parseInt(values.threshold, 10),
93
+ interval: parseInt(values.interval, 10),
94
+ watch: values.watch,
95
+ json: values.json,
96
+ };
97
+
98
+ const reporter = new Reporter(config);
99
+ const detective = new Detective(config);
100
+
101
+ detective.on('connected', () => reporter.onConnected());
102
+ detective.on('lag', (data) => reporter.onLag(data));
103
+ detective.on('profile', (data) => reporter.onProfile(data));
104
+ detective.on('error', (err) => reporter.onError(err));
105
+ detective.on('disconnected', () => reporter.onDisconnected());
106
+
107
+ process.on('SIGINT', async () => {
108
+ reporter.onInfo('Shutting down...');
109
+ await detective.stop();
110
+ process.exit(0);
111
+ });
112
+
113
+ try {
114
+ await detective.start();
115
+ } catch (err) {
116
+ reporter.onError(err);
117
+ process.exit(1);
118
+ }
119
+ }
120
+
121
+ main();
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "node-loop-detective",
3
+ "version": "1.0.0",
4
+ "description": "Detect event loop blocking & lag in running Node.js apps without code changes or restarts",
5
+ "main": "src/index.js",
6
+ "bin": {
7
+ "loop-detective": "./bin/cli.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node bin/cli.js",
11
+ "test": "node test/test.js"
12
+ },
13
+ "keywords": [
14
+ "nodejs",
15
+ "event-loop",
16
+ "blocking",
17
+ "lag",
18
+ "diagnostics",
19
+ "profiler",
20
+ "inspector",
21
+ "performance",
22
+ "debugging"
23
+ ],
24
+ "author": "",
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/iwtxokhtd83/node-loop-detective.git"
29
+ },
30
+ "homepage": "https://github.com/iwtxokhtd83/node-loop-detective#readme",
31
+ "bugs": {
32
+ "url": "https://github.com/iwtxokhtd83/node-loop-detective/issues"
33
+ },
34
+ "dependencies": {
35
+ "ws": "^8.16.0"
36
+ },
37
+ "engines": {
38
+ "node": ">=16.0.0"
39
+ },
40
+ "files": [
41
+ "bin/",
42
+ "src/",
43
+ "README.md",
44
+ "LICENSE"
45
+ ]
46
+ }
@@ -0,0 +1,247 @@
1
+ 'use strict';
2
+
3
+ class Analyzer {
4
+ constructor(config) {
5
+ this.config = config;
6
+ }
7
+
8
+ /**
9
+ * Analyze a V8 CPU profile to find blocking functions
10
+ *
11
+ * V8 CPU profile format:
12
+ * - nodes: array of { id, callFrame: { functionName, url, lineNumber, columnNumber }, hitCount, children }
13
+ * - startTime, endTime: microseconds
14
+ * - samples: array of node IDs (sampled at each tick)
15
+ * - timeDeltas: array of time deltas between samples (microseconds)
16
+ */
17
+ analyzeProfile(profile) {
18
+ const { nodes, samples, timeDeltas, startTime, endTime } = profile;
19
+
20
+ const nodeMap = new Map();
21
+ for (const node of nodes) {
22
+ nodeMap.set(node.id, node);
23
+ }
24
+
25
+ // Calculate total time per node
26
+ const timings = new Map();
27
+ for (let i = 0; i < samples.length; i++) {
28
+ const nodeId = samples[i];
29
+ const delta = timeDeltas[i] || 0;
30
+ timings.set(nodeId, (timings.get(nodeId) || 0) + delta);
31
+ }
32
+
33
+ // Build results: find heavy functions
34
+ const totalDuration = endTime - startTime; // microseconds
35
+ const heavyFunctions = [];
36
+
37
+ for (const [nodeId, selfTime] of timings) {
38
+ const node = nodeMap.get(nodeId);
39
+ if (!node) continue;
40
+
41
+ const { functionName, url, lineNumber, columnNumber } = node.callFrame;
42
+
43
+ // Skip internal/idle nodes
44
+ if (!url && !functionName) continue;
45
+ if (functionName === '(idle)' || functionName === '(program)') continue;
46
+ if (functionName === '(garbage collector)') {
47
+ // GC is interesting — keep it
48
+ }
49
+
50
+ const selfTimeMs = selfTime / 1000;
51
+ const percentage = totalDuration > 0 ? (selfTime / totalDuration) * 100 : 0;
52
+
53
+ if (selfTimeMs < 1) continue; // skip trivial entries
54
+
55
+ heavyFunctions.push({
56
+ functionName: functionName || '(anonymous)',
57
+ url: url || '(native)',
58
+ lineNumber: lineNumber + 1, // V8 uses 0-based
59
+ columnNumber: columnNumber + 1,
60
+ selfTimeMs: Math.round(selfTimeMs * 100) / 100,
61
+ percentage: Math.round(percentage * 100) / 100,
62
+ });
63
+ }
64
+
65
+ // Sort by self time descending
66
+ heavyFunctions.sort((a, b) => b.selfTimeMs - a.selfTimeMs);
67
+
68
+ // Build call tree for the top blockers
69
+ const callStacks = this._buildCallStacks(nodeMap, timings, heavyFunctions.slice(0, 5));
70
+
71
+ // Detect event loop blocking patterns
72
+ const blockingPatterns = this._detectPatterns(heavyFunctions, profile);
73
+
74
+ return {
75
+ summary: {
76
+ totalDurationMs: Math.round(totalDuration / 1000),
77
+ samplesCount: samples.length,
78
+ heavyFunctionCount: heavyFunctions.length,
79
+ },
80
+ heavyFunctions: heavyFunctions.slice(0, 20),
81
+ callStacks,
82
+ blockingPatterns,
83
+ timestamp: Date.now(),
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Build call stacks for the heaviest functions
89
+ */
90
+ _buildCallStacks(nodeMap, timings, topFunctions) {
91
+ const stacks = [];
92
+
93
+ // Build parent map
94
+ const parentMap = new Map();
95
+ for (const node of nodeMap.values()) {
96
+ if (node.children) {
97
+ for (const childId of node.children) {
98
+ parentMap.set(childId, node.id);
99
+ }
100
+ }
101
+ }
102
+
103
+ for (const fn of topFunctions) {
104
+ // Find the node matching this function
105
+ let targetNode = null;
106
+ for (const node of nodeMap.values()) {
107
+ const cf = node.callFrame;
108
+ if (cf.functionName === fn.functionName &&
109
+ cf.url === (fn.url === '(native)' ? '' : fn.url) &&
110
+ cf.lineNumber === fn.lineNumber - 1) {
111
+ targetNode = node;
112
+ break;
113
+ }
114
+ }
115
+
116
+ if (!targetNode) continue;
117
+
118
+ // Walk up the call stack
119
+ const stack = [];
120
+ let current = targetNode;
121
+ while (current) {
122
+ const cf = current.callFrame;
123
+ if (cf.functionName || cf.url) {
124
+ stack.push({
125
+ functionName: cf.functionName || '(anonymous)',
126
+ url: cf.url || '(native)',
127
+ lineNumber: cf.lineNumber + 1,
128
+ columnNumber: cf.columnNumber + 1,
129
+ });
130
+ }
131
+ const parentId = parentMap.get(current.id);
132
+ current = parentId ? nodeMap.get(parentId) : null;
133
+ }
134
+
135
+ stacks.push({
136
+ target: fn.functionName,
137
+ selfTimeMs: fn.selfTimeMs,
138
+ stack: stack.reverse(),
139
+ });
140
+ }
141
+
142
+ return stacks;
143
+ }
144
+
145
+ /**
146
+ * Detect common event loop blocking patterns
147
+ */
148
+ _detectPatterns(heavyFunctions, profile) {
149
+ const patterns = [];
150
+ const totalMs = (profile.endTime - profile.startTime) / 1000;
151
+
152
+ // Pattern: Single function dominating CPU
153
+ const topFn = heavyFunctions[0];
154
+ if (topFn && topFn.percentage > 50) {
155
+ patterns.push({
156
+ type: 'cpu-hog',
157
+ severity: 'high',
158
+ message: `Function "${topFn.functionName}" consumed ${topFn.percentage}% of CPU time (${topFn.selfTimeMs}ms)`,
159
+ location: `${topFn.url}:${topFn.lineNumber}`,
160
+ suggestion: 'Consider breaking this into smaller async chunks or moving to a worker thread',
161
+ });
162
+ }
163
+
164
+ // Pattern: JSON parsing / serialization
165
+ const jsonFns = heavyFunctions.filter(
166
+ (f) => f.functionName.includes('JSON') || f.functionName.includes('parse') || f.functionName.includes('stringify')
167
+ );
168
+ const jsonTime = jsonFns.reduce((sum, f) => sum + f.selfTimeMs, 0);
169
+ if (jsonTime > totalMs * 0.1) {
170
+ patterns.push({
171
+ type: 'json-heavy',
172
+ severity: 'medium',
173
+ message: `JSON operations took ${Math.round(jsonTime)}ms (${Math.round((jsonTime / totalMs) * 100)}% of profile)`,
174
+ suggestion: 'Consider streaming JSON parsing or processing smaller payloads',
175
+ });
176
+ }
177
+
178
+ // Pattern: RegExp heavy
179
+ const regexFns = heavyFunctions.filter(
180
+ (f) => f.functionName.includes('RegExp') || f.functionName.includes('exec') || f.functionName.includes('match')
181
+ );
182
+ const regexTime = regexFns.reduce((sum, f) => sum + f.selfTimeMs, 0);
183
+ if (regexTime > totalMs * 0.1) {
184
+ patterns.push({
185
+ type: 'regex-heavy',
186
+ severity: 'medium',
187
+ message: `RegExp operations took ${Math.round(regexTime)}ms`,
188
+ suggestion: 'Check for catastrophic backtracking in regex patterns. Consider simpler string operations.',
189
+ });
190
+ }
191
+
192
+ // Pattern: GC pressure
193
+ const gcFns = heavyFunctions.filter((f) => f.functionName.includes('garbage collector'));
194
+ const gcTime = gcFns.reduce((sum, f) => sum + f.selfTimeMs, 0);
195
+ if (gcTime > totalMs * 0.05) {
196
+ patterns.push({
197
+ type: 'gc-pressure',
198
+ severity: 'medium',
199
+ message: `Garbage collection took ${Math.round(gcTime)}ms (${Math.round((gcTime / totalMs) * 100)}% of profile)`,
200
+ suggestion: 'Reduce object allocations. Reuse buffers. Check for memory leaks.',
201
+ });
202
+ }
203
+
204
+ // Pattern: Synchronous file I/O
205
+ const syncFns = heavyFunctions.filter(
206
+ (f) => f.functionName.includes('Sync') || f.url.includes('fs.js') || f.url.includes('node:fs')
207
+ );
208
+ if (syncFns.length > 0) {
209
+ const syncTime = syncFns.reduce((sum, f) => sum + f.selfTimeMs, 0);
210
+ patterns.push({
211
+ type: 'sync-io',
212
+ severity: 'high',
213
+ message: `Synchronous I/O detected: ${syncFns.map((f) => f.functionName).join(', ')} (${Math.round(syncTime)}ms)`,
214
+ suggestion: 'Replace synchronous file operations with async alternatives',
215
+ });
216
+ }
217
+
218
+ // Pattern: Crypto operations
219
+ const cryptoFns = heavyFunctions.filter(
220
+ (f) => f.url.includes('crypto') || f.functionName.includes('pbkdf') || f.functionName.includes('hash')
221
+ );
222
+ if (cryptoFns.length > 0) {
223
+ const cryptoTime = cryptoFns.reduce((sum, f) => sum + f.selfTimeMs, 0);
224
+ if (cryptoTime > totalMs * 0.1) {
225
+ patterns.push({
226
+ type: 'crypto-heavy',
227
+ severity: 'medium',
228
+ message: `Crypto operations took ${Math.round(cryptoTime)}ms`,
229
+ suggestion: 'Consider offloading heavy crypto to worker threads',
230
+ });
231
+ }
232
+ }
233
+
234
+ if (patterns.length === 0) {
235
+ patterns.push({
236
+ type: 'healthy',
237
+ severity: 'low',
238
+ message: 'No obvious event loop blocking patterns detected in this sample',
239
+ suggestion: 'Try profiling for a longer duration or during peak load',
240
+ });
241
+ }
242
+
243
+ return patterns;
244
+ }
245
+ }
246
+
247
+ module.exports = { Analyzer };
@@ -0,0 +1,237 @@
1
+ 'use strict';
2
+
3
+ const { EventEmitter } = require('node:events');
4
+ const { Inspector } = require('./inspector');
5
+ const { Analyzer } = require('./analyzer');
6
+
7
+ class Detective extends EventEmitter {
8
+ constructor(config) {
9
+ super();
10
+ this.config = config;
11
+ this.inspector = null;
12
+ this.analyzer = new Analyzer(config);
13
+ this._running = false;
14
+ this._lagTimer = null;
15
+ }
16
+
17
+ /**
18
+ * Activate the inspector on the target process via SIGUSR1
19
+ */
20
+ _activateInspector() {
21
+ if (this.config.inspectorPort) return; // already specified
22
+
23
+ const pid = this.config.pid;
24
+ if (!pid) throw new Error('No PID provided');
25
+
26
+ try {
27
+ process.kill(pid, 0); // check process exists
28
+ } catch (err) {
29
+ throw new Error(`Process ${pid} not found or not accessible: ${err.message}`);
30
+ }
31
+
32
+ try {
33
+ process.kill(pid, 'SIGUSR1');
34
+ } catch (err) {
35
+ throw new Error(
36
+ `Failed to send SIGUSR1 to process ${pid}: ${err.message}\n` +
37
+ 'Try running with elevated permissions, or start the target with --inspect'
38
+ );
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Discover which port the inspector opened on
44
+ */
45
+ async _findInspectorPort() {
46
+ if (this.config.inspectorPort) return this.config.inspectorPort;
47
+
48
+ // After SIGUSR1, Node.js opens inspector on 9229 by default
49
+ // Give it a moment to start
50
+ await this._sleep(1000);
51
+ return 9229;
52
+ }
53
+
54
+ /**
55
+ * Start event loop lag detection via CDP Runtime.evaluate
56
+ * We inject a tiny lag-measuring snippet into the target process
57
+ */
58
+ async _startLagDetection() {
59
+ // Inject a lag detector into the target process
60
+ const script = `
61
+ (function() {
62
+ if (globalThis.__loopDetective) {
63
+ return { alreadyRunning: true };
64
+ }
65
+ const lags = [];
66
+ let lastTime = Date.now();
67
+ const threshold = ${this.config.threshold};
68
+
69
+ const timer = setInterval(() => {
70
+ const now = Date.now();
71
+ const delta = now - lastTime;
72
+ const lag = delta - ${this.config.interval};
73
+ if (lag > threshold) {
74
+ lags.push({ lag, timestamp: now });
75
+ if (lags.length > 100) lags.shift();
76
+ }
77
+ lastTime = now;
78
+ }, ${this.config.interval});
79
+
80
+ // Make sure our timer doesn't keep the process alive
81
+ if (timer.unref) timer.unref();
82
+
83
+ globalThis.__loopDetective = {
84
+ timer,
85
+ getLags: () => {
86
+ const result = lags.splice(0);
87
+ return result;
88
+ },
89
+ cleanup: () => {
90
+ clearInterval(timer);
91
+ delete globalThis.__loopDetective;
92
+ }
93
+ };
94
+ return { started: true };
95
+ })()
96
+ `;
97
+
98
+ await this.inspector.send('Runtime.enable');
99
+ const result = await this.inspector.send('Runtime.evaluate', {
100
+ expression: script,
101
+ returnByValue: true,
102
+ });
103
+
104
+ if (result.exceptionDetails) {
105
+ throw new Error(`Failed to inject lag detector: ${JSON.stringify(result.exceptionDetails)}`);
106
+ }
107
+
108
+ // Poll for lag events
109
+ this._lagTimer = setInterval(async () => {
110
+ if (!this._running) return;
111
+ try {
112
+ const pollResult = await this.inspector.send('Runtime.evaluate', {
113
+ expression: 'globalThis.__loopDetective ? globalThis.__loopDetective.getLags() : []',
114
+ returnByValue: true,
115
+ });
116
+ const lags = pollResult.result?.value || [];
117
+ for (const lag of lags) {
118
+ this.emit('lag', lag);
119
+ }
120
+ } catch {
121
+ // Inspector may have disconnected
122
+ }
123
+ }, 1000);
124
+ }
125
+
126
+ /**
127
+ * Take a CPU profile to identify blocking code
128
+ */
129
+ async _captureProfile(duration) {
130
+ await this.inspector.send('Profiler.enable');
131
+ await this.inspector.send('Profiler.setSamplingInterval', { interval: 100 });
132
+ await this.inspector.send('Profiler.start');
133
+
134
+ await this._sleep(duration);
135
+
136
+ const { profile } = await this.inspector.send('Profiler.stop');
137
+ await this.inspector.send('Profiler.disable');
138
+
139
+ return profile;
140
+ }
141
+
142
+ /**
143
+ * Clean up the injected lag detector
144
+ */
145
+ async _cleanupLagDetector() {
146
+ try {
147
+ await this.inspector.send('Runtime.evaluate', {
148
+ expression: 'globalThis.__loopDetective && globalThis.__loopDetective.cleanup()',
149
+ returnByValue: true,
150
+ });
151
+ } catch {
152
+ // Best effort cleanup
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Start the detective
158
+ */
159
+ async start() {
160
+ this._running = true;
161
+
162
+ // Step 1: Activate inspector
163
+ this._activateInspector();
164
+ const port = await this._findInspectorPort();
165
+
166
+ // Step 2: Connect
167
+ this.inspector = new Inspector({ port });
168
+ await this.inspector.connect();
169
+ this.emit('connected');
170
+
171
+ if (this.config.watch) {
172
+ await this._watchMode();
173
+ } else {
174
+ await this._singleRun();
175
+ }
176
+ }
177
+
178
+ async _singleRun() {
179
+ try {
180
+ // Step 3: Start lag detection
181
+ await this._startLagDetection();
182
+
183
+ // Step 4: Capture CPU profile
184
+ const profile = await this._captureProfile(this.config.duration);
185
+
186
+ // Step 5: Analyze
187
+ const analysis = this.analyzer.analyzeProfile(profile);
188
+ this.emit('profile', analysis);
189
+ } finally {
190
+ await this.stop();
191
+ }
192
+ }
193
+
194
+ async _watchMode() {
195
+ await this._startLagDetection();
196
+
197
+ const runCycle = async () => {
198
+ if (!this._running) return;
199
+
200
+ const profile = await this._captureProfile(this.config.duration);
201
+ const analysis = this.analyzer.analyzeProfile(profile);
202
+ this.emit('profile', analysis);
203
+
204
+ if (this._running) {
205
+ setTimeout(runCycle, 1000);
206
+ }
207
+ };
208
+
209
+ runCycle();
210
+ }
211
+
212
+ /**
213
+ * Stop the detective and clean up
214
+ */
215
+ async stop() {
216
+ this._running = false;
217
+
218
+ if (this._lagTimer) {
219
+ clearInterval(this._lagTimer);
220
+ this._lagTimer = null;
221
+ }
222
+
223
+ if (this.inspector) {
224
+ await this._cleanupLagDetector();
225
+ await this.inspector.disconnect();
226
+ this.inspector = null;
227
+ }
228
+
229
+ this.emit('disconnected');
230
+ }
231
+
232
+ _sleep(ms) {
233
+ return new Promise((resolve) => setTimeout(resolve, ms));
234
+ }
235
+ }
236
+
237
+ module.exports = { Detective };
package/src/index.js ADDED
@@ -0,0 +1,8 @@
1
+ 'use strict';
2
+
3
+ const { Detective } = require('./detective');
4
+ const { Inspector } = require('./inspector');
5
+ const { Analyzer } = require('./analyzer');
6
+ const { Reporter } = require('./reporter');
7
+
8
+ module.exports = { Detective, Inspector, Analyzer, Reporter };
@@ -0,0 +1,126 @@
1
+ 'use strict';
2
+
3
+ const http = require('node:http');
4
+ const WebSocket = require('ws');
5
+ const { EventEmitter } = require('node:events');
6
+
7
+ class Inspector extends EventEmitter {
8
+ constructor({ host = '127.0.0.1', port = 9229 } = {}) {
9
+ super();
10
+ this.host = host;
11
+ this.port = port;
12
+ this.ws = null;
13
+ this._id = 0;
14
+ this._callbacks = new Map();
15
+ }
16
+
17
+ /**
18
+ * Discover the inspector WebSocket URL via /json/list
19
+ */
20
+ async getWebSocketUrl() {
21
+ return new Promise((resolve, reject) => {
22
+ const req = http.get(`http://${this.host}:${this.port}/json/list`, (res) => {
23
+ let data = '';
24
+ res.on('data', (chunk) => (data += chunk));
25
+ res.on('end', () => {
26
+ try {
27
+ const targets = JSON.parse(data);
28
+ if (targets.length === 0) {
29
+ return reject(new Error('No inspector targets found'));
30
+ }
31
+ resolve(targets[0].webSocketDebuggerUrl);
32
+ } catch (e) {
33
+ reject(new Error(`Failed to parse inspector response: ${e.message}`));
34
+ }
35
+ });
36
+ });
37
+ req.on('error', (err) => {
38
+ reject(new Error(
39
+ `Cannot connect to inspector at ${this.host}:${this.port}. ` +
40
+ `Is the Node.js inspector active? (${err.message})`
41
+ ));
42
+ });
43
+ req.setTimeout(5000, () => {
44
+ req.destroy();
45
+ reject(new Error('Timeout connecting to inspector'));
46
+ });
47
+ });
48
+ }
49
+
50
+ /**
51
+ * Connect to the inspector WebSocket
52
+ */
53
+ async connect() {
54
+ const wsUrl = await this.getWebSocketUrl();
55
+
56
+ return new Promise((resolve, reject) => {
57
+ this.ws = new WebSocket(wsUrl);
58
+
59
+ this.ws.on('open', () => {
60
+ this.emit('connected');
61
+ resolve();
62
+ });
63
+
64
+ this.ws.on('message', (data) => {
65
+ const msg = JSON.parse(data.toString());
66
+ if (msg.id !== undefined && this._callbacks.has(msg.id)) {
67
+ const { resolve, reject } = this._callbacks.get(msg.id);
68
+ this._callbacks.delete(msg.id);
69
+ if (msg.error) {
70
+ reject(new Error(msg.error.message));
71
+ } else {
72
+ resolve(msg.result);
73
+ }
74
+ } else if (msg.method) {
75
+ this.emit('event', msg);
76
+ }
77
+ });
78
+
79
+ this.ws.on('error', (err) => {
80
+ this.emit('error', err);
81
+ reject(err);
82
+ });
83
+
84
+ this.ws.on('close', () => {
85
+ this.emit('disconnected');
86
+ });
87
+ });
88
+ }
89
+
90
+ /**
91
+ * Send a CDP command and wait for the response
92
+ */
93
+ async send(method, params = {}) {
94
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
95
+ throw new Error('Inspector not connected');
96
+ }
97
+
98
+ const id = ++this._id;
99
+
100
+ return new Promise((resolve, reject) => {
101
+ this._callbacks.set(id, { resolve, reject });
102
+ this.ws.send(JSON.stringify({ id, method, params }));
103
+
104
+ // Timeout for individual commands
105
+ setTimeout(() => {
106
+ if (this._callbacks.has(id)) {
107
+ this._callbacks.delete(id);
108
+ reject(new Error(`CDP command timeout: ${method}`));
109
+ }
110
+ }, 30000);
111
+ });
112
+ }
113
+
114
+ /**
115
+ * Disconnect from the inspector
116
+ */
117
+ async disconnect() {
118
+ if (this.ws) {
119
+ this._callbacks.clear();
120
+ this.ws.close();
121
+ this.ws = null;
122
+ }
123
+ }
124
+ }
125
+
126
+ module.exports = { Inspector };
@@ -0,0 +1,164 @@
1
+ 'use strict';
2
+
3
+ const COLORS = {
4
+ reset: '\x1b[0m',
5
+ bold: '\x1b[1m',
6
+ dim: '\x1b[2m',
7
+ red: '\x1b[31m',
8
+ green: '\x1b[32m',
9
+ yellow: '\x1b[33m',
10
+ blue: '\x1b[34m',
11
+ magenta: '\x1b[35m',
12
+ cyan: '\x1b[36m',
13
+ white: '\x1b[37m',
14
+ bgRed: '\x1b[41m',
15
+ bgYellow: '\x1b[43m',
16
+ bgGreen: '\x1b[42m',
17
+ };
18
+
19
+ class Reporter {
20
+ constructor(config) {
21
+ this.config = config;
22
+ this.lagEvents = [];
23
+ }
24
+
25
+ onConnected() {
26
+ if (this.config.json) return;
27
+ this._print(`\n${COLORS.green}✔${COLORS.reset} Connected to Node.js process`);
28
+ this._print(`${COLORS.dim} Profiling for ${this.config.duration / 1000}s with ${this.config.threshold}ms lag threshold...${COLORS.reset}\n`);
29
+ }
30
+
31
+ onLag(data) {
32
+ this.lagEvents.push(data);
33
+ if (this.config.json) return;
34
+ const severity = data.lag > 500 ? COLORS.red : data.lag > 200 ? COLORS.yellow : COLORS.cyan;
35
+ this._print(`${severity}⚠ Event loop lag: ${data.lag}ms${COLORS.reset} ${COLORS.dim}at ${new Date(data.timestamp).toISOString()}${COLORS.reset}`);
36
+ }
37
+
38
+ onProfile(analysis) {
39
+ if (this.config.json) {
40
+ this._print(JSON.stringify({ ...analysis, lagEvents: this.lagEvents }, null, 2));
41
+ this.lagEvents = [];
42
+ return;
43
+ }
44
+
45
+ this._printSummary(analysis.summary);
46
+ this._printPatterns(analysis.blockingPatterns);
47
+ this._printHeavyFunctions(analysis.heavyFunctions);
48
+ this._printCallStacks(analysis.callStacks);
49
+ this._printLagSummary();
50
+
51
+ this.lagEvents = [];
52
+ }
53
+
54
+ onError(err) {
55
+ if (this.config.json) {
56
+ this._print(JSON.stringify({ error: err.message }));
57
+ } else {
58
+ this._print(`\n${COLORS.red}✖ Error: ${err.message}${COLORS.reset}`);
59
+ }
60
+ }
61
+
62
+ onInfo(msg) {
63
+ if (!this.config.json) {
64
+ this._print(`${COLORS.dim}${msg}${COLORS.reset}`);
65
+ }
66
+ }
67
+
68
+ onDisconnected() {
69
+ if (!this.config.json) {
70
+ this._print(`\n${COLORS.green}✔${COLORS.reset} Disconnected cleanly\n`);
71
+ }
72
+ }
73
+
74
+ _printSummary(summary) {
75
+ this._print(`\n${'─'.repeat(60)}`);
76
+ this._print(`${COLORS.bold} Event Loop Detective Report${COLORS.reset}`);
77
+ this._print(`${'─'.repeat(60)}`);
78
+ this._print(` Duration: ${summary.totalDurationMs}ms`);
79
+ this._print(` Samples: ${summary.samplesCount}`);
80
+ this._print(` Hot funcs: ${summary.heavyFunctionCount}`);
81
+ }
82
+
83
+ _printPatterns(patterns) {
84
+ this._print(`\n${COLORS.bold} Diagnosis${COLORS.reset}`);
85
+ this._print(`${'─'.repeat(60)}`);
86
+
87
+ for (const p of patterns) {
88
+ const icon = p.severity === 'high' ? `${COLORS.bgRed} HIGH ${COLORS.reset}`
89
+ : p.severity === 'medium' ? `${COLORS.bgYellow} MED ${COLORS.reset}`
90
+ : `${COLORS.bgGreen} LOW ${COLORS.reset}`;
91
+
92
+ this._print(` ${icon} ${COLORS.bold}${p.type}${COLORS.reset}`);
93
+ this._print(` ${p.message}`);
94
+ if (p.location) {
95
+ this._print(` ${COLORS.dim}at ${p.location}${COLORS.reset}`);
96
+ }
97
+ this._print(` ${COLORS.cyan}→ ${p.suggestion}${COLORS.reset}`);
98
+ this._print('');
99
+ }
100
+ }
101
+
102
+ _printHeavyFunctions(functions) {
103
+ if (functions.length === 0) return;
104
+
105
+ this._print(`${COLORS.bold} Top CPU-Heavy Functions${COLORS.reset}`);
106
+ this._print(`${'─'.repeat(60)}`);
107
+
108
+ const top = functions.slice(0, 10);
109
+ for (let i = 0; i < top.length; i++) {
110
+ const f = top[i];
111
+ const bar = this._makeBar(f.percentage);
112
+ this._print(` ${COLORS.bold}${(i + 1).toString().padStart(2)}.${COLORS.reset} ${f.functionName}`);
113
+ this._print(` ${bar} ${f.selfTimeMs}ms (${f.percentage}%)`);
114
+ this._print(` ${COLORS.dim}${f.url}:${f.lineNumber}:${f.columnNumber}${COLORS.reset}`);
115
+ }
116
+ }
117
+
118
+ _printCallStacks(stacks) {
119
+ if (stacks.length === 0) return;
120
+
121
+ this._print(`\n${COLORS.bold} Call Stacks (top blockers)${COLORS.reset}`);
122
+ this._print(`${'─'.repeat(60)}`);
123
+
124
+ for (const s of stacks) {
125
+ this._print(`\n ${COLORS.yellow}▸ ${s.target}${COLORS.reset} (${s.selfTimeMs}ms)`);
126
+ for (let i = 0; i < s.stack.length; i++) {
127
+ const frame = s.stack[i];
128
+ const indent = ' ' + ' '.repeat(Math.min(i, 5));
129
+ const isTarget = frame.functionName === s.target;
130
+ const color = isTarget ? COLORS.yellow : COLORS.dim;
131
+ this._print(`${indent}${color}${isTarget ? '→' : '│'} ${frame.functionName} ${frame.url}:${frame.lineNumber}${COLORS.reset}`);
132
+ }
133
+ }
134
+ }
135
+
136
+ _printLagSummary() {
137
+ if (this.lagEvents.length === 0) {
138
+ this._print(`\n ${COLORS.green}✔ No event loop lag detected above threshold${COLORS.reset}`);
139
+ } else {
140
+ const maxLag = Math.max(...this.lagEvents.map((e) => e.lag));
141
+ const avgLag = Math.round(this.lagEvents.reduce((s, e) => s + e.lag, 0) / this.lagEvents.length);
142
+ this._print(`\n ${COLORS.red}⚠ Event Loop Lag Summary${COLORS.reset}`);
143
+ this._print(` Events: ${this.lagEvents.length}`);
144
+ this._print(` Max: ${maxLag}ms`);
145
+ this._print(` Avg: ${avgLag}ms`);
146
+ }
147
+
148
+ this._print(`\n${'─'.repeat(60)}\n`);
149
+ }
150
+
151
+ _makeBar(percentage) {
152
+ const width = 20;
153
+ const filled = Math.round((percentage / 100) * width);
154
+ const empty = width - filled;
155
+ const color = percentage > 50 ? COLORS.red : percentage > 20 ? COLORS.yellow : COLORS.green;
156
+ return `${color}${'█'.repeat(filled)}${'░'.repeat(empty)}${COLORS.reset}`;
157
+ }
158
+
159
+ _print(msg) {
160
+ console.log(msg);
161
+ }
162
+ }
163
+
164
+ module.exports = { Reporter };