@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.
@@ -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, thresholded ReportData structure.
2
+ * Transform raw SampleStore data into a sorted ReportData structure.
3
3
  *
4
- * Pure function: reads nested Maps from SampleStore, applies a 5% threshold
5
- * at every level (package, file, function) relative to the total profiled time,
6
- * aggregates below-threshold entries into an "other" count, and sorts by
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
- * Aggregate SampleStore data into a ReportData structure.
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
- export function aggregate(store: SampleStore, projectName: string): ReportData {
28
- // 1. Calculate total user-attributed time
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
- totalTimeUs += us;
25
+ total += us;
34
26
  }
35
27
  }
36
28
  }
29
+ return total;
30
+ }
37
31
 
38
- if (totalTimeUs === 0) {
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
- const threshold = totalTimeUs * THRESHOLD_PCT;
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 [packageName, fileMap] of store.packages) {
54
- // Sum total time for this package
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
- for (const funcMap of fileMap.values()) {
57
- for (const us of funcMap.values()) {
58
- packageTimeUs += us;
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
- // 3. Process files within the package
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 [fileName, funcMap] of fileMap) {
84
- // Sum time for this file
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
- // Apply threshold at file level (relative to total)
91
- if (fileTimeUs < threshold) {
92
- fileOtherCount++;
93
- continue;
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
- // 4. Process functions within the file
106
- const functions: FunctionEntry[] = [];
107
- let funcOtherCount = 0;
108
-
109
- for (const [funcName, funcTimeUs] of funcMap) {
110
- // Apply threshold at function level (relative to total)
111
- if (funcTimeUs < threshold) {
112
- funcOtherCount++;
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
- functions.push({
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
- files.push({
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: funcOtherCount,
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
- packages.push({
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: fileOtherCount,
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
- return {
238
+ const result: ReportData = {
157
239
  timestamp: new Date().toLocaleString(),
158
240
  totalTimeUs,
159
241
  packages,
160
- otherCount: topLevelOtherCount,
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
  }
@@ -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