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