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 +21 -0
- package/README.md +124 -0
- package/bin/cli.js +121 -0
- package/package.json +46 -0
- package/src/analyzer.js +247 -0
- package/src/detective.js +237 -0
- package/src/index.js +8 -0
- package/src/inspector.js +126 -0
- package/src/reporter.js +164 -0
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
|
+
}
|
package/src/analyzer.js
ADDED
|
@@ -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 };
|
package/src/detective.js
ADDED
|
@@ -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 };
|
package/src/inspector.js
ADDED
|
@@ -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 };
|
package/src/reporter.js
ADDED
|
@@ -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 };
|