wukong-profiler 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 +225 -0
- package/README.zh.md +242 -0
- package/bin/wukong-profiler +103 -0
- package/package.json +100 -0
- package/src/api/testFunction.js +56 -0
- package/src/index.mjs +12 -0
- package/src/report.mjs +50 -0
- package/src/utils/diff.mjs +21 -0
- package/src/utils/format.mjs +11 -0
- package/src/utils/profiler.mjs +281 -0
- package/src/utils/trace.mjs +40 -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,225 @@
|
|
|
1
|
+
# wukong-profiler
|
|
2
|
+
|
|
3
|
+
š„ High-performance Node/CLI profiler supporting:
|
|
4
|
+
|
|
5
|
+
- Nested steps (true Flame Graph)
|
|
6
|
+
- Chrome Trace export (`--trace trace.json`)
|
|
7
|
+
- HOT step detection with CI failure
|
|
8
|
+
- Profile diff for performance regression detection
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install wukong-profiler
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Or use directly via npx:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npx wukong-profiler [options]
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## CLI Usage
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npx wukong-profiler --flame --trace trace.json --hot-threshold 0.8 --fail-on-hot
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Run profiler
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# Simple run
|
|
34
|
+
npx wukong-profiler --flame --trace trace.json
|
|
35
|
+
|
|
36
|
+
# Set HOT threshold
|
|
37
|
+
npx wukong-profiler --hot-threshold 0.8 --fail-on-hot
|
|
38
|
+
|
|
39
|
+
# With baseline profile for regression detection
|
|
40
|
+
npx wukong-profiler --diff-base baseline.json --diff-threshold 0.2
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Generate HTML report
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
# Generate HTML report from profile.json
|
|
47
|
+
npx wukong-profiler report ./profile.json
|
|
48
|
+
|
|
49
|
+
# Generate and open automatically in browser
|
|
50
|
+
npx wukong-profiler report ./profile.json --open
|
|
51
|
+
|
|
52
|
+
# Specify output HTML file
|
|
53
|
+
npx wukong-profiler report ./profile.json -o my-report.html
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**Options:**
|
|
57
|
+
|
|
58
|
+
| Option | Description |
|
|
59
|
+
| ---------------------- | ------------------------------------------------------- |
|
|
60
|
+
| `--profile` | Save profile JSON for analysis |
|
|
61
|
+
| `--flame` | Display flame-like console output |
|
|
62
|
+
| `--trace <file>` | Export Chrome Trace JSON file |
|
|
63
|
+
| `--hot-threshold <n>` | HOT step threshold (default: 0.8) |
|
|
64
|
+
| `--fail-on-hot` | Exit with non-zero code if a HOT step exceeds threshold |
|
|
65
|
+
| `--diff-base <file>` | Compare current profile with baseline for regression |
|
|
66
|
+
| `--diff-threshold <n>` | Diff threshold for regression (default: 0.2) |
|
|
67
|
+
| `-v, --version` | Show version |
|
|
68
|
+
| `-h, --help` | Show help |
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Programmatic Usage
|
|
73
|
+
|
|
74
|
+
```js
|
|
75
|
+
import { createProfiler } from 'wukong-profiler'
|
|
76
|
+
|
|
77
|
+
const profiler = createProfiler({
|
|
78
|
+
enabled: true,
|
|
79
|
+
flame: true,
|
|
80
|
+
traceFile: 'trace.json',
|
|
81
|
+
hotThreshold: 0.8,
|
|
82
|
+
failOnHot: true,
|
|
83
|
+
diffBaseFile: 'baseline.json',
|
|
84
|
+
diffThreshold: 0.2
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
profiler.step('load data', () => {
|
|
88
|
+
// heavy operation
|
|
89
|
+
})
|
|
90
|
+
profiler.step('process data', () => {
|
|
91
|
+
// another heavy operation
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
profiler.end('Total')
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## API Reference
|
|
100
|
+
|
|
101
|
+
### `createProfiler(options)`
|
|
102
|
+
|
|
103
|
+
Returns a profiler instance.
|
|
104
|
+
|
|
105
|
+
#### Options
|
|
106
|
+
|
|
107
|
+
| Name | Default | Description |
|
|
108
|
+
| --------------- | ----------- | --------------------------- |
|
|
109
|
+
| `enabled` | `false` | Enable output & JSON export |
|
|
110
|
+
| `verbose` | `false` | Verbose logging |
|
|
111
|
+
| `flame` | `false` | Flame-style tree output |
|
|
112
|
+
| `slowThreshold` | `500` | Slow step threshold (ms) |
|
|
113
|
+
| `hotThreshold` | `0.8` | HOT step ratio |
|
|
114
|
+
| `traceFile` | `undefined` | Chrome trace file |
|
|
115
|
+
| `failOnHot` | `false` | Fail CI on HOT step |
|
|
116
|
+
| `diffBaseFile` | `undefined` | Base profile for diff |
|
|
117
|
+
| `diffThreshold` | `0.2` | Regression threshold |
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
### `profiler.step(name, fn)`
|
|
122
|
+
|
|
123
|
+
Measure a synchronous step.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
### `profiler.measure(name, fn)`
|
|
128
|
+
|
|
129
|
+
Measure sync or async function.
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
### `profiler.end(label?)`
|
|
134
|
+
|
|
135
|
+
Finish profiling and output results.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## Examples
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
node examples/basic.mjs
|
|
143
|
+
node examples/flame.mjs
|
|
144
|
+
node examples/async.mjs
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## Chrome Trace
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
node examples/basic.mjs
|
|
153
|
+
chrome://tracing
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Load the generated trace file.
|
|
157
|
+
|
|
158
|
+
or
|
|
159
|
+
|
|
160
|
+
```text
|
|
161
|
+
https://ui.perfetto.dev
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
drag to Load the generated trace file.
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
### š Profile Summary (Top HOT Paths)
|
|
169
|
+
|
|
170
|
+
````js
|
|
171
|
+
const summary = profiler.summary({ top: 3 });
|
|
172
|
+
|
|
173
|
+
summary.top.forEach(step => {
|
|
174
|
+
console.log(step.path, step.ratio);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## API Usage
|
|
180
|
+
|
|
181
|
+
```js
|
|
182
|
+
import { createProfiler } from 'wukong-profiler'
|
|
183
|
+
|
|
184
|
+
const profiler = createProfiler({ enabled: true, flame: true })
|
|
185
|
+
|
|
186
|
+
// Measure a function
|
|
187
|
+
await profiler.measure('heavyTask', async () => {
|
|
188
|
+
await doHeavyWork()
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
// Nested steps
|
|
192
|
+
await profiler.measure('outer', async () => {
|
|
193
|
+
await profiler.measure('inner1', task1)
|
|
194
|
+
await profiler.measure('inner2', task2)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
const { total, events, exitCode } = profiler.end('Total')
|
|
198
|
+
console.log('Total time:', total, 'ms')
|
|
199
|
+
````
|
|
200
|
+
|
|
201
|
+
- `measure(name, fn)` : measure a function (sync/async)
|
|
202
|
+
- `step(name)` : manually log a step
|
|
203
|
+
- `end(label)` : end profiling, optionally export Chrome Trace JSON
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
**Features:**
|
|
208
|
+
|
|
209
|
+
- Nested steps for Flame Graph visualization
|
|
210
|
+
- Slow steps (> threshold) marked as HOT š„
|
|
211
|
+
- Automatic exit code for CI if HOT steps detected
|
|
212
|
+
- Chrome Trace export compatible with Chrome's `chrome://tracing`
|
|
213
|
+
- Profile diff for performance regression detection
|
|
214
|
+
|
|
215
|
+
## š å
³é®čÆ
|
|
216
|
+
|
|
217
|
+
<!--
|
|
218
|
+
Node.js profiler, JavaScript profiler, Node performance analysis, CLI profiler,
|
|
219
|
+
Flame Graph, Flame Chart, Chrome Trace, Chrome tracing, Perfetto,
|
|
220
|
+
Performance regression detection, Profile diff, CI performance check,
|
|
221
|
+
HOT path detection, Slow function detection,
|
|
222
|
+
Async performance profiling, Nested performance steps,
|
|
223
|
+
Node.js benchmarking, Build performance monitoring,
|
|
224
|
+
Developer tooling, DevOps performance, Continuous Integration profiling
|
|
225
|
+
-->
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# wukong-profiler
|
|
2
|
+
|
|
3
|
+
š„ **é«ę§č½ Node / CLI ę§č½åęåØļ¼Profilerļ¼**ļ¼ęÆęļ¼
|
|
4
|
+
|
|
5
|
+
- ā
**ēę£ēåµå„ę„éŖ¤**ļ¼ēå® Flame Graph ē»ęļ¼
|
|
6
|
+
|
|
7
|
+
- ā
**Chrome Trace 导åŗ**ļ¼`--trace trace.json`ļ¼
|
|
8
|
+
|
|
9
|
+
- ā
**HOT ę„éŖ¤ę£ęµ**ļ¼ęÆę CI ē“ę„失蓄ļ¼
|
|
10
|
+
|
|
11
|
+
- ā
**Profile Diff**ļ¼ēØäŗę§č½åé / åå½ę£ęµļ¼
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## å®č£
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install wukong-profiler
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
ęē“ę„ä½æēØ `npx`ļ¼
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx wukong-profiler [options]
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## CLI 使ēØę¹å¼
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npx wukong-profiler --flame --trace trace.json --hot-threshold 0.8 --fail-on-hot
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### čæč” Profiler
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
# ē®åčæč”
|
|
39
|
+
npx wukong-profiler --flame --trace trace.json
|
|
40
|
+
|
|
41
|
+
# 设置 HOT éå¼
|
|
42
|
+
npx wukong-profiler --hot-threshold 0.8 --fail-on-hot
|
|
43
|
+
|
|
44
|
+
# 使ēØåŗå profile ę£ęµę§č½åå½
|
|
45
|
+
npx wukong-profiler --diff-base baseline.json --diff-threshold 0.2
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### ēę HTML ę„å
|
|
49
|
+
|
|
50
|
+
````bash
|
|
51
|
+
# ä» profile.json ēę HTML ę„å
|
|
52
|
+
npx wukong-profiler report ./profile.json
|
|
53
|
+
|
|
54
|
+
# ēęå¹¶čŖåØåØęµč§åØęå¼
|
|
55
|
+
npx wukong-profiler report ./profile.json --open
|
|
56
|
+
|
|
57
|
+
# ęå®č¾åŗ HTML ęä»¶
|
|
58
|
+
npx wukong-profiler report ./profile.json -o my-report.html
|
|
59
|
+
|
|
60
|
+
### CLI åę°čÆ“ę
|
|
61
|
+
|
|
62
|
+
| åę° | 诓ę |
|
|
63
|
+
| ---------------------- | --------------------------------------------- |
|
|
64
|
+
| `--profile` | äæå profile JSON ęä»¶ļ¼ēØäŗåē»åę |
|
|
65
|
+
| `--flame` | åØę§å¶å°č¾åŗ Flame é£ę ¼ēę ē¶ē»ę |
|
|
66
|
+
| `--trace <file>` | åÆ¼åŗ Chrome Trace JSON ęä»¶ |
|
|
67
|
+
| `--hot-threshold <n>` | HOT ę„éŖ¤å ęÆéå¼ļ¼é»č®¤ļ¼0.8ļ¼ |
|
|
68
|
+
| `--fail-on-hot` | å¦ęååØ HOT ę„éŖ¤ļ¼čæēØä»„é 0 éåŗļ¼CI å¤±č“„ļ¼ |
|
|
69
|
+
| `--diff-base <file>` | äøåŗå profile 对ęÆļ¼ę£ęµę§č½åé |
|
|
70
|
+
| `--diff-threshold <n>` | ę§č½åééå¼ļ¼é»č®¤ļ¼0.2ļ¼ |
|
|
71
|
+
| `-v, --version` | ę¾ē¤ŗēę¬å· |
|
|
72
|
+
| `-h, --help` | ę¾ē¤ŗåø®å©äæ”ęÆ |
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## ē¼ēØę¹å¼ļ¼Programmatic Usageļ¼
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
import { createProfiler } from 'wukong-profiler'
|
|
80
|
+
|
|
81
|
+
const profiler = createProfiler({
|
|
82
|
+
enabled: true,
|
|
83
|
+
flame: true,
|
|
84
|
+
traceFile: 'trace.json',
|
|
85
|
+
hotThreshold: 0.8,
|
|
86
|
+
failOnHot: true,
|
|
87
|
+
diffBaseFile: 'baseline.json',
|
|
88
|
+
diffThreshold: 0.2
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
profiler.step('å č½½ę°ę®', () => {
|
|
92
|
+
// éä»»å”
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
profiler.step('å¤ēę°ę®', () => {
|
|
96
|
+
// å¦äøäøŖéä»»å”
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
profiler.end('ę»čę¶')
|
|
100
|
+
````
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## API ę攣
|
|
105
|
+
|
|
106
|
+
### `createProfiler(options)`
|
|
107
|
+
|
|
108
|
+
å建并čæåäøäøŖ profiler å®ä¾ć
|
|
109
|
+
|
|
110
|
+
#### Options åę°čÆ“ę
|
|
111
|
+
|
|
112
|
+
| åę°å | é»č®¤å¼ | 诓ę |
|
|
113
|
+
| --------------- | ----------- | -------------------------------- |
|
|
114
|
+
| `enabled` | `false` | ęÆå¦åÆēØ profilerļ¼č¾åŗ & JSONļ¼ |
|
|
115
|
+
| `verbose` | `false` | č¾åŗę“详ē»ēę„åæ |
|
|
116
|
+
| `flame` | `false` | č¾åŗ Flame é£ę ¼ēę ē»ę |
|
|
117
|
+
| `slowThreshold` | `500` | ę
¢ę„éŖ¤éå¼ļ¼ęÆ«ē§ļ¼ |
|
|
118
|
+
| `hotThreshold` | `0.8` | HOT ę„éŖ¤å ęÆéå¼ |
|
|
119
|
+
| `traceFile` | `undefined` | Chrome Trace č¾åŗęä»¶ |
|
|
120
|
+
| `failOnHot` | `false` | ę£ęµå° HOT ę„éŖ¤ę¶ CI 失蓄 |
|
|
121
|
+
| `diffBaseFile` | `undefined` | ēØäŗ diff ēåŗå profile |
|
|
122
|
+
| `diffThreshold` | `0.2` | ę§č½åééå¼ |
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
### `profiler.step(name, fn)`
|
|
127
|
+
|
|
128
|
+
ęµéäøäøŖ **åę„ę„éŖ¤**ć
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
### `profiler.measure(name, fn)`
|
|
133
|
+
|
|
134
|
+
ęµéäøäøŖ **åę„ęå¼ę„å½ę°**ć
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
### `profiler.end(label?)`
|
|
139
|
+
|
|
140
|
+
ē»ę profilingļ¼å¹¶č¾åŗęē»ē»ęć
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## 示ä¾
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
node examples/basic.mjs
|
|
148
|
+
node examples/flame.mjs
|
|
149
|
+
node examples/async.mjs
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## Chrome Trace 使ēØę¹å¼
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
node examples/basic.mjs
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
ē¶ååØęµč§åØäøęå¼ļ¼
|
|
161
|
+
|
|
162
|
+
```text
|
|
163
|
+
chrome://tracing
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
å č½½ēęē trace ęä»¶ć
|
|
167
|
+
|
|
168
|
+
ęä½æēØ Perfettoļ¼ęØčļ¼ļ¼
|
|
169
|
+
|
|
170
|
+
```text
|
|
171
|
+
https://ui.perfetto.dev
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
å°ēęē trace ęä»¶ęå
„å³åÆę„ēć
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
### š ę§č½ęč¦ļ¼Top HOT č·Æå¾ļ¼
|
|
179
|
+
|
|
180
|
+
```js
|
|
181
|
+
const summary = profiler.summary({ top: 3 })
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## API 使ēØē¤ŗä¾
|
|
187
|
+
|
|
188
|
+
```js
|
|
189
|
+
import { createProfiler } from 'wukong-profiler'
|
|
190
|
+
|
|
191
|
+
const profiler = createProfiler({ enabled: true, flame: true })
|
|
192
|
+
|
|
193
|
+
// ęµéå½ę°
|
|
194
|
+
await profiler.measure('heavyTask', async () => {
|
|
195
|
+
await doHeavyWork()
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
// åµå„ę„éŖ¤
|
|
199
|
+
await profiler.measure('outer', async () => {
|
|
200
|
+
await profiler.measure('inner1', task1)
|
|
201
|
+
await profiler.measure('inner2', task2)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
const { total, events, exitCode } = profiler.end('Total')
|
|
205
|
+
console.log('ę»čę¶:', total, 'ms')
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### API 诓ę
|
|
209
|
+
|
|
210
|
+
- `measure(name, fn)`ļ¼ęµéå½ę°ļ¼ęÆę sync / asyncļ¼
|
|
211
|
+
|
|
212
|
+
- `step(name)`ļ¼ęåØč®°å½äøäøŖę„éŖ¤
|
|
213
|
+
|
|
214
|
+
- `end(label)`ļ¼ē»ę profilingļ¼åÆåÆ¼åŗ Chrome Trace
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
## ę øåæē¹ę§ę»ē»
|
|
219
|
+
|
|
220
|
+
- š„ **ęÆęåµå„ę„éŖ¤**ļ¼å¤©ē¶éå Flame Graph
|
|
221
|
+
|
|
222
|
+
- š„ **ę
¢ę„éŖ¤ / HOT ę„éŖ¤čŖåØę č®°**
|
|
223
|
+
|
|
224
|
+
- š„ **CI å儽**ļ¼ę§č½é®é¢åÆē“ę„让ę建失蓄
|
|
225
|
+
|
|
226
|
+
- š„ **Chrome Trace 导åŗ**ļ¼åÆč§å精甮å°ęÆ«ē§
|
|
227
|
+
|
|
228
|
+
- š„ **Profile Diff**ļ¼ēØäŗę§č½åéę£ęµ
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## š Keywords
|
|
233
|
+
|
|
234
|
+
<!--
|
|
235
|
+
Node.js profiler, JavaScript profiler, Node performance analysis, CLI profiler,
|
|
236
|
+
Flame Graph, Flame Chart, Chrome Trace, Chrome tracing, Perfetto,
|
|
237
|
+
Performance regression detection, Profile diff, CI performance check,
|
|
238
|
+
HOT path detection, Slow function detection,
|
|
239
|
+
Async performance profiling, Nested performance steps,
|
|
240
|
+
Node.js benchmarking, Build performance monitoring,
|
|
241
|
+
Developer tooling, DevOps performance, Continuous Integration profiling
|
|
242
|
+
-->
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import { Command } from 'commander'
|
|
4
|
+
import fs from 'fs'
|
|
5
|
+
import path from 'path'
|
|
6
|
+
import { fileURLToPath } from 'url'
|
|
7
|
+
import open from "open";
|
|
8
|
+
|
|
9
|
+
import { generateReport } from '../src/report.mjs'
|
|
10
|
+
import { formatTime } from '../src/utils/format.mjs'
|
|
11
|
+
import { createProfiler } from '../src/utils/profiler.mjs'
|
|
12
|
+
|
|
13
|
+
// č·å package.json ēēę¬å·
|
|
14
|
+
// å
¼å®¹ Windows
|
|
15
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
16
|
+
const __dirname = path.dirname(__filename)
|
|
17
|
+
|
|
18
|
+
const pkgPath = path.resolve(__dirname, '../package.json')
|
|
19
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
|
|
20
|
+
|
|
21
|
+
const program = new Command()
|
|
22
|
+
|
|
23
|
+
program
|
|
24
|
+
.name('wukong-profiler')
|
|
25
|
+
.description(
|
|
26
|
+
'š„ Node/CLI profiler with flame graph, Chrome Trace and HOT detection'
|
|
27
|
+
)
|
|
28
|
+
.version(pkg.version, '-v, --version', 'show version')
|
|
29
|
+
|
|
30
|
+
/* ------------------------------
|
|
31
|
+
profile / runtime options
|
|
32
|
+
--------------------------------*/
|
|
33
|
+
|
|
34
|
+
program
|
|
35
|
+
.option('--profile', 'save profile.json')
|
|
36
|
+
.option('--flame', 'flame-like console output')
|
|
37
|
+
.option('--trace <file>', 'Chrome Trace export file')
|
|
38
|
+
.option('--hot-threshold <n>', 'HOT step threshold', parseFloat, 0.8)
|
|
39
|
+
.option('--fail-on-hot', 'exit with non-zero if HOT step detected')
|
|
40
|
+
.option('--diff-base <file>', 'baseline profile for regression check')
|
|
41
|
+
.option('--diff-threshold <n>', 'regression diff threshold', parseFloat, 0.2)
|
|
42
|
+
|
|
43
|
+
/* ------------------------------
|
|
44
|
+
report subcommand
|
|
45
|
+
--------------------------------*/
|
|
46
|
+
|
|
47
|
+
program
|
|
48
|
+
.command('report <profile>')
|
|
49
|
+
.description('š Generate HTML report from profile.json')
|
|
50
|
+
.option('-o, --output <file>', 'output html file', 'wukong-report.html')
|
|
51
|
+
.option('--open', 'open the report in default browser after generation')
|
|
52
|
+
.action((profile, options) => {
|
|
53
|
+
// options å°±ęÆęåäøäøŖåę°ļ¼å
å« output
|
|
54
|
+
const output = options.output
|
|
55
|
+
|
|
56
|
+
// TODO: remove debug log before production
|
|
57
|
+
console.log('ā
', 'output', output)
|
|
58
|
+
|
|
59
|
+
if (!fs.existsSync(profile)) {
|
|
60
|
+
console.error(`ā profile file not found: ${profile}`)
|
|
61
|
+
process.exit(1)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const json = JSON.parse(fs.readFileSync(profile, 'utf-8'))
|
|
65
|
+
|
|
66
|
+
const html = generateReport(json)
|
|
67
|
+
fs.writeFileSync(output, html, 'utf8')
|
|
68
|
+
|
|
69
|
+
console.log(`ā
report generated: ${output}`)
|
|
70
|
+
if (options.open) {
|
|
71
|
+
open(output)
|
|
72
|
+
.then(() => console.log(`š report opened in browser`))
|
|
73
|
+
.catch((err) => console.error('ā failed to open report', err))
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
program.parse(process.argv)
|
|
78
|
+
|
|
79
|
+
const opts = program.opts()
|
|
80
|
+
|
|
81
|
+
const profiler = createProfiler({
|
|
82
|
+
enabled: opts.profile || opts.flame || !!opts.trace || !!opts.diffBase,
|
|
83
|
+
flame: opts.flame,
|
|
84
|
+
traceFile: opts.trace,
|
|
85
|
+
hotThreshold: opts.hotThreshold,
|
|
86
|
+
failOnHot: opts.failOnHot,
|
|
87
|
+
diffBaseFile: opts.diffBase,
|
|
88
|
+
diffThreshold: opts.diffThreshold
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
console.log(chalk.green('š„ Wukong Profiler CLI started'))
|
|
92
|
+
console.log(
|
|
93
|
+
chalk.gray('Use profiler.step(name, fn) in your code or CLI script')
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
// č¾åŗ total č®”ę¶äæ”ęÆ
|
|
97
|
+
process.on('exit', () => {
|
|
98
|
+
const profile = profiler.end('Total')
|
|
99
|
+
console.log(
|
|
100
|
+
chalk.cyan('Profiler finished. Total:'),
|
|
101
|
+
formatTime(profile.total)
|
|
102
|
+
)
|
|
103
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "wukong-profiler",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "š„ é«ę§č½ CLI/Node Profilerļ¼ęÆę Flame GraphćChrome TracećHOT ę„éŖ¤ę£ęµćę§č½åå½åę",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"profiler",
|
|
7
|
+
"flamegraph",
|
|
8
|
+
"node",
|
|
9
|
+
"cli",
|
|
10
|
+
"performance",
|
|
11
|
+
"hotstep",
|
|
12
|
+
"chrome-trace",
|
|
13
|
+
"ci"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://tomatobybike.github.io/md-structure/",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/tomatobybike/md-structure"
|
|
19
|
+
},
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"author": "Tom <tomatobybike@gmail.com>",
|
|
22
|
+
"type": "module",
|
|
23
|
+
"imports": {
|
|
24
|
+
"#src/*": "./src/*",
|
|
25
|
+
"#utils/*": "src/utils/*",
|
|
26
|
+
"source-map": "./test/__mocks__/source-map.mjs"
|
|
27
|
+
},
|
|
28
|
+
"main": "src/index.mjs",
|
|
29
|
+
"bin": {
|
|
30
|
+
"wukong-profiler": "./bin/wukong-profiler"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"bin/",
|
|
34
|
+
"src/",
|
|
35
|
+
"README.md",
|
|
36
|
+
"README.zh.md"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"async-example": "node examples/async.mjs",
|
|
40
|
+
"basic-example": "node examples/basic.mjs",
|
|
41
|
+
"check:npm:login": "node scripts/check-npm-login.mjs",
|
|
42
|
+
"flame-example": "node examples/flame.mjs",
|
|
43
|
+
"format": "prettier --write \"src/**/*.{js,mjs}\"",
|
|
44
|
+
"lint": "eslint src --ext .js,.mjs src",
|
|
45
|
+
"lint:fix": "eslint src --ext .js,.mjs --fix",
|
|
46
|
+
"pack": "npm pack",
|
|
47
|
+
"prepare": "husky install",
|
|
48
|
+
"prepublishOnly": "yarn lint",
|
|
49
|
+
"prettierall": "npx prettier --write 'src/**/*.{js,jsx}'",
|
|
50
|
+
"push:tags": "git push origin main --follow-tags",
|
|
51
|
+
"prerelease": "yarn lint",
|
|
52
|
+
"release": "yarn check:npm:login && npm publish && yarn push:tags",
|
|
53
|
+
"release:rollback": "git reset --hard HEAD~1 && git tag -d $(git tag --points-at HEAD~1)",
|
|
54
|
+
"release:major": "yarn lint && standard-version --release-as major",
|
|
55
|
+
"release:minor": "yarn lint && standard-version --release-as minor",
|
|
56
|
+
"release:patch": "yarn lint && standard-version --release-as patch",
|
|
57
|
+
"report": "wukong-profiler report ./profile.json -o my-report.html --open",
|
|
58
|
+
"sort": "sort-package-json",
|
|
59
|
+
"test": "node --test"
|
|
60
|
+
},
|
|
61
|
+
"husky": {
|
|
62
|
+
"hooks": {
|
|
63
|
+
"pre-commit": "lint-staged"
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
"lint-staged": {
|
|
67
|
+
"package.json": [
|
|
68
|
+
"sort-package-json"
|
|
69
|
+
],
|
|
70
|
+
"src/**/*.js": [
|
|
71
|
+
"prettier --write",
|
|
72
|
+
"eslint --fix"
|
|
73
|
+
]
|
|
74
|
+
},
|
|
75
|
+
"dependencies": {
|
|
76
|
+
"chalk": "^5.3.0",
|
|
77
|
+
"commander": "^11.0.0",
|
|
78
|
+
"open": "^11.0.0"
|
|
79
|
+
},
|
|
80
|
+
"devDependencies": {
|
|
81
|
+
"@trivago/prettier-plugin-sort-imports": "5.2.2",
|
|
82
|
+
"eslint": "8.57.1",
|
|
83
|
+
"eslint-config-airbnb-base": "15.0.0",
|
|
84
|
+
"eslint-config-prettier": "10.1.8",
|
|
85
|
+
"eslint-import-resolver-alias": "1.1.2",
|
|
86
|
+
"eslint-plugin-import": "2.32.0",
|
|
87
|
+
"eslint-plugin-prettier": "5.5.4",
|
|
88
|
+
"eslint-plugin-simple-import-sort": "12.1.1",
|
|
89
|
+
"husky": "9.1.7",
|
|
90
|
+
"lint-staged": "16.2.7",
|
|
91
|
+
"prettier": "3.6.2",
|
|
92
|
+
"prettier-plugin-packagejson": "2.5.19",
|
|
93
|
+
"sort-package-json": "3.4.0",
|
|
94
|
+
"standard-version": "9.5.0"
|
|
95
|
+
},
|
|
96
|
+
"packageManager": "yarn@1.22.22",
|
|
97
|
+
"engines": {
|
|
98
|
+
"node": ">=18.0.0"
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// testFunction.mjs
|
|
2
|
+
import { measure, createProfiler } from 'wukong-profiler'
|
|
3
|
+
import fs from 'fs'
|
|
4
|
+
|
|
5
|
+
// å建 Profilerļ¼åÆå
Øå±é
ē½®
|
|
6
|
+
const profiler = createProfiler({
|
|
7
|
+
enabled: true,
|
|
8
|
+
verbose: true,
|
|
9
|
+
flame: true,
|
|
10
|
+
trace: 'trace.json',
|
|
11
|
+
hotThreshold: 0.8,
|
|
12
|
+
failOnHot: true
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
// 被ęµčÆå½ę°
|
|
16
|
+
function heavyComputation(n) {
|
|
17
|
+
let sum = 0
|
|
18
|
+
for (let i = 0; i < n * 1e6; i++) sum += i
|
|
19
|
+
return sum
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function nestedComputation(n) {
|
|
23
|
+
return measure('nestedComputation', () => {
|
|
24
|
+
const a = measure('heavyComputation', () => heavyComputation(n))
|
|
25
|
+
const b = measure('heavyComputation_half', () => heavyComputation(n / 2))
|
|
26
|
+
return a + b
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// === ęµčÆå
㌣ ===
|
|
31
|
+
measure('Test: heavyComputation', () => heavyComputation(5))
|
|
32
|
+
measure('Test: nestedComputation', () => nestedComputation(3))
|
|
33
|
+
|
|
34
|
+
// å®ęå¹¶č¾åŗę»čę¶
|
|
35
|
+
const result = profiler.end('Total Test')
|
|
36
|
+
|
|
37
|
+
// č¾åŗ Chrome Trace
|
|
38
|
+
if (profiler.traceFile) {
|
|
39
|
+
const traceEvents = result.events.map(ev => ({
|
|
40
|
+
name: ev.name,
|
|
41
|
+
ph: 'X',
|
|
42
|
+
ts: Math.round(ev.sinceStart * 1000), // μs
|
|
43
|
+
dur: Math.round(ev.duration * 1000), // μs
|
|
44
|
+
pid: 1,
|
|
45
|
+
tid: 1
|
|
46
|
+
}))
|
|
47
|
+
fs.writeFileSync(profiler.traceFile, JSON.stringify({ traceEvents }, null, 2))
|
|
48
|
+
console.log(`Chrome Trace å·²ēę: ${profiler.traceFile}`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// HOT ę„骤触å CI éé¶éåŗē
|
|
52
|
+
const anyHot = result.events.some(ev => ev.hot)
|
|
53
|
+
if (anyHot && profiler.failOnHot) {
|
|
54
|
+
console.error('š„ HOT step detected! Exiting with code 1.')
|
|
55
|
+
process.exit(1)
|
|
56
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createProfiler } from './utils/profiler.mjs'
|
|
2
|
+
import { formatTime, makeBar } from './utils/format.mjs'
|
|
3
|
+
import { exportChromeTrace } from './utils/trace.mjs'
|
|
4
|
+
import { diffProfiles } from './utils/diff.mjs'
|
|
5
|
+
|
|
6
|
+
export {
|
|
7
|
+
createProfiler,
|
|
8
|
+
formatTime,
|
|
9
|
+
makeBar,
|
|
10
|
+
exportChromeTrace,
|
|
11
|
+
diffProfiles
|
|
12
|
+
}
|
package/src/report.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// report.mjs
|
|
2
|
+
export const generateReport = (profile) => {
|
|
3
|
+
const rows = profile.events.map(e => `
|
|
4
|
+
<tr class="${e.duration / profile.total > 0.8 ? 'hot' : ''}">
|
|
5
|
+
<td>${e.name}</td>
|
|
6
|
+
<td>${e.duration.toFixed(2)} ms</td>
|
|
7
|
+
<td>${((e.duration / profile.total) * 100).toFixed(1)}%</td>
|
|
8
|
+
<td>${
|
|
9
|
+
e.source
|
|
10
|
+
? e.source.original
|
|
11
|
+
? `${e.source.original.source}:${e.source.original.line}`
|
|
12
|
+
: `${e.source.file}:${e.source.line}`
|
|
13
|
+
: ''
|
|
14
|
+
}</td>
|
|
15
|
+
</tr>`).join('')
|
|
16
|
+
|
|
17
|
+
const html = `<!doctype html>
|
|
18
|
+
<html>
|
|
19
|
+
<head>
|
|
20
|
+
<meta charset="utf-8"/>
|
|
21
|
+
<title>Wukong Profiler Report</title>
|
|
22
|
+
<style>
|
|
23
|
+
body { font-family: system-ui; padding: 20px; }
|
|
24
|
+
table { border-collapse: collapse; width: 100%; }
|
|
25
|
+
th, td { padding: 8px; border-bottom: 1px solid #eee; }
|
|
26
|
+
.hot { background: #ffe5e5; }
|
|
27
|
+
</style>
|
|
28
|
+
</head>
|
|
29
|
+
<body>
|
|
30
|
+
<h1>š„ Wukong Profiler Report</h1>
|
|
31
|
+
<p>Total: ${profile.total.toFixed(2)} ms</p>
|
|
32
|
+
|
|
33
|
+
<table>
|
|
34
|
+
<thead>
|
|
35
|
+
<tr>
|
|
36
|
+
<th>Step</th>
|
|
37
|
+
<th>Duration</th>
|
|
38
|
+
<th>Ratio</th>
|
|
39
|
+
<th>Source</th>
|
|
40
|
+
</tr>
|
|
41
|
+
</thead>
|
|
42
|
+
<tbody>
|
|
43
|
+
${rows}
|
|
44
|
+
</tbody>
|
|
45
|
+
</table>
|
|
46
|
+
</body>
|
|
47
|
+
</html>`
|
|
48
|
+
|
|
49
|
+
return html
|
|
50
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export const diffProfiles = (prev, curr, threshold = 0.2) => {
|
|
2
|
+
const map = new Map()
|
|
3
|
+
prev.events.forEach(e => map.set(e.name, e.duration))
|
|
4
|
+
const regressions = []
|
|
5
|
+
|
|
6
|
+
curr.events.forEach(e => {
|
|
7
|
+
const before = map.get(e.name)
|
|
8
|
+
if (!before) return
|
|
9
|
+
const diff = (e.duration - before) / before
|
|
10
|
+
if (diff >= threshold) {
|
|
11
|
+
regressions.push({
|
|
12
|
+
name: e.name,
|
|
13
|
+
before,
|
|
14
|
+
after: e.duration,
|
|
15
|
+
diff
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
return regressions
|
|
21
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const formatTime = (ms) => {
|
|
2
|
+
if (ms < 1) return `${(ms * 1000).toFixed(2)} μs`
|
|
3
|
+
if (ms < 1000) return `${ms.toFixed(2)} ms`
|
|
4
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(2)} s`
|
|
5
|
+
return `${(ms / 60000).toFixed(2)} min`
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const makeBar = (ratio, width = 32) => {
|
|
9
|
+
const len = Math.max(1, Math.round(ratio * width))
|
|
10
|
+
return 'ā'.repeat(len)
|
|
11
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
import fs from 'fs'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
|
|
6
|
+
import { diffProfiles } from './diff.mjs'
|
|
7
|
+
import { formatTime, makeBar } from './format.mjs'
|
|
8
|
+
import { exportChromeTrace } from './trace.mjs'
|
|
9
|
+
|
|
10
|
+
/* ----------------------------------
|
|
11
|
+
Optional sourcemap resolver
|
|
12
|
+
----------------------------------- */
|
|
13
|
+
let SourceMapConsumer = null
|
|
14
|
+
try {
|
|
15
|
+
// optional dependency
|
|
16
|
+
const sm = await import('source-map')
|
|
17
|
+
SourceMapConsumer = sm.SourceMapConsumer
|
|
18
|
+
} catch {
|
|
19
|
+
// ignore ā sourcemap is optional
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
23
|
+
const __dirname = path.dirname(__filename)
|
|
24
|
+
|
|
25
|
+
export const createProfiler = ({
|
|
26
|
+
enabled = false,
|
|
27
|
+
verbose = false,
|
|
28
|
+
flame = false,
|
|
29
|
+
slowThreshold = 500,
|
|
30
|
+
hotThreshold = 0.8,
|
|
31
|
+
traceFile,
|
|
32
|
+
failOnHot = false,
|
|
33
|
+
diffBaseFile,
|
|
34
|
+
diffThreshold = 0.2,
|
|
35
|
+
captureSource = true // š„ new
|
|
36
|
+
} = {}) => {
|
|
37
|
+
const start = process.hrtime.bigint()
|
|
38
|
+
const stack = []
|
|
39
|
+
const events = []
|
|
40
|
+
|
|
41
|
+
const toMs = (a, b) => Number(b - a) / 1e6
|
|
42
|
+
|
|
43
|
+
/* ----------------------------------
|
|
44
|
+
Stack + sourcemap capture
|
|
45
|
+
----------------------------------- */
|
|
46
|
+
const captureSourceInfo = async () => {
|
|
47
|
+
const err = new Error()
|
|
48
|
+
const frames = err.stack
|
|
49
|
+
?.split('\n')
|
|
50
|
+
.slice(2)
|
|
51
|
+
.map((l) => l.trim())
|
|
52
|
+
|
|
53
|
+
if (!frames?.length) return null
|
|
54
|
+
|
|
55
|
+
const top = frames[0]
|
|
56
|
+
|
|
57
|
+
const match =
|
|
58
|
+
top.match(/\((.*):(\d+):(\d+)\)/) ||
|
|
59
|
+
top.match(/at (.*):(\d+):(\d+)/)
|
|
60
|
+
|
|
61
|
+
if (!match) return { raw: top }
|
|
62
|
+
|
|
63
|
+
const [, file, line, column] = match
|
|
64
|
+
const source = {
|
|
65
|
+
file,
|
|
66
|
+
line: Number(line),
|
|
67
|
+
column: Number(column)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!captureSource || !SourceMapConsumer) return source
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const mapFile = `${file}.map`
|
|
74
|
+
if (!fs.existsSync(mapFile)) return source
|
|
75
|
+
|
|
76
|
+
const rawMap = JSON.parse(fs.readFileSync(mapFile, 'utf8'))
|
|
77
|
+
const consumer = await new SourceMapConsumer(rawMap)
|
|
78
|
+
|
|
79
|
+
const pos = consumer.originalPositionFor({
|
|
80
|
+
line: source.line,
|
|
81
|
+
column: source.column
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
consumer.destroy?.()
|
|
85
|
+
|
|
86
|
+
if (pos?.source) {
|
|
87
|
+
return {
|
|
88
|
+
...source,
|
|
89
|
+
original: {
|
|
90
|
+
source: pos.source,
|
|
91
|
+
line: pos.line,
|
|
92
|
+
column: pos.column,
|
|
93
|
+
name: pos.name
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
/* silent */
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return source
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* ----------------------------------
|
|
105
|
+
STEP / MEASURE
|
|
106
|
+
----------------------------------- */
|
|
107
|
+
const step = (name, fn) => {
|
|
108
|
+
const parent = stack[stack.length - 1]
|
|
109
|
+
const startTime = process.hrtime.bigint()
|
|
110
|
+
|
|
111
|
+
const node = {
|
|
112
|
+
name,
|
|
113
|
+
start: toMs(start, startTime),
|
|
114
|
+
duration: 0,
|
|
115
|
+
depth: stack.length,
|
|
116
|
+
children: [],
|
|
117
|
+
source: null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (parent) parent.children.push(node)
|
|
121
|
+
stack.push(node)
|
|
122
|
+
|
|
123
|
+
const finish = async () => {
|
|
124
|
+
const endTime = process.hrtime.bigint()
|
|
125
|
+
node.duration = toMs(startTime, endTime)
|
|
126
|
+
events.push(node)
|
|
127
|
+
stack.pop()
|
|
128
|
+
|
|
129
|
+
const totalSoFar = toMs(start, endTime)
|
|
130
|
+
const isSlow = node.duration >= slowThreshold
|
|
131
|
+
const isHot = totalSoFar > 0 && node.duration / totalSoFar >= hotThreshold
|
|
132
|
+
|
|
133
|
+
if ((isSlow || isHot) && captureSource) {
|
|
134
|
+
node.source = await captureSourceInfo()
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const result = fn?.()
|
|
140
|
+
if (result && typeof result.then === 'function') {
|
|
141
|
+
return result.finally(finish)
|
|
142
|
+
}
|
|
143
|
+
finish()
|
|
144
|
+
return result
|
|
145
|
+
} catch (err) {
|
|
146
|
+
finish()
|
|
147
|
+
throw err
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const measure = (name, fn) => step(name, fn)
|
|
152
|
+
|
|
153
|
+
/* ----------------------------------
|
|
154
|
+
END
|
|
155
|
+
----------------------------------- */
|
|
156
|
+
const end = (label = 'Total') => {
|
|
157
|
+
const total = toMs(start, process.hrtime.bigint())
|
|
158
|
+
|
|
159
|
+
let hasHot = false
|
|
160
|
+
for (const e of events) {
|
|
161
|
+
if (e.duration / total >= hotThreshold) {
|
|
162
|
+
hasHot = true
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (enabled || verbose) {
|
|
167
|
+
console.log(
|
|
168
|
+
chalk.cyan('ā±'),
|
|
169
|
+
chalk.bold(label),
|
|
170
|
+
chalk.yellow(formatTime(total))
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
if (flame) {
|
|
174
|
+
for (const e of events) {
|
|
175
|
+
const ratio = e.duration / total
|
|
176
|
+
const hot = ratio >= hotThreshold
|
|
177
|
+
const slow = e.duration >= slowThreshold
|
|
178
|
+
|
|
179
|
+
console.log(
|
|
180
|
+
chalk.gray(`${' '.repeat(e.depth)}āā`),
|
|
181
|
+
chalk.white(e.name.padEnd(22)),
|
|
182
|
+
chalk.yellow(formatTime(e.duration)),
|
|
183
|
+
chalk.gray(makeBar(ratio)),
|
|
184
|
+
hot
|
|
185
|
+
? chalk.red.bold(' š„ HOT')
|
|
186
|
+
: slow
|
|
187
|
+
? chalk.yellow(' ā SLOW')
|
|
188
|
+
: ''
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
if ((hot || slow) && e.source) {
|
|
192
|
+
console.log(
|
|
193
|
+
chalk.gray(
|
|
194
|
+
` ā³ ${e.source.original?.source || e.source.file}:${
|
|
195
|
+
e.source.original?.line || e.source.line
|
|
196
|
+
}`
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const profile = { total, events }
|
|
205
|
+
|
|
206
|
+
if (traceFile) exportChromeTrace(events, traceFile)
|
|
207
|
+
|
|
208
|
+
if (diffBaseFile && fs.existsSync(diffBaseFile)) {
|
|
209
|
+
const base = JSON.parse(fs.readFileSync(diffBaseFile, 'utf8'))
|
|
210
|
+
const regressions = diffProfiles(base, profile, diffThreshold)
|
|
211
|
+
|
|
212
|
+
if (regressions.length) {
|
|
213
|
+
if (enabled || verbose) {
|
|
214
|
+
console.log(chalk.red('\nā Performance Regression Detected:\n'))
|
|
215
|
+
regressions.forEach((r) =>
|
|
216
|
+
console.log(
|
|
217
|
+
chalk.red(
|
|
218
|
+
` ${r.name}: ${formatTime(r.before)} ā ${formatTime(
|
|
219
|
+
r.after
|
|
220
|
+
)} (+${(r.diff * 100).toFixed(1)}%)`
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
process.exitCode = 1
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (enabled) {
|
|
230
|
+
fs.writeFileSync('profile.json', JSON.stringify(profile, null, 2))
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (failOnHot && hasHot) {
|
|
234
|
+
if (enabled || verbose) {
|
|
235
|
+
console.log(chalk.red('\nš„ HOT step detected ā failing CI'))
|
|
236
|
+
}
|
|
237
|
+
process.exitCode = 1
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return profile
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/* ----------------------------------
|
|
244
|
+
SUMMARY
|
|
245
|
+
----------------------------------- */
|
|
246
|
+
const flatten = (nodes, parentPath = []) => {
|
|
247
|
+
let res = []
|
|
248
|
+
for (const n of nodes) {
|
|
249
|
+
const pathArr = [...parentPath, n.name]
|
|
250
|
+
res.push({
|
|
251
|
+
name: n.name,
|
|
252
|
+
path: pathArr.join(' ā '),
|
|
253
|
+
duration: n.duration,
|
|
254
|
+
depth: n.depth,
|
|
255
|
+
source: n.source
|
|
256
|
+
})
|
|
257
|
+
if (n.children?.length) {
|
|
258
|
+
res = res.concat(flatten(n.children, pathArr))
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return res
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// eslint-disable-next-line no-shadow
|
|
265
|
+
const summary = ({ top = 5, hotThreshold = 0.8 } = {}) => {
|
|
266
|
+
const total = toMs(start, process.hrtime.bigint())
|
|
267
|
+
|
|
268
|
+
const flat = flatten(events)
|
|
269
|
+
.map((e) => ({
|
|
270
|
+
...e,
|
|
271
|
+
ratio: e.duration / total,
|
|
272
|
+
hot: e.duration / total >= hotThreshold
|
|
273
|
+
}))
|
|
274
|
+
.sort((a, b) => b.duration - a.duration)
|
|
275
|
+
.slice(0, top)
|
|
276
|
+
|
|
277
|
+
return { total, top: flat }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return { step, measure, end, summary }
|
|
281
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
|
|
3
|
+
export const exportChromeTrace = (events, file) => {
|
|
4
|
+
const traceEvents = []
|
|
5
|
+
const pid = 1
|
|
6
|
+
const tid = 1
|
|
7
|
+
|
|
8
|
+
const walk = (nodes) => {
|
|
9
|
+
for (const n of nodes) {
|
|
10
|
+
traceEvents.push({
|
|
11
|
+
name: n.name,
|
|
12
|
+
cat: 'wukong',
|
|
13
|
+
ph: 'X', // complete event
|
|
14
|
+
ts: Math.round(n.start * 1000), // ms ā μs
|
|
15
|
+
dur: Math.round(n.duration * 1000),
|
|
16
|
+
pid,
|
|
17
|
+
tid,
|
|
18
|
+
args: {
|
|
19
|
+
duration_ms: n.duration,
|
|
20
|
+
depth: n.depth,
|
|
21
|
+
...(n.source && {
|
|
22
|
+
source: n.source.original
|
|
23
|
+
? `${n.source.original.source}:${n.source.original.line}`
|
|
24
|
+
: `${n.source.file}:${n.source.line}`
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
if (n.children?.length) walk(n.children)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
walk(events)
|
|
34
|
+
|
|
35
|
+
fs.writeFileSync(
|
|
36
|
+
file,
|
|
37
|
+
JSON.stringify({ traceEvents }, null, 2),
|
|
38
|
+
'utf8'
|
|
39
|
+
)
|
|
40
|
+
}
|