@mtharrison/pkg-profiler 1.1.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -7
- package/dist/index.cjs +981 -153
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +94 -11
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +94 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +976 -152
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/async-tracker.ts +289 -0
- package/src/frame-parser.ts +3 -0
- package/src/index.ts +10 -3
- package/src/pkg-profile.ts +77 -0
- package/src/reporter/aggregate.ts +156 -64
- package/src/reporter/format.ts +10 -0
- package/src/reporter/html.ts +441 -10
- package/src/sampler.ts +197 -38
- package/src/types.ts +23 -0
- package/src/reporter.ts +0 -42
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Immutable profiling result returned by `stop()` and `profile()`.
|
|
3
|
+
*
|
|
4
|
+
* Contains aggregated per-package timing data and a convenience method
|
|
5
|
+
* to write a self-contained HTML report to disk.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { writeFileSync } from 'node:fs';
|
|
9
|
+
import { join, resolve } from 'node:path';
|
|
10
|
+
import type { PackageEntry, ReportData } from './types.js';
|
|
11
|
+
import { renderHtml } from './reporter/html.js';
|
|
12
|
+
|
|
13
|
+
function generateFilename(timestamp: string): string {
|
|
14
|
+
const now = new Date();
|
|
15
|
+
const pad = (n: number) => String(n).padStart(2, '0');
|
|
16
|
+
const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
|
17
|
+
const time = `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
18
|
+
return `where-you-at-${date}-${time}.html`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class PkgProfile {
|
|
22
|
+
/** When the profile was captured */
|
|
23
|
+
readonly timestamp: string;
|
|
24
|
+
/** Total sampled wall time in microseconds */
|
|
25
|
+
readonly totalTimeUs: number;
|
|
26
|
+
/** Package breakdown sorted by time descending (all packages, no threshold applied) */
|
|
27
|
+
readonly packages: PackageEntry[];
|
|
28
|
+
/** Always 0 — threshold filtering is now applied client-side in the HTML report */
|
|
29
|
+
readonly otherCount: number;
|
|
30
|
+
/** Project name (from package.json) */
|
|
31
|
+
readonly projectName: string;
|
|
32
|
+
/** Total async wait time in microseconds (undefined when async tracking not enabled) */
|
|
33
|
+
readonly totalAsyncTimeUs?: number;
|
|
34
|
+
/** Elapsed wall time in microseconds from start() to stop() */
|
|
35
|
+
readonly wallTimeUs?: number;
|
|
36
|
+
|
|
37
|
+
/** @internal */
|
|
38
|
+
constructor(data: ReportData) {
|
|
39
|
+
this.timestamp = data.timestamp;
|
|
40
|
+
this.totalTimeUs = data.totalTimeUs;
|
|
41
|
+
this.packages = data.packages;
|
|
42
|
+
this.otherCount = data.otherCount;
|
|
43
|
+
this.projectName = data.projectName;
|
|
44
|
+
this.totalAsyncTimeUs = data.totalAsyncTimeUs;
|
|
45
|
+
this.wallTimeUs = data.wallTimeUs;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Write a self-contained HTML report to disk.
|
|
50
|
+
*
|
|
51
|
+
* @param path - Output file path. Defaults to `./where-you-at-{timestamp}.html` in cwd.
|
|
52
|
+
* @returns Absolute path to the written file.
|
|
53
|
+
*/
|
|
54
|
+
writeHtml(path?: string): string {
|
|
55
|
+
const data: ReportData = {
|
|
56
|
+
timestamp: this.timestamp,
|
|
57
|
+
totalTimeUs: this.totalTimeUs,
|
|
58
|
+
packages: this.packages,
|
|
59
|
+
otherCount: this.otherCount,
|
|
60
|
+
projectName: this.projectName,
|
|
61
|
+
totalAsyncTimeUs: this.totalAsyncTimeUs,
|
|
62
|
+
wallTimeUs: this.wallTimeUs,
|
|
63
|
+
};
|
|
64
|
+
const html = renderHtml(data);
|
|
65
|
+
|
|
66
|
+
let filepath: string;
|
|
67
|
+
if (path) {
|
|
68
|
+
filepath = resolve(path);
|
|
69
|
+
} else {
|
|
70
|
+
const filename = generateFilename(this.timestamp);
|
|
71
|
+
filepath = join(process.cwd(), filename);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
writeFileSync(filepath, html, 'utf-8');
|
|
75
|
+
return filepath;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Transform raw SampleStore data into a sorted
|
|
2
|
+
* Transform raw SampleStore data into a sorted ReportData structure.
|
|
3
3
|
*
|
|
4
|
-
* Pure function: reads nested Maps from SampleStore,
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* timeUs descending at each level.
|
|
4
|
+
* Pure function: reads nested Maps from SampleStore, computes percentages,
|
|
5
|
+
* and sorts by timeUs descending at each level. No threshold filtering —
|
|
6
|
+
* all entries are included so the HTML report can apply thresholds client-side.
|
|
8
7
|
*/
|
|
9
8
|
|
|
10
9
|
import type { SampleStore } from '../sample-store.js';
|
|
@@ -15,27 +14,38 @@ import type {
|
|
|
15
14
|
FunctionEntry,
|
|
16
15
|
} from '../types.js';
|
|
17
16
|
|
|
18
|
-
const THRESHOLD_PCT = 0.05;
|
|
19
|
-
|
|
20
17
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* @param store - SampleStore with accumulated microseconds and sample counts
|
|
24
|
-
* @param projectName - Name of the first-party project (for isFirstParty flag)
|
|
25
|
-
* @returns ReportData with packages sorted desc by time, thresholded at 5%
|
|
18
|
+
* Sum all microseconds in a SampleStore.
|
|
26
19
|
*/
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
let totalTimeUs = 0;
|
|
20
|
+
function sumStore(store: SampleStore): number {
|
|
21
|
+
let total = 0;
|
|
30
22
|
for (const fileMap of store.packages.values()) {
|
|
31
23
|
for (const funcMap of fileMap.values()) {
|
|
32
24
|
for (const us of funcMap.values()) {
|
|
33
|
-
|
|
25
|
+
total += us;
|
|
34
26
|
}
|
|
35
27
|
}
|
|
36
28
|
}
|
|
29
|
+
return total;
|
|
30
|
+
}
|
|
37
31
|
|
|
38
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Aggregate SampleStore data into a ReportData structure.
|
|
34
|
+
*
|
|
35
|
+
* @param store - SampleStore with accumulated microseconds and sample counts
|
|
36
|
+
* @param projectName - Name of the first-party project (for isFirstParty flag)
|
|
37
|
+
* @param asyncStore - Optional SampleStore with async wait time data
|
|
38
|
+
* @returns ReportData with all packages sorted desc by time, no threshold applied
|
|
39
|
+
*/
|
|
40
|
+
export function aggregate(store: SampleStore, projectName: string, asyncStore?: SampleStore, globalAsyncTimeUs?: number, wallTimeUs?: number): ReportData {
|
|
41
|
+
// 1. Calculate total user-attributed time
|
|
42
|
+
const totalTimeUs = sumStore(store);
|
|
43
|
+
// Per-entry percentages use the raw sum so they add up to 100%
|
|
44
|
+
const totalAsyncTimeUs = asyncStore ? sumStore(asyncStore) : 0;
|
|
45
|
+
// Header total uses the merged (de-duplicated) global value when available
|
|
46
|
+
const headerAsyncTimeUs = globalAsyncTimeUs ?? totalAsyncTimeUs;
|
|
47
|
+
|
|
48
|
+
if (totalTimeUs === 0 && totalAsyncTimeUs === 0) {
|
|
39
49
|
return {
|
|
40
50
|
timestamp: new Date().toLocaleString(),
|
|
41
51
|
totalTimeUs: 0,
|
|
@@ -45,26 +55,29 @@ export function aggregate(store: SampleStore, projectName: string): ReportData {
|
|
|
45
55
|
};
|
|
46
56
|
}
|
|
47
57
|
|
|
48
|
-
|
|
58
|
+
// Collect all package names from both stores
|
|
59
|
+
const allPackageNames = new Set<string>();
|
|
60
|
+
for (const name of store.packages.keys()) allPackageNames.add(name);
|
|
61
|
+
if (asyncStore) {
|
|
62
|
+
for (const name of asyncStore.packages.keys()) allPackageNames.add(name);
|
|
63
|
+
}
|
|
64
|
+
|
|
49
65
|
const packages: PackageEntry[] = [];
|
|
50
|
-
let topLevelOtherCount = 0;
|
|
51
66
|
|
|
52
67
|
// 2. Process each package
|
|
53
|
-
for (const
|
|
54
|
-
|
|
68
|
+
for (const packageName of allPackageNames) {
|
|
69
|
+
const fileMap = store.packages.get(packageName);
|
|
70
|
+
|
|
71
|
+
// Sum total CPU time for this package
|
|
55
72
|
let packageTimeUs = 0;
|
|
56
|
-
|
|
57
|
-
for (const
|
|
58
|
-
|
|
73
|
+
if (fileMap) {
|
|
74
|
+
for (const funcMap of fileMap.values()) {
|
|
75
|
+
for (const us of funcMap.values()) {
|
|
76
|
+
packageTimeUs += us;
|
|
77
|
+
}
|
|
59
78
|
}
|
|
60
79
|
}
|
|
61
80
|
|
|
62
|
-
// Apply threshold at package level
|
|
63
|
-
if (packageTimeUs < threshold) {
|
|
64
|
-
topLevelOtherCount++;
|
|
65
|
-
continue;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
81
|
// Sum total sample count for this package
|
|
69
82
|
let packageSampleCount = 0;
|
|
70
83
|
const countFileMap = store.sampleCountsByPackage.get(packageName);
|
|
@@ -76,21 +89,46 @@ export function aggregate(store: SampleStore, projectName: string): ReportData {
|
|
|
76
89
|
}
|
|
77
90
|
}
|
|
78
91
|
|
|
79
|
-
//
|
|
92
|
+
// Async totals for this package
|
|
93
|
+
let packageAsyncTimeUs = 0;
|
|
94
|
+
let packageAsyncOpCount = 0;
|
|
95
|
+
const asyncFileMap = asyncStore?.packages.get(packageName);
|
|
96
|
+
const asyncCountFileMap = asyncStore?.sampleCountsByPackage.get(packageName);
|
|
97
|
+
if (asyncFileMap) {
|
|
98
|
+
for (const funcMap of asyncFileMap.values()) {
|
|
99
|
+
for (const us of funcMap.values()) {
|
|
100
|
+
packageAsyncTimeUs += us;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (asyncCountFileMap) {
|
|
105
|
+
for (const countFuncMap of asyncCountFileMap.values()) {
|
|
106
|
+
for (const count of countFuncMap.values()) {
|
|
107
|
+
packageAsyncOpCount += count;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 3. Collect all file names from both stores for this package
|
|
113
|
+
const allFileNames = new Set<string>();
|
|
114
|
+
if (fileMap) {
|
|
115
|
+
for (const name of fileMap.keys()) allFileNames.add(name);
|
|
116
|
+
}
|
|
117
|
+
if (asyncFileMap) {
|
|
118
|
+
for (const name of asyncFileMap.keys()) allFileNames.add(name);
|
|
119
|
+
}
|
|
120
|
+
|
|
80
121
|
const files: FileEntry[] = [];
|
|
81
|
-
let fileOtherCount = 0;
|
|
82
122
|
|
|
83
|
-
for (const
|
|
84
|
-
|
|
85
|
-
let fileTimeUs = 0;
|
|
86
|
-
for (const us of funcMap.values()) {
|
|
87
|
-
fileTimeUs += us;
|
|
88
|
-
}
|
|
123
|
+
for (const fileName of allFileNames) {
|
|
124
|
+
const funcMap = fileMap?.get(fileName);
|
|
89
125
|
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
126
|
+
// Sum CPU time for this file
|
|
127
|
+
let fileTimeUs = 0;
|
|
128
|
+
if (funcMap) {
|
|
129
|
+
for (const us of funcMap.values()) {
|
|
130
|
+
fileTimeUs += us;
|
|
131
|
+
}
|
|
94
132
|
}
|
|
95
133
|
|
|
96
134
|
// Sum sample count for this file
|
|
@@ -102,62 +140,116 @@ export function aggregate(store: SampleStore, projectName: string): ReportData {
|
|
|
102
140
|
}
|
|
103
141
|
}
|
|
104
142
|
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
let
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
continue;
|
|
143
|
+
// Async totals for this file
|
|
144
|
+
let fileAsyncTimeUs = 0;
|
|
145
|
+
let fileAsyncOpCount = 0;
|
|
146
|
+
const asyncFuncMap = asyncFileMap?.get(fileName);
|
|
147
|
+
const asyncCountFuncMap = asyncCountFileMap?.get(fileName);
|
|
148
|
+
if (asyncFuncMap) {
|
|
149
|
+
for (const us of asyncFuncMap.values()) {
|
|
150
|
+
fileAsyncTimeUs += us;
|
|
114
151
|
}
|
|
152
|
+
}
|
|
153
|
+
if (asyncCountFuncMap) {
|
|
154
|
+
for (const count of asyncCountFuncMap.values()) {
|
|
155
|
+
fileAsyncOpCount += count;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 4. Collect all function names from both stores for this file
|
|
160
|
+
const allFuncNames = new Set<string>();
|
|
161
|
+
if (funcMap) {
|
|
162
|
+
for (const name of funcMap.keys()) allFuncNames.add(name);
|
|
163
|
+
}
|
|
164
|
+
if (asyncFuncMap) {
|
|
165
|
+
for (const name of asyncFuncMap.keys()) allFuncNames.add(name);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const functions: FunctionEntry[] = [];
|
|
115
169
|
|
|
170
|
+
for (const funcName of allFuncNames) {
|
|
171
|
+
const funcTimeUs = funcMap?.get(funcName) ?? 0;
|
|
116
172
|
const funcSampleCount = countFuncMap?.get(funcName) ?? 0;
|
|
173
|
+
const funcAsyncTimeUs = asyncFuncMap?.get(funcName) ?? 0;
|
|
174
|
+
const funcAsyncOpCount = asyncCountFuncMap?.get(funcName) ?? 0;
|
|
117
175
|
|
|
118
|
-
|
|
176
|
+
const entry: FunctionEntry = {
|
|
119
177
|
name: funcName,
|
|
120
178
|
timeUs: funcTimeUs,
|
|
121
|
-
pct: (funcTimeUs / totalTimeUs) * 100,
|
|
179
|
+
pct: totalTimeUs > 0 ? (funcTimeUs / totalTimeUs) * 100 : 0,
|
|
122
180
|
sampleCount: funcSampleCount,
|
|
123
|
-
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
if (totalAsyncTimeUs > 0) {
|
|
184
|
+
entry.asyncTimeUs = funcAsyncTimeUs;
|
|
185
|
+
entry.asyncPct = (funcAsyncTimeUs / totalAsyncTimeUs) * 100;
|
|
186
|
+
entry.asyncOpCount = funcAsyncOpCount;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
functions.push(entry);
|
|
124
190
|
}
|
|
125
191
|
|
|
126
192
|
// Sort functions by timeUs descending
|
|
127
193
|
functions.sort((a, b) => b.timeUs - a.timeUs);
|
|
128
194
|
|
|
129
|
-
|
|
195
|
+
const fileEntry: FileEntry = {
|
|
130
196
|
name: fileName,
|
|
131
197
|
timeUs: fileTimeUs,
|
|
132
|
-
pct: (fileTimeUs / totalTimeUs) * 100,
|
|
198
|
+
pct: totalTimeUs > 0 ? (fileTimeUs / totalTimeUs) * 100 : 0,
|
|
133
199
|
sampleCount: fileSampleCount,
|
|
134
200
|
functions,
|
|
135
|
-
otherCount:
|
|
136
|
-
}
|
|
201
|
+
otherCount: 0,
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
if (totalAsyncTimeUs > 0) {
|
|
205
|
+
fileEntry.asyncTimeUs = fileAsyncTimeUs;
|
|
206
|
+
fileEntry.asyncPct = (fileAsyncTimeUs / totalAsyncTimeUs) * 100;
|
|
207
|
+
fileEntry.asyncOpCount = fileAsyncOpCount;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
files.push(fileEntry);
|
|
137
211
|
}
|
|
138
212
|
|
|
139
213
|
// Sort files by timeUs descending
|
|
140
214
|
files.sort((a, b) => b.timeUs - a.timeUs);
|
|
141
215
|
|
|
142
|
-
|
|
216
|
+
const pkgEntry: PackageEntry = {
|
|
143
217
|
name: packageName,
|
|
144
218
|
timeUs: packageTimeUs,
|
|
145
|
-
pct: (packageTimeUs / totalTimeUs) * 100,
|
|
219
|
+
pct: totalTimeUs > 0 ? (packageTimeUs / totalTimeUs) * 100 : 0,
|
|
146
220
|
isFirstParty: packageName === projectName,
|
|
147
221
|
sampleCount: packageSampleCount,
|
|
148
222
|
files,
|
|
149
|
-
otherCount:
|
|
150
|
-
}
|
|
223
|
+
otherCount: 0,
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
if (totalAsyncTimeUs > 0) {
|
|
227
|
+
pkgEntry.asyncTimeUs = packageAsyncTimeUs;
|
|
228
|
+
pkgEntry.asyncPct = (packageAsyncTimeUs / totalAsyncTimeUs) * 100;
|
|
229
|
+
pkgEntry.asyncOpCount = packageAsyncOpCount;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
packages.push(pkgEntry);
|
|
151
233
|
}
|
|
152
234
|
|
|
153
235
|
// Sort packages by timeUs descending
|
|
154
236
|
packages.sort((a, b) => b.timeUs - a.timeUs);
|
|
155
237
|
|
|
156
|
-
|
|
238
|
+
const result: ReportData = {
|
|
157
239
|
timestamp: new Date().toLocaleString(),
|
|
158
240
|
totalTimeUs,
|
|
159
241
|
packages,
|
|
160
|
-
otherCount:
|
|
242
|
+
otherCount: 0,
|
|
161
243
|
projectName,
|
|
162
244
|
};
|
|
245
|
+
|
|
246
|
+
if (headerAsyncTimeUs > 0) {
|
|
247
|
+
result.totalAsyncTimeUs = headerAsyncTimeUs;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (wallTimeUs !== undefined) {
|
|
251
|
+
result.wallTimeUs = wallTimeUs;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return result;
|
|
163
255
|
}
|
package/src/reporter/format.ts
CHANGED
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
* - < 1s: shows rounded milliseconds (e.g. "432ms")
|
|
11
11
|
* - Sub-millisecond values round up to 1ms (never shows "0ms" for nonzero input)
|
|
12
12
|
* - Zero returns "0ms"
|
|
13
|
+
*
|
|
14
|
+
* @param us - Time value in microseconds.
|
|
15
|
+
* @returns Human-readable time string.
|
|
13
16
|
*/
|
|
14
17
|
export function formatTime(us: number): string {
|
|
15
18
|
if (us === 0) return '0ms';
|
|
@@ -28,6 +31,10 @@ export function formatTime(us: number): string {
|
|
|
28
31
|
/**
|
|
29
32
|
* Convert microseconds to percentage of total with one decimal place.
|
|
30
33
|
* Returns "0.0%" when totalUs is zero (avoids division by zero).
|
|
34
|
+
*
|
|
35
|
+
* @param us - Time value in microseconds.
|
|
36
|
+
* @param totalUs - Total time in microseconds (denominator).
|
|
37
|
+
* @returns Percentage string like `"12.3%"`.
|
|
31
38
|
*/
|
|
32
39
|
export function formatPct(us: number, totalUs: number): string {
|
|
33
40
|
if (totalUs === 0) return '0.0%';
|
|
@@ -38,6 +45,9 @@ export function formatPct(us: number, totalUs: number): string {
|
|
|
38
45
|
* Escape HTML-special characters to prevent broken markup.
|
|
39
46
|
* Handles: & < > " '
|
|
40
47
|
* Ampersand is replaced first to avoid double-escaping.
|
|
48
|
+
*
|
|
49
|
+
* @param str - Raw string to escape.
|
|
50
|
+
* @returns HTML-safe string.
|
|
41
51
|
*/
|
|
42
52
|
export function escapeHtml(str: string): string {
|
|
43
53
|
return str
|