@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/dist/index.js CHANGED
@@ -11,6 +11,9 @@ import { fileURLToPath } from "node:url";
11
11
  * Tracks the time between async resource init (when the I/O op is started)
12
12
  * and the first before callback (when the callback fires), attributing
13
13
  * that wait time to the package/file/function that initiated the operation.
14
+ *
15
+ * Intervals are buffered and merged at disable() time so that overlapping
16
+ * concurrent I/O is not double-counted.
14
17
  */
15
18
  /** Async resource types worth tracking — I/O and timers, not promises. */
16
19
  const TRACKED_TYPES = new Set([
@@ -31,6 +34,37 @@ const TRACKED_TYPES = new Set([
31
34
  "Timeout"
32
35
  ]);
33
36
  /**
37
+ * Merge overlapping or adjacent intervals. Returns a new sorted array
38
+ * of non-overlapping intervals.
39
+ */
40
+ function mergeIntervals(intervals) {
41
+ if (intervals.length <= 1) return intervals.slice();
42
+ const sorted = intervals.slice().sort((a, b) => a.startUs - b.startUs);
43
+ const merged = [{ ...sorted[0] }];
44
+ for (let i = 1; i < sorted.length; i++) {
45
+ const current = sorted[i];
46
+ const last = merged[merged.length - 1];
47
+ if (current.startUs <= last.endUs) {
48
+ if (current.endUs > last.endUs) last.endUs = current.endUs;
49
+ } else merged.push({ ...current });
50
+ }
51
+ return merged;
52
+ }
53
+ /**
54
+ * Sum the durations of a list of (presumably non-overlapping) intervals.
55
+ */
56
+ function sumIntervals(intervals) {
57
+ let total = 0;
58
+ for (const iv of intervals) total += iv.endUs - iv.startUs;
59
+ return total;
60
+ }
61
+ /**
62
+ * Convert an hrtime tuple to absolute microseconds.
63
+ */
64
+ function hrtimeToUs(hr) {
65
+ return hr[0] * 1e6 + Math.round(hr[1] / 1e3);
66
+ }
67
+ /**
34
68
  * Parse a single line from an Error().stack trace into file path and function id.
35
69
  * Returns null for lines that don't match V8's stack frame format or are node internals.
36
70
  *
@@ -54,22 +88,34 @@ function parseStackLine(line) {
54
88
  }
55
89
  var AsyncTracker = class {
56
90
  resolver;
57
- store;
58
91
  thresholdUs;
59
92
  hook = null;
60
93
  pending = /* @__PURE__ */ new Map();
94
+ /** Buffered intervals keyed by "pkg\0file\0fn" */
95
+ keyedIntervals = /* @__PURE__ */ new Map();
96
+ /** Flat list of all intervals for global merging */
97
+ globalIntervals = [];
98
+ /** Origin time in absolute microseconds, set when enable() is called */
99
+ originUs = 0;
100
+ /** Merged global total set after flush() */
101
+ _mergedTotalUs = 0;
61
102
  /**
62
103
  * @param resolver - PackageResolver for mapping file paths to packages
63
- * @param store - SampleStore to record async wait times into
104
+ * @param store - SampleStore to record async wait times into (used at flush time)
64
105
  * @param thresholdUs - Minimum wait duration in microseconds to record (default 1000 = 1ms)
65
106
  */
66
107
  constructor(resolver, store, thresholdUs = 1e3) {
67
- this.resolver = resolver;
68
108
  this.store = store;
109
+ this.resolver = resolver;
69
110
  this.thresholdUs = thresholdUs;
70
111
  }
112
+ /** Merged global async total in microseconds, available after disable(). */
113
+ get mergedTotalUs() {
114
+ return this._mergedTotalUs;
115
+ }
71
116
  enable() {
72
117
  if (this.hook) return;
118
+ this.originUs = hrtimeToUs(process.hrtime());
73
119
  this.hook = createHook({
74
120
  init: (asyncId, type) => {
75
121
  if (!TRACKED_TYPES.has(type)) return;
@@ -102,9 +148,23 @@ var AsyncTracker = class {
102
148
  before: (asyncId) => {
103
149
  const op = this.pending.get(asyncId);
104
150
  if (!op) return;
105
- const elapsed = process.hrtime(op.startHrtime);
106
- const durationUs = elapsed[0] * 1e6 + Math.round(elapsed[1] / 1e3);
107
- if (durationUs >= this.thresholdUs) this.store.record(op.pkg, op.file, op.fn, durationUs);
151
+ const endHr = process.hrtime();
152
+ const startUs = hrtimeToUs(op.startHrtime);
153
+ const endUs = hrtimeToUs(endHr);
154
+ if (endUs - startUs >= this.thresholdUs) {
155
+ const interval = {
156
+ startUs,
157
+ endUs
158
+ };
159
+ const key = `${op.pkg}\0${op.file}\0${op.fn}`;
160
+ let arr = this.keyedIntervals.get(key);
161
+ if (!arr) {
162
+ arr = [];
163
+ this.keyedIntervals.set(key, arr);
164
+ }
165
+ arr.push(interval);
166
+ this.globalIntervals.push(interval);
167
+ }
108
168
  this.pending.delete(asyncId);
109
169
  },
110
170
  destroy: (asyncId) => {
@@ -116,19 +176,43 @@ var AsyncTracker = class {
116
176
  disable() {
117
177
  if (!this.hook) return;
118
178
  this.hook.disable();
119
- const now = process.hrtime();
179
+ const nowUs = hrtimeToUs(process.hrtime());
120
180
  for (const [, op] of this.pending) {
121
- let secs = now[0] - op.startHrtime[0];
122
- let nanos = now[1] - op.startHrtime[1];
123
- if (nanos < 0) {
124
- secs -= 1;
125
- nanos += 1e9;
181
+ const startUs = hrtimeToUs(op.startHrtime);
182
+ if (nowUs - startUs >= this.thresholdUs) {
183
+ const interval = {
184
+ startUs,
185
+ endUs: nowUs
186
+ };
187
+ const key = `${op.pkg}\0${op.file}\0${op.fn}`;
188
+ let arr = this.keyedIntervals.get(key);
189
+ if (!arr) {
190
+ arr = [];
191
+ this.keyedIntervals.set(key, arr);
192
+ }
193
+ arr.push(interval);
194
+ this.globalIntervals.push(interval);
126
195
  }
127
- const durationUs = secs * 1e6 + Math.round(nanos / 1e3);
128
- if (durationUs >= this.thresholdUs) this.store.record(op.pkg, op.file, op.fn, durationUs);
129
196
  }
130
197
  this.pending.clear();
131
198
  this.hook = null;
199
+ this.flush();
200
+ }
201
+ /**
202
+ * Merge buffered intervals and record to the store.
203
+ * Sets mergedTotalUs to the global merged duration.
204
+ */
205
+ flush() {
206
+ for (const [key, intervals] of this.keyedIntervals) {
207
+ const totalUs = sumIntervals(mergeIntervals(intervals));
208
+ if (totalUs > 0) {
209
+ const parts = key.split("\0");
210
+ this.store.record(parts[0], parts[1], parts[2], totalUs);
211
+ }
212
+ }
213
+ this._mergedTotalUs = sumIntervals(mergeIntervals(this.globalIntervals));
214
+ this.keyedIntervals.clear();
215
+ this.globalIntervals = [];
132
216
  }
133
217
  };
134
218
 
@@ -279,6 +363,10 @@ function escapeHtml(str) {
279
363
 
280
364
  //#endregion
281
365
  //#region src/reporter/html.ts
366
+ function formatDepChain(depChain) {
367
+ if (!depChain || depChain.length === 0) return "";
368
+ return `<span class="dep-chain">via ${depChain.map((n) => escapeHtml(n)).join(" &gt; ")}</span>`;
369
+ }
282
370
  function generateCss() {
283
371
  return `
284
372
  :root {
@@ -422,6 +510,7 @@ function generateCss() {
422
510
  }
423
511
 
424
512
  td.pkg-name { font-family: var(--font-mono); font-size: 0.85rem; }
513
+ .dep-chain { display: block; font-size: 0.7rem; color: var(--muted); font-family: var(--font-sans); }
425
514
  td.numeric { text-align: right; font-family: var(--font-mono); font-size: 0.85rem; }
426
515
  td.async-col { color: var(--bar-fill-async); }
427
516
 
@@ -565,9 +654,59 @@ function generateCss() {
565
654
  .other-item.indent-1 { padding-left: 2rem; }
566
655
  .other-item.indent-2 { padding-left: 3.25rem; }
567
656
 
657
+ /* Sort control */
658
+ .sort-control {
659
+ display: inline-flex;
660
+ align-items: center;
661
+ gap: 0.5rem;
662
+ margin-left: 1.5rem;
663
+ font-size: 0.85rem;
664
+ }
665
+
666
+ .sort-control label {
667
+ font-weight: 600;
668
+ color: var(--muted);
669
+ text-transform: uppercase;
670
+ letter-spacing: 0.04em;
671
+ font-size: 0.8rem;
672
+ }
673
+
674
+ .sort-toggle {
675
+ display: inline-flex;
676
+ border: 1px solid var(--border);
677
+ border-radius: 4px;
678
+ overflow: hidden;
679
+ }
680
+
681
+ .sort-toggle button {
682
+ font-family: var(--font-sans);
683
+ font-size: 0.8rem;
684
+ padding: 0.25rem 0.6rem;
685
+ border: none;
686
+ background: #fff;
687
+ color: var(--muted);
688
+ cursor: pointer;
689
+ transition: background 0.15s, color 0.15s;
690
+ }
691
+
692
+ .sort-toggle button + button {
693
+ border-left: 1px solid var(--border);
694
+ }
695
+
696
+ .sort-toggle button.active {
697
+ background: var(--bar-fill);
698
+ color: #fff;
699
+ }
700
+
701
+ .sort-toggle button.active-async {
702
+ background: var(--bar-fill-async);
703
+ color: #fff;
704
+ }
705
+
568
706
  @media (max-width: 600px) {
569
707
  body { padding: 1rem; }
570
708
  .bar-cell { width: 25%; }
709
+ .sort-control { margin-left: 0; margin-top: 0.5rem; }
571
710
  }
572
711
  `;
573
712
  }
@@ -600,14 +739,32 @@ function generateJs() {
600
739
  .replace(/'/g, '&#39;');
601
740
  }
602
741
 
742
+ function depChainHtml(depChain) {
743
+ if (!depChain || depChain.length === 0) return '';
744
+ return '<span class="dep-chain">via ' + depChain.map(function(n) { return escapeHtml(n); }).join(' &gt; ') + '</span>';
745
+ }
746
+
747
+ var sortBy = 'cpu';
748
+
749
+ function metricTime(entry) {
750
+ return sortBy === 'async' ? (entry.asyncTimeUs || 0) : entry.timeUs;
751
+ }
752
+
753
+ function sortDesc(arr) {
754
+ return arr.slice().sort(function(a, b) { return metricTime(b) - metricTime(a); });
755
+ }
756
+
603
757
  function applyThreshold(data, pct) {
604
- var threshold = data.totalTimeUs * (pct / 100);
758
+ var totalBase = sortBy === 'async' ? (data.totalAsyncTimeUs || 0) : data.totalTimeUs;
759
+ var threshold = totalBase * (pct / 100);
605
760
  var filtered = [];
606
761
  var otherCount = 0;
607
762
 
608
- for (var i = 0; i < data.packages.length; i++) {
609
- var pkg = data.packages[i];
610
- if (pkg.timeUs < threshold) {
763
+ var pkgs = sortDesc(data.packages);
764
+
765
+ for (var i = 0; i < pkgs.length; i++) {
766
+ var pkg = pkgs[i];
767
+ if (metricTime(pkg) < threshold) {
611
768
  otherCount++;
612
769
  continue;
613
770
  }
@@ -615,9 +772,11 @@ function generateJs() {
615
772
  var files = [];
616
773
  var fileOtherCount = 0;
617
774
 
618
- for (var j = 0; j < pkg.files.length; j++) {
619
- var file = pkg.files[j];
620
- if (file.timeUs < threshold) {
775
+ var sortedFiles = sortDesc(pkg.files);
776
+
777
+ for (var j = 0; j < sortedFiles.length; j++) {
778
+ var file = sortedFiles[j];
779
+ if (metricTime(file) < threshold) {
621
780
  fileOtherCount++;
622
781
  continue;
623
782
  }
@@ -625,9 +784,11 @@ function generateJs() {
625
784
  var functions = [];
626
785
  var funcOtherCount = 0;
627
786
 
628
- for (var k = 0; k < file.functions.length; k++) {
629
- var fn = file.functions[k];
630
- if (fn.timeUs < threshold) {
787
+ var sortedFns = sortDesc(file.functions);
788
+
789
+ for (var k = 0; k < sortedFns.length; k++) {
790
+ var fn = sortedFns[k];
791
+ if (metricTime(fn) < threshold) {
631
792
  funcOtherCount++;
632
793
  continue;
633
794
  }
@@ -653,6 +814,7 @@ function generateJs() {
653
814
  pct: pkg.pct,
654
815
  isFirstParty: pkg.isFirstParty,
655
816
  sampleCount: pkg.sampleCount,
817
+ depChain: pkg.depChain,
656
818
  asyncTimeUs: pkg.asyncTimeUs,
657
819
  asyncPct: pkg.asyncPct,
658
820
  asyncOpCount: pkg.asyncOpCount,
@@ -664,18 +826,21 @@ function generateJs() {
664
826
  return { packages: filtered, otherCount: otherCount };
665
827
  }
666
828
 
667
- function renderTable(packages, otherCount, totalTimeUs) {
829
+ function renderTable(packages, otherCount, totalTimeUs, totalAsyncTimeUs) {
668
830
  var rows = '';
831
+ var isAsync = sortBy === 'async';
832
+ var barTotal = isAsync ? (totalAsyncTimeUs || 0) : totalTimeUs;
669
833
  for (var i = 0; i < packages.length; i++) {
670
834
  var pkg = packages[i];
671
835
  var cls = pkg.isFirstParty ? 'first-party' : 'dependency';
672
- var pctVal = totalTimeUs > 0 ? (pkg.timeUs / totalTimeUs) * 100 : 0;
836
+ var barVal = isAsync ? (pkg.asyncTimeUs || 0) : pkg.timeUs;
837
+ var pctVal = barTotal > 0 ? (barVal / barTotal) * 100 : 0;
673
838
  rows += '<tr class="' + cls + '">' +
674
- '<td class="pkg-name">' + escapeHtml(pkg.name) + '</td>' +
839
+ '<td class="pkg-name">' + escapeHtml(pkg.name) + depChainHtml(pkg.depChain) + '</td>' +
675
840
  '<td class="numeric">' + escapeHtml(formatTime(pkg.timeUs)) + '</td>' +
676
841
  '<td class="bar-cell"><div class="bar-container">' +
677
842
  '<div class="bar-track"><div class="bar-fill" style="width:' + pctVal.toFixed(1) + '%"></div></div>' +
678
- '<span class="bar-pct">' + escapeHtml(formatPct(pkg.timeUs, totalTimeUs)) + '</span>' +
843
+ '<span class="bar-pct">' + escapeHtml(formatPct(barVal, barTotal)) + '</span>' +
679
844
  '</div></td>' +
680
845
  '<td class="numeric">' + pkg.sampleCount + '</td>';
681
846
  if (HAS_ASYNC) {
@@ -697,9 +862,9 @@ function generateJs() {
697
862
  rows += '</tr>';
698
863
  }
699
864
 
700
- var headers = '<th>Package</th><th>Wall Time</th><th>% of Total</th><th>Samples</th>';
865
+ var headers = '<th>Package</th><th>CPU Time</th><th>% of Total</th><th>Samples</th>';
701
866
  if (HAS_ASYNC) {
702
- headers += '<th>Async Wait</th><th>Async Ops</th>';
867
+ headers += '<th>Async I/O Wait</th><th>Async Ops</th>';
703
868
  }
704
869
 
705
870
  return '<table><thead><tr>' + headers + '</tr></thead><tbody>' + rows + '</tbody></table>';
@@ -713,34 +878,40 @@ function generateJs() {
713
878
  return ' <span class="tree-async">| ' + escapeHtml(formatTime(at)) + ' async &middot; ' + ac + ' ops</span>';
714
879
  }
715
880
 
716
- function renderTree(packages, otherCount, totalTimeUs) {
881
+ function renderTree(packages, otherCount, totalTimeUs, totalAsyncTimeUs) {
717
882
  var html = '<div class="tree">';
883
+ var isAsync = sortBy === 'async';
884
+ var pctTotal = isAsync ? (totalAsyncTimeUs || 0) : totalTimeUs;
718
885
 
719
886
  for (var i = 0; i < packages.length; i++) {
720
887
  var pkg = packages[i];
721
888
  var fpCls = pkg.isFirstParty ? ' fp-pkg' : '';
889
+ var pkgTime = isAsync ? (pkg.asyncTimeUs || 0) : pkg.timeUs;
722
890
  html += '<details class="level-0' + fpCls + '"><summary>';
723
891
  html += '<span class="tree-label pkg">pkg</span>';
724
892
  html += '<span class="tree-name">' + escapeHtml(pkg.name) + '</span>';
725
- html += '<span class="tree-stats">' + escapeHtml(formatTime(pkg.timeUs)) + ' &middot; ' + escapeHtml(formatPct(pkg.timeUs, totalTimeUs)) + ' &middot; ' + pkg.sampleCount + ' samples</span>';
893
+ html += depChainHtml(pkg.depChain);
894
+ html += '<span class="tree-stats">' + escapeHtml(formatTime(pkgTime)) + ' &middot; ' + escapeHtml(formatPct(pkgTime, pctTotal)) + ' &middot; ' + pkg.sampleCount + ' samples</span>';
726
895
  html += asyncStats(pkg);
727
896
  html += '</summary>';
728
897
 
729
898
  for (var j = 0; j < pkg.files.length; j++) {
730
899
  var file = pkg.files[j];
900
+ var fileTime = isAsync ? (file.asyncTimeUs || 0) : file.timeUs;
731
901
  html += '<details class="level-1"><summary>';
732
902
  html += '<span class="tree-label file">file</span>';
733
903
  html += '<span class="tree-name">' + escapeHtml(file.name) + '</span>';
734
- html += '<span class="tree-stats">' + escapeHtml(formatTime(file.timeUs)) + ' &middot; ' + escapeHtml(formatPct(file.timeUs, totalTimeUs)) + ' &middot; ' + file.sampleCount + ' samples</span>';
904
+ html += '<span class="tree-stats">' + escapeHtml(formatTime(fileTime)) + ' &middot; ' + escapeHtml(formatPct(fileTime, pctTotal)) + ' &middot; ' + file.sampleCount + ' samples</span>';
735
905
  html += asyncStats(file);
736
906
  html += '</summary>';
737
907
 
738
908
  for (var k = 0; k < file.functions.length; k++) {
739
909
  var fn = file.functions[k];
910
+ var fnTime = isAsync ? (fn.asyncTimeUs || 0) : fn.timeUs;
740
911
  html += '<div class="level-2">';
741
912
  html += '<span class="tree-label fn">fn</span> ';
742
913
  html += '<span class="tree-name">' + escapeHtml(fn.name) + '</span>';
743
- html += ' <span class="tree-stats">' + escapeHtml(formatTime(fn.timeUs)) + ' &middot; ' + escapeHtml(formatPct(fn.timeUs, totalTimeUs)) + ' &middot; ' + fn.sampleCount + ' samples</span>';
914
+ html += ' <span class="tree-stats">' + escapeHtml(formatTime(fnTime)) + ' &middot; ' + escapeHtml(formatPct(fnTime, pctTotal)) + ' &middot; ' + fn.sampleCount + ' samples</span>';
744
915
  html += asyncStats(fn);
745
916
  html += '</div>';
746
917
  }
@@ -767,12 +938,26 @@ function generateJs() {
767
938
  return html;
768
939
  }
769
940
 
941
+ var currentThreshold = 5;
942
+
770
943
  function update(pct) {
944
+ currentThreshold = pct;
771
945
  var result = applyThreshold(DATA, pct);
772
946
  var summaryEl = document.getElementById('summary-container');
773
947
  var treeEl = document.getElementById('tree-container');
774
- if (summaryEl) summaryEl.innerHTML = renderTable(result.packages, result.otherCount, DATA.totalTimeUs);
775
- if (treeEl) treeEl.innerHTML = renderTree(result.packages, result.otherCount, DATA.totalTimeUs);
948
+ if (summaryEl) summaryEl.innerHTML = renderTable(result.packages, result.otherCount, DATA.totalTimeUs, DATA.totalAsyncTimeUs);
949
+ if (treeEl) treeEl.innerHTML = renderTree(result.packages, result.otherCount, DATA.totalTimeUs, DATA.totalAsyncTimeUs);
950
+ }
951
+
952
+ function updateSortButtons() {
953
+ var btns = document.querySelectorAll('.sort-toggle button');
954
+ for (var i = 0; i < btns.length; i++) {
955
+ var btn = btns[i];
956
+ btn.className = '';
957
+ if (btn.getAttribute('data-sort') === sortBy) {
958
+ btn.className = sortBy === 'async' ? 'active-async' : 'active';
959
+ }
960
+ }
776
961
  }
777
962
 
778
963
  document.addEventListener('DOMContentLoaded', function() {
@@ -786,6 +971,15 @@ function generateJs() {
786
971
  update(val);
787
972
  });
788
973
  }
974
+
975
+ var sortBtns = document.querySelectorAll('.sort-toggle button');
976
+ for (var i = 0; i < sortBtns.length; i++) {
977
+ sortBtns[i].addEventListener('click', function() {
978
+ sortBy = this.getAttribute('data-sort') || 'cpu';
979
+ updateSortButtons();
980
+ update(currentThreshold);
981
+ });
982
+ }
789
983
  });
790
984
  })();
791
985
  `;
@@ -797,7 +991,7 @@ function renderSummaryTable(packages, otherCount, totalTimeUs, hasAsync) {
797
991
  const pctVal = totalTimeUs > 0 ? pkg.timeUs / totalTimeUs * 100 : 0;
798
992
  rows += `
799
993
  <tr class="${cls}">
800
- <td class="pkg-name">${escapeHtml(pkg.name)}</td>
994
+ <td class="pkg-name">${escapeHtml(pkg.name)}${formatDepChain(pkg.depChain)}</td>
801
995
  <td class="numeric">${escapeHtml(formatTime(pkg.timeUs))}</td>
802
996
  <td class="bar-cell">
803
997
  <div class="bar-container">
@@ -824,10 +1018,10 @@ function renderSummaryTable(packages, otherCount, totalTimeUs, hasAsync) {
824
1018
  <thead>
825
1019
  <tr>
826
1020
  <th>Package</th>
827
- <th>Wall Time</th>
1021
+ <th>CPU Time</th>
828
1022
  <th>% of Total</th>
829
1023
  <th>Samples</th>${hasAsync ? `
830
- <th>Async Wait</th>
1024
+ <th>Async I/O Wait</th>
831
1025
  <th>Async Ops</th>` : ""}
832
1026
  </tr>
833
1027
  </thead>
@@ -849,6 +1043,7 @@ function renderTree(packages, otherCount, totalTimeUs, hasAsync) {
849
1043
  html += `<summary>`;
850
1044
  html += `<span class="tree-label pkg">pkg</span>`;
851
1045
  html += `<span class="tree-name">${escapeHtml(pkg.name)}</span>`;
1046
+ html += formatDepChain(pkg.depChain);
852
1047
  html += `<span class="tree-stats">${escapeHtml(formatTime(pkg.timeUs))} &middot; ${escapeHtml(formatPct(pkg.timeUs, totalTimeUs))} &middot; ${pkg.sampleCount} samples</span>`;
853
1048
  if (hasAsync) html += formatAsyncStats(pkg);
854
1049
  html += `</summary>`;
@@ -890,8 +1085,11 @@ function renderHtml(data) {
890
1085
  const tree = renderTree(data.packages, data.otherCount, data.totalTimeUs, hasAsync);
891
1086
  const totalFormatted = escapeHtml(formatTime(data.totalTimeUs));
892
1087
  const titleName = escapeHtml(data.projectName);
893
- let metaLine = `Generated ${escapeHtml(data.timestamp)} &middot; Total wall time: ${totalFormatted}`;
894
- if (hasAsync) metaLine += ` &middot; Total async wait: ${escapeHtml(formatTime(data.totalAsyncTimeUs))}`;
1088
+ const wallFormatted = data.wallTimeUs ? escapeHtml(formatTime(data.wallTimeUs)) : null;
1089
+ let metaLine = `Generated ${escapeHtml(data.timestamp)}`;
1090
+ if (wallFormatted) metaLine += ` &middot; Wall time: ${wallFormatted}`;
1091
+ metaLine += ` &middot; CPU time: ${totalFormatted}`;
1092
+ if (hasAsync) metaLine += ` &middot; Async I/O wait: ${escapeHtml(formatTime(data.totalAsyncTimeUs))}`;
895
1093
  const safeJson = JSON.stringify(data).replace(/</g, "\\u003c");
896
1094
  return `<!DOCTYPE html>
897
1095
  <html lang="en">
@@ -910,7 +1108,14 @@ function renderHtml(data) {
910
1108
  <div class="threshold-control">
911
1109
  <label>Threshold</label>
912
1110
  <input type="range" id="threshold-slider" min="0" max="20" step="0.5" value="5">
913
- <span id="threshold-value">5.0%</span>
1111
+ <span id="threshold-value">5.0%</span>${hasAsync ? `
1112
+ <span class="sort-control">
1113
+ <label>Sort by</label>
1114
+ <span class="sort-toggle">
1115
+ <button data-sort="cpu" class="active">CPU Time</button>
1116
+ <button data-sort="async">Async I/O Wait</button>
1117
+ </span>
1118
+ </span>` : ""}
914
1119
  </div>
915
1120
  <div id="summary-container">${summaryTable}</div>
916
1121
 
@@ -949,6 +1154,8 @@ var PkgProfile = class {
949
1154
  projectName;
950
1155
  /** Total async wait time in microseconds (undefined when async tracking not enabled) */
951
1156
  totalAsyncTimeUs;
1157
+ /** Elapsed wall time in microseconds from start() to stop() */
1158
+ wallTimeUs;
952
1159
  /** @internal */
953
1160
  constructor(data) {
954
1161
  this.timestamp = data.timestamp;
@@ -957,6 +1164,7 @@ var PkgProfile = class {
957
1164
  this.otherCount = data.otherCount;
958
1165
  this.projectName = data.projectName;
959
1166
  this.totalAsyncTimeUs = data.totalAsyncTimeUs;
1167
+ this.wallTimeUs = data.wallTimeUs;
960
1168
  }
961
1169
  /**
962
1170
  * Write a self-contained HTML report to disk.
@@ -971,7 +1179,8 @@ var PkgProfile = class {
971
1179
  packages: this.packages,
972
1180
  otherCount: this.otherCount,
973
1181
  projectName: this.projectName,
974
- totalAsyncTimeUs: this.totalAsyncTimeUs
1182
+ totalAsyncTimeUs: this.totalAsyncTimeUs,
1183
+ wallTimeUs: this.wallTimeUs
975
1184
  });
976
1185
  let filepath;
977
1186
  if (path) filepath = resolve(path);
@@ -984,6 +1193,73 @@ var PkgProfile = class {
984
1193
  }
985
1194
  };
986
1195
 
1196
+ //#endregion
1197
+ //#region src/dep-chain.ts
1198
+ /**
1199
+ * Resolve dependency chains for transitive npm packages.
1200
+ *
1201
+ * BFS through node_modules package.json files starting from the project's
1202
+ * direct dependencies to find the shortest path to each profiled package.
1203
+ */
1204
+ function readPkgJson(dir, cache) {
1205
+ if (cache.has(dir)) return cache.get(dir);
1206
+ try {
1207
+ const raw = readFileSync(join(dir, "package.json"), "utf-8");
1208
+ const parsed = JSON.parse(raw);
1209
+ cache.set(dir, parsed);
1210
+ return parsed;
1211
+ } catch {
1212
+ cache.set(dir, null);
1213
+ return null;
1214
+ }
1215
+ }
1216
+ function depsOf(pkg) {
1217
+ return [...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.optionalDependencies ?? {})];
1218
+ }
1219
+ /**
1220
+ * Resolve the shortest dependency chain from the project's direct deps
1221
+ * to each of the given package names.
1222
+ *
1223
+ * @param projectRoot - Absolute path to the project root (contains package.json and node_modules/)
1224
+ * @param packageNames - Set of package names that appeared in profiling data
1225
+ * @param maxDepth - Maximum BFS depth to search (default 5)
1226
+ * @returns Map from package name to chain array (e.g. `["express", "qs"]` means project -> express -> qs)
1227
+ */
1228
+ function resolveDependencyChains(projectRoot, packageNames, maxDepth = 5) {
1229
+ const result = /* @__PURE__ */ new Map();
1230
+ const cache = /* @__PURE__ */ new Map();
1231
+ const rootPkg = readPkgJson(projectRoot, cache);
1232
+ if (!rootPkg) return result;
1233
+ const directDeps = new Set(depsOf(rootPkg));
1234
+ for (const name of packageNames) if (directDeps.has(name)) {}
1235
+ const targets = /* @__PURE__ */ new Set();
1236
+ for (const name of packageNames) if (!directDeps.has(name)) targets.add(name);
1237
+ if (targets.size === 0) return result;
1238
+ const visited = /* @__PURE__ */ new Set();
1239
+ const queue = [];
1240
+ for (const dep of directDeps) {
1241
+ queue.push([dep, [dep]]);
1242
+ visited.add(dep);
1243
+ }
1244
+ let qi = 0;
1245
+ while (qi < queue.length && targets.size > 0) {
1246
+ const [pkgName, chain] = queue[qi++];
1247
+ if (chain.length > maxDepth) continue;
1248
+ if (targets.has(pkgName)) {
1249
+ result.set(pkgName, chain.slice(0, -1));
1250
+ targets.delete(pkgName);
1251
+ if (targets.size === 0) break;
1252
+ }
1253
+ const pkg = readPkgJson(join(projectRoot, "node_modules", pkgName), cache);
1254
+ if (!pkg) continue;
1255
+ for (const child of depsOf(pkg)) if (!visited.has(child)) {
1256
+ visited.add(child);
1257
+ queue.push([child, [...chain, child]]);
1258
+ }
1259
+ }
1260
+ return result;
1261
+ }
1262
+
987
1263
  //#endregion
988
1264
  //#region src/reporter/aggregate.ts
989
1265
  /**
@@ -1002,9 +1278,10 @@ function sumStore(store) {
1002
1278
  * @param asyncStore - Optional SampleStore with async wait time data
1003
1279
  * @returns ReportData with all packages sorted desc by time, no threshold applied
1004
1280
  */
1005
- function aggregate(store, projectName, asyncStore) {
1281
+ function aggregate(store, projectName, asyncStore, globalAsyncTimeUs, wallTimeUs, projectRoot) {
1006
1282
  const totalTimeUs = sumStore(store);
1007
1283
  const totalAsyncTimeUs = asyncStore ? sumStore(asyncStore) : 0;
1284
+ const headerAsyncTimeUs = globalAsyncTimeUs ?? totalAsyncTimeUs;
1008
1285
  if (totalTimeUs === 0 && totalAsyncTimeUs === 0) return {
1009
1286
  timestamp: (/* @__PURE__ */ new Date()).toLocaleString(),
1010
1287
  totalTimeUs: 0,
@@ -1101,6 +1378,16 @@ function aggregate(store, projectName, asyncStore) {
1101
1378
  }
1102
1379
  packages.push(pkgEntry);
1103
1380
  }
1381
+ if (projectRoot) {
1382
+ const thirdPartyNames = new Set(packages.filter((p) => !p.isFirstParty).map((p) => p.name));
1383
+ if (thirdPartyNames.size > 0) {
1384
+ const chains = resolveDependencyChains(projectRoot, thirdPartyNames);
1385
+ for (const pkg of packages) {
1386
+ const chain = chains.get(pkg.name);
1387
+ if (chain && chain.length > 0) pkg.depChain = chain;
1388
+ }
1389
+ }
1390
+ }
1104
1391
  packages.sort((a, b) => b.timeUs - a.timeUs);
1105
1392
  const result = {
1106
1393
  timestamp: (/* @__PURE__ */ new Date()).toLocaleString(),
@@ -1109,7 +1396,8 @@ function aggregate(store, projectName, asyncStore) {
1109
1396
  otherCount: 0,
1110
1397
  projectName
1111
1398
  };
1112
- if (totalAsyncTimeUs > 0) result.totalAsyncTimeUs = totalAsyncTimeUs;
1399
+ if (headerAsyncTimeUs > 0) result.totalAsyncTimeUs = headerAsyncTimeUs;
1400
+ if (wallTimeUs !== void 0) result.wallTimeUs = wallTimeUs;
1113
1401
  return result;
1114
1402
  }
1115
1403
 
@@ -1193,6 +1481,7 @@ var SampleStore = class {
1193
1481
  //#region src/sampler.ts
1194
1482
  let session = null;
1195
1483
  let profiling = false;
1484
+ let startHrtime = null;
1196
1485
  const store = new SampleStore();
1197
1486
  const asyncStore = new SampleStore();
1198
1487
  const resolver = new PackageResolver(process.cwd());
@@ -1249,15 +1538,21 @@ function buildEmptyProfile() {
1249
1538
  */
1250
1539
  function stopSync() {
1251
1540
  if (!profiling || !session) return buildEmptyProfile();
1541
+ const elapsed = startHrtime ? process.hrtime(startHrtime) : null;
1542
+ const wallTimeUs = elapsed ? elapsed[0] * 1e6 + Math.round(elapsed[1] / 1e3) : void 0;
1543
+ startHrtime = null;
1252
1544
  const { profile } = postSync("Profiler.stop");
1253
1545
  postSync("Profiler.disable");
1254
1546
  profiling = false;
1547
+ let globalAsyncTimeUs;
1255
1548
  if (asyncTracker) {
1256
1549
  asyncTracker.disable();
1550
+ globalAsyncTimeUs = asyncTracker.mergedTotalUs;
1257
1551
  asyncTracker = null;
1258
1552
  }
1259
1553
  processProfile(profile);
1260
- const data = aggregate(store, readProjectName(process.cwd()), asyncStore.packages.size > 0 ? asyncStore : void 0);
1554
+ const cwd = process.cwd();
1555
+ const data = aggregate(store, readProjectName(cwd), asyncStore.packages.size > 0 ? asyncStore : void 0, globalAsyncTimeUs, wallTimeUs, cwd);
1261
1556
  store.clear();
1262
1557
  asyncStore.clear();
1263
1558
  return new PkgProfile(data);
@@ -1279,6 +1574,7 @@ async function start(options) {
1279
1574
  if (options?.interval !== void 0) await postAsync("Profiler.setSamplingInterval", { interval: options.interval });
1280
1575
  await postAsync("Profiler.start");
1281
1576
  profiling = true;
1577
+ startHrtime = process.hrtime();
1282
1578
  if (options?.trackAsync) {
1283
1579
  asyncTracker = new AsyncTracker(resolver, asyncStore);
1284
1580
  asyncTracker.enable();
@@ -1302,6 +1598,7 @@ async function clear() {
1302
1598
  postSync("Profiler.disable");
1303
1599
  profiling = false;
1304
1600
  }
1601
+ startHrtime = null;
1305
1602
  store.clear();
1306
1603
  if (asyncTracker) {
1307
1604
  asyncTracker.disable();