@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.
@@ -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
+ }
@@ -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 (totalAsyncTimeUs > 0) {
244
- result.totalAsyncTimeUs = 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;
@@ -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(' &gt; ')}</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, '&#39;');
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(' &gt; ') + '</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 threshold = data.totalTimeUs * (pct / 100);
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
- for (var i = 0; i < data.packages.length; i++) {
340
- var pkg = data.packages[i];
341
- if (pkg.timeUs < threshold) {
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
- for (var j = 0; j < pkg.files.length; j++) {
350
- var file = pkg.files[j];
351
- if (file.timeUs < threshold) {
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
- for (var k = 0; k < file.functions.length; k++) {
360
- var fn = file.functions[k];
361
- if (fn.timeUs < threshold) {
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 pctVal = totalTimeUs > 0 ? (pkg.timeUs / totalTimeUs) * 100 : 0;
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(pkg.timeUs, totalTimeUs)) + '</span>' +
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>Wall Time</th><th>% of Total</th><th>Samples</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 &middot; ' + 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 += '<span class="tree-stats">' + escapeHtml(formatTime(pkg.timeUs)) + ' &middot; ' + escapeHtml(formatPct(pkg.timeUs, totalTimeUs)) + ' &middot; ' + pkg.sampleCount + ' samples</span>';
541
+ html += depChainHtml(pkg.depChain);
542
+ html += '<span class="tree-stats">' + escapeHtml(formatTime(pkgTime)) + ' &middot; ' + escapeHtml(formatPct(pkgTime, pctTotal)) + ' &middot; ' + 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(file.timeUs)) + ' &middot; ' + escapeHtml(formatPct(file.timeUs, totalTimeUs)) + ' &middot; ' + file.sampleCount + ' samples</span>';
552
+ html += '<span class="tree-stats">' + escapeHtml(formatTime(fileTime)) + ' &middot; ' + escapeHtml(formatPct(fileTime, pctTotal)) + ' &middot; ' + 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(fn.timeUs)) + ' &middot; ' + escapeHtml(formatPct(fn.timeUs, totalTimeUs)) + ' &middot; ' + fn.sampleCount + ' samples</span>';
562
+ html += ' <span class="tree-stats">' + escapeHtml(formatTime(fnTime)) + ' &middot; ' + escapeHtml(formatPct(fnTime, pctTotal)) + ' &middot; ' + 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>Wall Time</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))} &middot; ${escapeHtml(formatPct(pkg.timeUs, totalTimeUs))} &middot; ${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
- let metaLine = `Generated ${escapeHtml(data.timestamp)} &middot; Total wall time: ${totalFormatted}`;
772
+ const wallFormatted = data.wallTimeUs ? escapeHtml(formatTime(data.wallTimeUs)) : null;
773
+ let metaLine = `Generated ${escapeHtml(data.timestamp)}`;
774
+ if (wallFormatted) {
775
+ metaLine += ` &middot; Wall time: ${wallFormatted}`;
776
+ }
777
+ metaLine += ` &middot; CPU time: ${totalFormatted}`;
661
778
  if (hasAsync) {
662
- metaLine += ` &middot; Total async wait: ${escapeHtml(formatTime(data.totalAsyncTimeUs!))}`;
779
+ metaLine += ` &middot; 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 projectName = readProjectName(process.cwd());
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
  /**