@mtharrison/pkg-profiler 2.0.0 → 2.2.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 +12 -12
- package/dist/index.cjs +343 -46
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -0
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +343 -46
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/async-tracker.ts +128 -17
- package/src/dep-chain.ts +111 -0
- package/src/pkg-profile.ts +4 -0
- package/src/reporter/aggregate.ts +27 -3
- package/src/reporter/html.ts +152 -28
- package/src/sampler.ts +14 -1
- package/src/types.ts +2 -0
package/src/dep-chain.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve dependency chains for transitive npm packages.
|
|
3
|
+
*
|
|
4
|
+
* BFS through node_modules package.json files starting from the project's
|
|
5
|
+
* direct dependencies to find the shortest path to each profiled package.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
interface PkgJson {
|
|
12
|
+
dependencies?: Record<string, string>;
|
|
13
|
+
optionalDependencies?: Record<string, string>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function readPkgJson(dir: string, cache: Map<string, PkgJson | null>): PkgJson | null {
|
|
17
|
+
if (cache.has(dir)) return cache.get(dir)!;
|
|
18
|
+
try {
|
|
19
|
+
const raw = readFileSync(join(dir, 'package.json'), 'utf-8');
|
|
20
|
+
const parsed = JSON.parse(raw) as PkgJson;
|
|
21
|
+
cache.set(dir, parsed);
|
|
22
|
+
return parsed;
|
|
23
|
+
} catch {
|
|
24
|
+
cache.set(dir, null);
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function depsOf(pkg: PkgJson): string[] {
|
|
30
|
+
return [
|
|
31
|
+
...Object.keys(pkg.dependencies ?? {}),
|
|
32
|
+
...Object.keys(pkg.optionalDependencies ?? {}),
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the shortest dependency chain from the project's direct deps
|
|
38
|
+
* to each of the given package names.
|
|
39
|
+
*
|
|
40
|
+
* @param projectRoot - Absolute path to the project root (contains package.json and node_modules/)
|
|
41
|
+
* @param packageNames - Set of package names that appeared in profiling data
|
|
42
|
+
* @param maxDepth - Maximum BFS depth to search (default 5)
|
|
43
|
+
* @returns Map from package name to chain array (e.g. `["express", "qs"]` means project -> express -> qs)
|
|
44
|
+
*/
|
|
45
|
+
export function resolveDependencyChains(
|
|
46
|
+
projectRoot: string,
|
|
47
|
+
packageNames: Set<string>,
|
|
48
|
+
maxDepth: number = 5,
|
|
49
|
+
): Map<string, string[]> {
|
|
50
|
+
const result = new Map<string, string[]>();
|
|
51
|
+
const cache = new Map<string, PkgJson | null>();
|
|
52
|
+
|
|
53
|
+
const rootPkg = readPkgJson(projectRoot, cache);
|
|
54
|
+
if (!rootPkg) return result;
|
|
55
|
+
|
|
56
|
+
const directDeps = new Set(depsOf(rootPkg));
|
|
57
|
+
|
|
58
|
+
// Mark direct deps — they have no chain
|
|
59
|
+
for (const name of packageNames) {
|
|
60
|
+
if (directDeps.has(name)) {
|
|
61
|
+
// Direct dep: no chain needed (empty array signals "direct")
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Only need to resolve transitive deps
|
|
66
|
+
const targets = new Set<string>();
|
|
67
|
+
for (const name of packageNames) {
|
|
68
|
+
if (!directDeps.has(name)) {
|
|
69
|
+
targets.add(name);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (targets.size === 0) return result;
|
|
74
|
+
|
|
75
|
+
// BFS: queue entries are [packageName, chain-so-far]
|
|
76
|
+
const visited = new Set<string>();
|
|
77
|
+
const queue: Array<[string, string[]]> = [];
|
|
78
|
+
|
|
79
|
+
for (const dep of directDeps) {
|
|
80
|
+
queue.push([dep, [dep]]);
|
|
81
|
+
visited.add(dep);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let qi = 0;
|
|
85
|
+
while (qi < queue.length && targets.size > 0) {
|
|
86
|
+
const [pkgName, chain] = queue[qi++]!;
|
|
87
|
+
|
|
88
|
+
if (chain.length > maxDepth) continue;
|
|
89
|
+
|
|
90
|
+
// If this package is one of our targets, record the chain (excluding the target itself)
|
|
91
|
+
if (targets.has(pkgName)) {
|
|
92
|
+
result.set(pkgName, chain.slice(0, -1));
|
|
93
|
+
targets.delete(pkgName);
|
|
94
|
+
if (targets.size === 0) break;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Read this package's deps and enqueue
|
|
98
|
+
const pkgDir = join(projectRoot, 'node_modules', pkgName);
|
|
99
|
+
const pkg = readPkgJson(pkgDir, cache);
|
|
100
|
+
if (!pkg) continue;
|
|
101
|
+
|
|
102
|
+
for (const child of depsOf(pkg)) {
|
|
103
|
+
if (!visited.has(child)) {
|
|
104
|
+
visited.add(child);
|
|
105
|
+
queue.push([child, [...chain, child]]);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return result;
|
|
111
|
+
}
|
package/src/pkg-profile.ts
CHANGED
|
@@ -31,6 +31,8 @@ export class PkgProfile {
|
|
|
31
31
|
readonly projectName: string;
|
|
32
32
|
/** Total async wait time in microseconds (undefined when async tracking not enabled) */
|
|
33
33
|
readonly totalAsyncTimeUs?: number;
|
|
34
|
+
/** Elapsed wall time in microseconds from start() to stop() */
|
|
35
|
+
readonly wallTimeUs?: number;
|
|
34
36
|
|
|
35
37
|
/** @internal */
|
|
36
38
|
constructor(data: ReportData) {
|
|
@@ -40,6 +42,7 @@ export class PkgProfile {
|
|
|
40
42
|
this.otherCount = data.otherCount;
|
|
41
43
|
this.projectName = data.projectName;
|
|
42
44
|
this.totalAsyncTimeUs = data.totalAsyncTimeUs;
|
|
45
|
+
this.wallTimeUs = data.wallTimeUs;
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
/**
|
|
@@ -56,6 +59,7 @@ export class PkgProfile {
|
|
|
56
59
|
otherCount: this.otherCount,
|
|
57
60
|
projectName: this.projectName,
|
|
58
61
|
totalAsyncTimeUs: this.totalAsyncTimeUs,
|
|
62
|
+
wallTimeUs: this.wallTimeUs,
|
|
59
63
|
};
|
|
60
64
|
const html = renderHtml(data);
|
|
61
65
|
|
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
FileEntry,
|
|
14
14
|
FunctionEntry,
|
|
15
15
|
} from '../types.js';
|
|
16
|
+
import { resolveDependencyChains } from '../dep-chain.js';
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Sum all microseconds in a SampleStore.
|
|
@@ -37,10 +38,13 @@ function sumStore(store: SampleStore): number {
|
|
|
37
38
|
* @param asyncStore - Optional SampleStore with async wait time data
|
|
38
39
|
* @returns ReportData with all packages sorted desc by time, no threshold applied
|
|
39
40
|
*/
|
|
40
|
-
export function aggregate(store: SampleStore, projectName: string, asyncStore?: SampleStore): ReportData {
|
|
41
|
+
export function aggregate(store: SampleStore, projectName: string, asyncStore?: SampleStore, globalAsyncTimeUs?: number, wallTimeUs?: number, projectRoot?: string): ReportData {
|
|
41
42
|
// 1. Calculate total user-attributed time
|
|
42
43
|
const totalTimeUs = sumStore(store);
|
|
44
|
+
// Per-entry percentages use the raw sum so they add up to 100%
|
|
43
45
|
const totalAsyncTimeUs = asyncStore ? sumStore(asyncStore) : 0;
|
|
46
|
+
// Header total uses the merged (de-duplicated) global value when available
|
|
47
|
+
const headerAsyncTimeUs = globalAsyncTimeUs ?? totalAsyncTimeUs;
|
|
44
48
|
|
|
45
49
|
if (totalTimeUs === 0 && totalAsyncTimeUs === 0) {
|
|
46
50
|
return {
|
|
@@ -229,6 +233,22 @@ export function aggregate(store: SampleStore, projectName: string, asyncStore?:
|
|
|
229
233
|
packages.push(pkgEntry);
|
|
230
234
|
}
|
|
231
235
|
|
|
236
|
+
// Resolve dependency chains for non-first-party packages
|
|
237
|
+
if (projectRoot) {
|
|
238
|
+
const thirdPartyNames = new Set(
|
|
239
|
+
packages.filter(p => !p.isFirstParty).map(p => p.name),
|
|
240
|
+
);
|
|
241
|
+
if (thirdPartyNames.size > 0) {
|
|
242
|
+
const chains = resolveDependencyChains(projectRoot, thirdPartyNames);
|
|
243
|
+
for (const pkg of packages) {
|
|
244
|
+
const chain = chains.get(pkg.name);
|
|
245
|
+
if (chain && chain.length > 0) {
|
|
246
|
+
pkg.depChain = chain;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
232
252
|
// Sort packages by timeUs descending
|
|
233
253
|
packages.sort((a, b) => b.timeUs - a.timeUs);
|
|
234
254
|
|
|
@@ -240,8 +260,12 @@ export function aggregate(store: SampleStore, projectName: string, asyncStore?:
|
|
|
240
260
|
projectName,
|
|
241
261
|
};
|
|
242
262
|
|
|
243
|
-
if (
|
|
244
|
-
result.totalAsyncTimeUs =
|
|
263
|
+
if (headerAsyncTimeUs > 0) {
|
|
264
|
+
result.totalAsyncTimeUs = headerAsyncTimeUs;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (wallTimeUs !== undefined) {
|
|
268
|
+
result.wallTimeUs = wallTimeUs;
|
|
245
269
|
}
|
|
246
270
|
|
|
247
271
|
return result;
|
package/src/reporter/html.ts
CHANGED
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
import type { ReportData, PackageEntry, FileEntry } from '../types.js';
|
|
10
10
|
import { formatTime, formatPct, escapeHtml } from './format.js';
|
|
11
11
|
|
|
12
|
+
function formatDepChain(depChain: string[] | undefined): string {
|
|
13
|
+
if (!depChain || depChain.length === 0) return '';
|
|
14
|
+
return `<span class="dep-chain">via ${depChain.map(n => escapeHtml(n)).join(' > ')}</span>`;
|
|
15
|
+
}
|
|
16
|
+
|
|
12
17
|
function generateCss(): string {
|
|
13
18
|
return `
|
|
14
19
|
:root {
|
|
@@ -152,6 +157,7 @@ function generateCss(): string {
|
|
|
152
157
|
}
|
|
153
158
|
|
|
154
159
|
td.pkg-name { font-family: var(--font-mono); font-size: 0.85rem; }
|
|
160
|
+
.dep-chain { display: block; font-size: 0.7rem; color: var(--muted); font-family: var(--font-sans); }
|
|
155
161
|
td.numeric { text-align: right; font-family: var(--font-mono); font-size: 0.85rem; }
|
|
156
162
|
td.async-col { color: var(--bar-fill-async); }
|
|
157
163
|
|
|
@@ -295,9 +301,59 @@ function generateCss(): string {
|
|
|
295
301
|
.other-item.indent-1 { padding-left: 2rem; }
|
|
296
302
|
.other-item.indent-2 { padding-left: 3.25rem; }
|
|
297
303
|
|
|
304
|
+
/* Sort control */
|
|
305
|
+
.sort-control {
|
|
306
|
+
display: inline-flex;
|
|
307
|
+
align-items: center;
|
|
308
|
+
gap: 0.5rem;
|
|
309
|
+
margin-left: 1.5rem;
|
|
310
|
+
font-size: 0.85rem;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
.sort-control label {
|
|
314
|
+
font-weight: 600;
|
|
315
|
+
color: var(--muted);
|
|
316
|
+
text-transform: uppercase;
|
|
317
|
+
letter-spacing: 0.04em;
|
|
318
|
+
font-size: 0.8rem;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
.sort-toggle {
|
|
322
|
+
display: inline-flex;
|
|
323
|
+
border: 1px solid var(--border);
|
|
324
|
+
border-radius: 4px;
|
|
325
|
+
overflow: hidden;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
.sort-toggle button {
|
|
329
|
+
font-family: var(--font-sans);
|
|
330
|
+
font-size: 0.8rem;
|
|
331
|
+
padding: 0.25rem 0.6rem;
|
|
332
|
+
border: none;
|
|
333
|
+
background: #fff;
|
|
334
|
+
color: var(--muted);
|
|
335
|
+
cursor: pointer;
|
|
336
|
+
transition: background 0.15s, color 0.15s;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
.sort-toggle button + button {
|
|
340
|
+
border-left: 1px solid var(--border);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
.sort-toggle button.active {
|
|
344
|
+
background: var(--bar-fill);
|
|
345
|
+
color: #fff;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
.sort-toggle button.active-async {
|
|
349
|
+
background: var(--bar-fill-async);
|
|
350
|
+
color: #fff;
|
|
351
|
+
}
|
|
352
|
+
|
|
298
353
|
@media (max-width: 600px) {
|
|
299
354
|
body { padding: 1rem; }
|
|
300
355
|
.bar-cell { width: 25%; }
|
|
356
|
+
.sort-control { margin-left: 0; margin-top: 0.5rem; }
|
|
301
357
|
}
|
|
302
358
|
`;
|
|
303
359
|
}
|
|
@@ -331,14 +387,32 @@ function generateJs(): string {
|
|
|
331
387
|
.replace(/'/g, ''');
|
|
332
388
|
}
|
|
333
389
|
|
|
390
|
+
function depChainHtml(depChain) {
|
|
391
|
+
if (!depChain || depChain.length === 0) return '';
|
|
392
|
+
return '<span class="dep-chain">via ' + depChain.map(function(n) { return escapeHtml(n); }).join(' > ') + '</span>';
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
var sortBy = 'cpu';
|
|
396
|
+
|
|
397
|
+
function metricTime(entry) {
|
|
398
|
+
return sortBy === 'async' ? (entry.asyncTimeUs || 0) : entry.timeUs;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function sortDesc(arr) {
|
|
402
|
+
return arr.slice().sort(function(a, b) { return metricTime(b) - metricTime(a); });
|
|
403
|
+
}
|
|
404
|
+
|
|
334
405
|
function applyThreshold(data, pct) {
|
|
335
|
-
var
|
|
406
|
+
var totalBase = sortBy === 'async' ? (data.totalAsyncTimeUs || 0) : data.totalTimeUs;
|
|
407
|
+
var threshold = totalBase * (pct / 100);
|
|
336
408
|
var filtered = [];
|
|
337
409
|
var otherCount = 0;
|
|
338
410
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
411
|
+
var pkgs = sortDesc(data.packages);
|
|
412
|
+
|
|
413
|
+
for (var i = 0; i < pkgs.length; i++) {
|
|
414
|
+
var pkg = pkgs[i];
|
|
415
|
+
if (metricTime(pkg) < threshold) {
|
|
342
416
|
otherCount++;
|
|
343
417
|
continue;
|
|
344
418
|
}
|
|
@@ -346,9 +420,11 @@ function generateJs(): string {
|
|
|
346
420
|
var files = [];
|
|
347
421
|
var fileOtherCount = 0;
|
|
348
422
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
423
|
+
var sortedFiles = sortDesc(pkg.files);
|
|
424
|
+
|
|
425
|
+
for (var j = 0; j < sortedFiles.length; j++) {
|
|
426
|
+
var file = sortedFiles[j];
|
|
427
|
+
if (metricTime(file) < threshold) {
|
|
352
428
|
fileOtherCount++;
|
|
353
429
|
continue;
|
|
354
430
|
}
|
|
@@ -356,9 +432,11 @@ function generateJs(): string {
|
|
|
356
432
|
var functions = [];
|
|
357
433
|
var funcOtherCount = 0;
|
|
358
434
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
435
|
+
var sortedFns = sortDesc(file.functions);
|
|
436
|
+
|
|
437
|
+
for (var k = 0; k < sortedFns.length; k++) {
|
|
438
|
+
var fn = sortedFns[k];
|
|
439
|
+
if (metricTime(fn) < threshold) {
|
|
362
440
|
funcOtherCount++;
|
|
363
441
|
continue;
|
|
364
442
|
}
|
|
@@ -384,6 +462,7 @@ function generateJs(): string {
|
|
|
384
462
|
pct: pkg.pct,
|
|
385
463
|
isFirstParty: pkg.isFirstParty,
|
|
386
464
|
sampleCount: pkg.sampleCount,
|
|
465
|
+
depChain: pkg.depChain,
|
|
387
466
|
asyncTimeUs: pkg.asyncTimeUs,
|
|
388
467
|
asyncPct: pkg.asyncPct,
|
|
389
468
|
asyncOpCount: pkg.asyncOpCount,
|
|
@@ -395,18 +474,21 @@ function generateJs(): string {
|
|
|
395
474
|
return { packages: filtered, otherCount: otherCount };
|
|
396
475
|
}
|
|
397
476
|
|
|
398
|
-
function renderTable(packages, otherCount, totalTimeUs) {
|
|
477
|
+
function renderTable(packages, otherCount, totalTimeUs, totalAsyncTimeUs) {
|
|
399
478
|
var rows = '';
|
|
479
|
+
var isAsync = sortBy === 'async';
|
|
480
|
+
var barTotal = isAsync ? (totalAsyncTimeUs || 0) : totalTimeUs;
|
|
400
481
|
for (var i = 0; i < packages.length; i++) {
|
|
401
482
|
var pkg = packages[i];
|
|
402
483
|
var cls = pkg.isFirstParty ? 'first-party' : 'dependency';
|
|
403
|
-
var
|
|
484
|
+
var barVal = isAsync ? (pkg.asyncTimeUs || 0) : pkg.timeUs;
|
|
485
|
+
var pctVal = barTotal > 0 ? (barVal / barTotal) * 100 : 0;
|
|
404
486
|
rows += '<tr class="' + cls + '">' +
|
|
405
|
-
'<td class="pkg-name">' + escapeHtml(pkg.name) + '</td>' +
|
|
487
|
+
'<td class="pkg-name">' + escapeHtml(pkg.name) + depChainHtml(pkg.depChain) + '</td>' +
|
|
406
488
|
'<td class="numeric">' + escapeHtml(formatTime(pkg.timeUs)) + '</td>' +
|
|
407
489
|
'<td class="bar-cell"><div class="bar-container">' +
|
|
408
490
|
'<div class="bar-track"><div class="bar-fill" style="width:' + pctVal.toFixed(1) + '%"></div></div>' +
|
|
409
|
-
'<span class="bar-pct">' + escapeHtml(formatPct(
|
|
491
|
+
'<span class="bar-pct">' + escapeHtml(formatPct(barVal, barTotal)) + '</span>' +
|
|
410
492
|
'</div></td>' +
|
|
411
493
|
'<td class="numeric">' + pkg.sampleCount + '</td>';
|
|
412
494
|
if (HAS_ASYNC) {
|
|
@@ -428,9 +510,9 @@ function generateJs(): string {
|
|
|
428
510
|
rows += '</tr>';
|
|
429
511
|
}
|
|
430
512
|
|
|
431
|
-
var headers = '<th>Package</th><th>
|
|
513
|
+
var headers = '<th>Package</th><th>CPU Time</th><th>% of Total</th><th>Samples</th>';
|
|
432
514
|
if (HAS_ASYNC) {
|
|
433
|
-
headers += '<th>Async Wait</th><th>Async Ops</th>';
|
|
515
|
+
headers += '<th>Async I/O Wait</th><th>Async Ops</th>';
|
|
434
516
|
}
|
|
435
517
|
|
|
436
518
|
return '<table><thead><tr>' + headers + '</tr></thead><tbody>' + rows + '</tbody></table>';
|
|
@@ -444,34 +526,40 @@ function generateJs(): string {
|
|
|
444
526
|
return ' <span class="tree-async">| ' + escapeHtml(formatTime(at)) + ' async · ' + ac + ' ops</span>';
|
|
445
527
|
}
|
|
446
528
|
|
|
447
|
-
function renderTree(packages, otherCount, totalTimeUs) {
|
|
529
|
+
function renderTree(packages, otherCount, totalTimeUs, totalAsyncTimeUs) {
|
|
448
530
|
var html = '<div class="tree">';
|
|
531
|
+
var isAsync = sortBy === 'async';
|
|
532
|
+
var pctTotal = isAsync ? (totalAsyncTimeUs || 0) : totalTimeUs;
|
|
449
533
|
|
|
450
534
|
for (var i = 0; i < packages.length; i++) {
|
|
451
535
|
var pkg = packages[i];
|
|
452
536
|
var fpCls = pkg.isFirstParty ? ' fp-pkg' : '';
|
|
537
|
+
var pkgTime = isAsync ? (pkg.asyncTimeUs || 0) : pkg.timeUs;
|
|
453
538
|
html += '<details class="level-0' + fpCls + '"><summary>';
|
|
454
539
|
html += '<span class="tree-label pkg">pkg</span>';
|
|
455
540
|
html += '<span class="tree-name">' + escapeHtml(pkg.name) + '</span>';
|
|
456
|
-
html +=
|
|
541
|
+
html += depChainHtml(pkg.depChain);
|
|
542
|
+
html += '<span class="tree-stats">' + escapeHtml(formatTime(pkgTime)) + ' · ' + escapeHtml(formatPct(pkgTime, pctTotal)) + ' · ' + pkg.sampleCount + ' samples</span>';
|
|
457
543
|
html += asyncStats(pkg);
|
|
458
544
|
html += '</summary>';
|
|
459
545
|
|
|
460
546
|
for (var j = 0; j < pkg.files.length; j++) {
|
|
461
547
|
var file = pkg.files[j];
|
|
548
|
+
var fileTime = isAsync ? (file.asyncTimeUs || 0) : file.timeUs;
|
|
462
549
|
html += '<details class="level-1"><summary>';
|
|
463
550
|
html += '<span class="tree-label file">file</span>';
|
|
464
551
|
html += '<span class="tree-name">' + escapeHtml(file.name) + '</span>';
|
|
465
|
-
html += '<span class="tree-stats">' + escapeHtml(formatTime(
|
|
552
|
+
html += '<span class="tree-stats">' + escapeHtml(formatTime(fileTime)) + ' · ' + escapeHtml(formatPct(fileTime, pctTotal)) + ' · ' + file.sampleCount + ' samples</span>';
|
|
466
553
|
html += asyncStats(file);
|
|
467
554
|
html += '</summary>';
|
|
468
555
|
|
|
469
556
|
for (var k = 0; k < file.functions.length; k++) {
|
|
470
557
|
var fn = file.functions[k];
|
|
558
|
+
var fnTime = isAsync ? (fn.asyncTimeUs || 0) : fn.timeUs;
|
|
471
559
|
html += '<div class="level-2">';
|
|
472
560
|
html += '<span class="tree-label fn">fn</span> ';
|
|
473
561
|
html += '<span class="tree-name">' + escapeHtml(fn.name) + '</span>';
|
|
474
|
-
html += ' <span class="tree-stats">' + escapeHtml(formatTime(
|
|
562
|
+
html += ' <span class="tree-stats">' + escapeHtml(formatTime(fnTime)) + ' · ' + escapeHtml(formatPct(fnTime, pctTotal)) + ' · ' + fn.sampleCount + ' samples</span>';
|
|
475
563
|
html += asyncStats(fn);
|
|
476
564
|
html += '</div>';
|
|
477
565
|
}
|
|
@@ -498,12 +586,26 @@ function generateJs(): string {
|
|
|
498
586
|
return html;
|
|
499
587
|
}
|
|
500
588
|
|
|
589
|
+
var currentThreshold = 5;
|
|
590
|
+
|
|
501
591
|
function update(pct) {
|
|
592
|
+
currentThreshold = pct;
|
|
502
593
|
var result = applyThreshold(DATA, pct);
|
|
503
594
|
var summaryEl = document.getElementById('summary-container');
|
|
504
595
|
var treeEl = document.getElementById('tree-container');
|
|
505
|
-
if (summaryEl) summaryEl.innerHTML = renderTable(result.packages, result.otherCount, DATA.totalTimeUs);
|
|
506
|
-
if (treeEl) treeEl.innerHTML = renderTree(result.packages, result.otherCount, DATA.totalTimeUs);
|
|
596
|
+
if (summaryEl) summaryEl.innerHTML = renderTable(result.packages, result.otherCount, DATA.totalTimeUs, DATA.totalAsyncTimeUs);
|
|
597
|
+
if (treeEl) treeEl.innerHTML = renderTree(result.packages, result.otherCount, DATA.totalTimeUs, DATA.totalAsyncTimeUs);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function updateSortButtons() {
|
|
601
|
+
var btns = document.querySelectorAll('.sort-toggle button');
|
|
602
|
+
for (var i = 0; i < btns.length; i++) {
|
|
603
|
+
var btn = btns[i];
|
|
604
|
+
btn.className = '';
|
|
605
|
+
if (btn.getAttribute('data-sort') === sortBy) {
|
|
606
|
+
btn.className = sortBy === 'async' ? 'active-async' : 'active';
|
|
607
|
+
}
|
|
608
|
+
}
|
|
507
609
|
}
|
|
508
610
|
|
|
509
611
|
document.addEventListener('DOMContentLoaded', function() {
|
|
@@ -517,6 +619,15 @@ function generateJs(): string {
|
|
|
517
619
|
update(val);
|
|
518
620
|
});
|
|
519
621
|
}
|
|
622
|
+
|
|
623
|
+
var sortBtns = document.querySelectorAll('.sort-toggle button');
|
|
624
|
+
for (var i = 0; i < sortBtns.length; i++) {
|
|
625
|
+
sortBtns[i].addEventListener('click', function() {
|
|
626
|
+
sortBy = this.getAttribute('data-sort') || 'cpu';
|
|
627
|
+
updateSortButtons();
|
|
628
|
+
update(currentThreshold);
|
|
629
|
+
});
|
|
630
|
+
}
|
|
520
631
|
});
|
|
521
632
|
})();
|
|
522
633
|
`;
|
|
@@ -535,7 +646,7 @@ function renderSummaryTable(
|
|
|
535
646
|
const pctVal = totalTimeUs > 0 ? (pkg.timeUs / totalTimeUs) * 100 : 0;
|
|
536
647
|
rows += `
|
|
537
648
|
<tr class="${cls}">
|
|
538
|
-
<td class="pkg-name">${escapeHtml(pkg.name)}</td>
|
|
649
|
+
<td class="pkg-name">${escapeHtml(pkg.name)}${formatDepChain(pkg.depChain)}</td>
|
|
539
650
|
<td class="numeric">${escapeHtml(formatTime(pkg.timeUs))}</td>
|
|
540
651
|
<td class="bar-cell">
|
|
541
652
|
<div class="bar-container">
|
|
@@ -566,10 +677,10 @@ function renderSummaryTable(
|
|
|
566
677
|
<thead>
|
|
567
678
|
<tr>
|
|
568
679
|
<th>Package</th>
|
|
569
|
-
<th>
|
|
680
|
+
<th>CPU Time</th>
|
|
570
681
|
<th>% of Total</th>
|
|
571
682
|
<th>Samples</th>${hasAsync ? `
|
|
572
|
-
<th>Async Wait</th>
|
|
683
|
+
<th>Async I/O Wait</th>
|
|
573
684
|
<th>Async Ops</th>` : ''}
|
|
574
685
|
</tr>
|
|
575
686
|
</thead>
|
|
@@ -599,6 +710,7 @@ function renderTree(
|
|
|
599
710
|
html += `<summary>`;
|
|
600
711
|
html += `<span class="tree-label pkg">pkg</span>`;
|
|
601
712
|
html += `<span class="tree-name">${escapeHtml(pkg.name)}</span>`;
|
|
713
|
+
html += formatDepChain(pkg.depChain);
|
|
602
714
|
html += `<span class="tree-stats">${escapeHtml(formatTime(pkg.timeUs))} · ${escapeHtml(formatPct(pkg.timeUs, totalTimeUs))} · ${pkg.sampleCount} samples</span>`;
|
|
603
715
|
if (hasAsync) html += formatAsyncStats(pkg);
|
|
604
716
|
html += `</summary>`;
|
|
@@ -657,9 +769,14 @@ export function renderHtml(data: ReportData): string {
|
|
|
657
769
|
|
|
658
770
|
const titleName = escapeHtml(data.projectName);
|
|
659
771
|
|
|
660
|
-
|
|
772
|
+
const wallFormatted = data.wallTimeUs ? escapeHtml(formatTime(data.wallTimeUs)) : null;
|
|
773
|
+
let metaLine = `Generated ${escapeHtml(data.timestamp)}`;
|
|
774
|
+
if (wallFormatted) {
|
|
775
|
+
metaLine += ` · Wall time: ${wallFormatted}`;
|
|
776
|
+
}
|
|
777
|
+
metaLine += ` · CPU time: ${totalFormatted}`;
|
|
661
778
|
if (hasAsync) {
|
|
662
|
-
metaLine += ` ·
|
|
779
|
+
metaLine += ` · Async I/O wait: ${escapeHtml(formatTime(data.totalAsyncTimeUs!))}`;
|
|
663
780
|
}
|
|
664
781
|
|
|
665
782
|
// Sanitize JSON for safe embedding in <script> — replace < to prevent </script> injection
|
|
@@ -682,7 +799,14 @@ export function renderHtml(data: ReportData): string {
|
|
|
682
799
|
<div class="threshold-control">
|
|
683
800
|
<label>Threshold</label>
|
|
684
801
|
<input type="range" id="threshold-slider" min="0" max="20" step="0.5" value="5">
|
|
685
|
-
<span id="threshold-value">5.0%</span
|
|
802
|
+
<span id="threshold-value">5.0%</span>${hasAsync ? `
|
|
803
|
+
<span class="sort-control">
|
|
804
|
+
<label>Sort by</label>
|
|
805
|
+
<span class="sort-toggle">
|
|
806
|
+
<button data-sort="cpu" class="active">CPU Time</button>
|
|
807
|
+
<button data-sort="async">Async I/O Wait</button>
|
|
808
|
+
</span>
|
|
809
|
+
</span>` : ''}
|
|
686
810
|
</div>
|
|
687
811
|
<div id="summary-container">${summaryTable}</div>
|
|
688
812
|
|
package/src/sampler.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
// Module-level state -- lazy initialization
|
|
18
18
|
let session: Session | null = null;
|
|
19
19
|
let profiling = false;
|
|
20
|
+
let startHrtime: [number, number] | null = null;
|
|
20
21
|
const store = new SampleStore();
|
|
21
22
|
const asyncStore = new SampleStore();
|
|
22
23
|
const resolver = new PackageResolver(process.cwd());
|
|
@@ -85,22 +86,32 @@ function stopSync(): PkgProfile {
|
|
|
85
86
|
return buildEmptyProfile();
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
const elapsed = startHrtime ? process.hrtime(startHrtime) : null;
|
|
90
|
+
const wallTimeUs = elapsed ? elapsed[0] * 1_000_000 + Math.round(elapsed[1] / 1000) : undefined;
|
|
91
|
+
startHrtime = null;
|
|
92
|
+
|
|
88
93
|
const { profile } = postSync("Profiler.stop") as Profiler.StopReturnType;
|
|
89
94
|
postSync("Profiler.disable");
|
|
90
95
|
profiling = false;
|
|
91
96
|
|
|
97
|
+
let globalAsyncTimeUs: number | undefined;
|
|
92
98
|
if (asyncTracker) {
|
|
93
99
|
asyncTracker.disable();
|
|
100
|
+
globalAsyncTimeUs = asyncTracker.mergedTotalUs;
|
|
94
101
|
asyncTracker = null;
|
|
95
102
|
}
|
|
96
103
|
|
|
97
104
|
processProfile(profile);
|
|
98
105
|
|
|
99
|
-
const
|
|
106
|
+
const cwd = process.cwd();
|
|
107
|
+
const projectName = readProjectName(cwd);
|
|
100
108
|
const data = aggregate(
|
|
101
109
|
store,
|
|
102
110
|
projectName,
|
|
103
111
|
asyncStore.packages.size > 0 ? asyncStore : undefined,
|
|
112
|
+
globalAsyncTimeUs,
|
|
113
|
+
wallTimeUs,
|
|
114
|
+
cwd,
|
|
104
115
|
);
|
|
105
116
|
store.clear();
|
|
106
117
|
asyncStore.clear();
|
|
@@ -133,6 +144,7 @@ export async function start(options?: StartOptions): Promise<void> {
|
|
|
133
144
|
|
|
134
145
|
await postAsync("Profiler.start");
|
|
135
146
|
profiling = true;
|
|
147
|
+
startHrtime = process.hrtime();
|
|
136
148
|
|
|
137
149
|
if (options?.trackAsync) {
|
|
138
150
|
asyncTracker = new AsyncTracker(resolver, asyncStore);
|
|
@@ -159,6 +171,7 @@ export async function clear(): Promise<void> {
|
|
|
159
171
|
postSync("Profiler.disable");
|
|
160
172
|
profiling = false;
|
|
161
173
|
}
|
|
174
|
+
startHrtime = null;
|
|
162
175
|
store.clear();
|
|
163
176
|
if (asyncTracker) {
|
|
164
177
|
asyncTracker.disable();
|
package/src/types.ts
CHANGED
|
@@ -41,6 +41,7 @@ export interface FileEntry extends ReportEntry {
|
|
|
41
41
|
|
|
42
42
|
export interface PackageEntry extends ReportEntry {
|
|
43
43
|
isFirstParty: boolean;
|
|
44
|
+
depChain?: string[];
|
|
44
45
|
files: FileEntry[];
|
|
45
46
|
otherCount: number;
|
|
46
47
|
}
|
|
@@ -52,6 +53,7 @@ export interface ReportData {
|
|
|
52
53
|
otherCount: number;
|
|
53
54
|
projectName: string;
|
|
54
55
|
totalAsyncTimeUs?: number;
|
|
56
|
+
wallTimeUs?: number;
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
/**
|