nuxt-devtools-observatory 0.1.31 → 0.1.33

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.
Files changed (44) hide show
  1. package/README.md +79 -46
  2. package/client/.env.example +1 -0
  3. package/client/dist/assets/index-BqKYgjVB.js +20 -0
  4. package/client/dist/assets/index-bs1JBJ2u.css +1 -0
  5. package/client/dist/index.html +2 -2
  6. package/client/src/App.vue +4 -0
  7. package/client/src/components/Flamegraph.vue +7 -8
  8. package/client/src/components/SpanInspector.vue +1 -1
  9. package/client/src/components/TraceFilter.vue +0 -2
  10. package/client/src/components/WaterfallView.vue +1 -1
  11. package/client/src/composables/composable-search.ts +127 -0
  12. package/client/src/composables/trace-render-aggregation.ts +263 -0
  13. package/client/src/composables/useExportImport.ts +63 -0
  14. package/client/src/composables/useTraceFilter.ts +1 -5
  15. package/client/src/composables/useVirtualizationConfig.ts +40 -0
  16. package/client/src/composables/useVirtualizationFlags.ts +129 -0
  17. package/client/src/stores/observatory.ts +9 -1
  18. package/client/src/views/ComposableTracker.vue +273 -97
  19. package/client/src/views/FetchDashboard.vue +181 -16
  20. package/client/src/views/ProvideInjectGraph.vue +41 -18
  21. package/client/src/views/RenderHeatmap.vue +392 -76
  22. package/client/src/views/TraceViewer.vue +797 -14
  23. package/client/src/views/TransitionTimeline.vue +112 -19
  24. package/dist/module.d.mts +5 -0
  25. package/dist/module.json +1 -1
  26. package/dist/module.mjs +12 -23
  27. package/dist/runtime/composables/composable-registry.d.ts +19 -0
  28. package/dist/runtime/composables/composable-registry.js +63 -5
  29. package/dist/runtime/composables/render-registry.js +23 -13
  30. package/dist/runtime/instrumentation/asyncData.d.ts +1 -1
  31. package/dist/runtime/instrumentation/fetch.d.ts +7 -1
  32. package/dist/runtime/instrumentation/fetch.js +22 -1
  33. package/dist/runtime/nitro/fetch-capture.d.ts +1 -2
  34. package/dist/runtime/nitro/fetch-capture.js +85 -7
  35. package/dist/runtime/nitro/ssr-trace-store.d.ts +85 -0
  36. package/dist/runtime/nitro/ssr-trace-store.js +84 -0
  37. package/dist/runtime/plugin.js +48 -1
  38. package/dist/runtime/test-bridge.d.ts +18 -0
  39. package/dist/runtime/test-bridge.js +86 -0
  40. package/dist/runtime/tracing/trace.d.ts +1 -1
  41. package/package.json +18 -3
  42. package/client/.env +0 -17
  43. package/client/dist/assets/index-BuMXDBO9.js +0 -17
  44. package/client/dist/assets/index-CwcspZ6w.css +0 -1
@@ -1,5 +1,8 @@
1
1
  <script setup lang="ts">
2
2
  import { ref, computed } from 'vue'
3
+ import { useVirtualizer } from '@tanstack/vue-virtual'
4
+ import { useVirtualizationConfig } from '@observatory-client/composables/useVirtualizationConfig'
5
+ import { useVirtualizationFlags } from '@observatory-client/composables/useVirtualizationFlags'
3
6
  import { useResizablePane } from '@observatory-client/composables/useResizablePane'
4
7
  import { useObservatoryData } from '@observatory-client/stores/observatory'
5
8
  import type { TransitionEntry } from '@observatory/types/snapshot'
@@ -10,10 +13,25 @@ const { paneWidth: detailWidth, onHandleMouseDown } = useResizablePane(260, 'obs
10
13
  type FilterMode = 'all' | 'cancelled' | 'active' | 'completed'
11
14
  const filter = ref<FilterMode>('all')
12
15
  const search = ref('')
13
- const selected = ref<TransitionEntry | null>(null)
16
+ const selectedId = ref<string | null>(null)
17
+ const tableScrollRef = ref<HTMLElement | null>(null)
18
+
19
+ const { effective: virtualizationFlags } = useVirtualizationFlags()
20
+ const { preset: virtualizationPreset } = useVirtualizationConfig({ rowHeight: 42, overscan: 6 })
21
+
22
+ const entriesSorted = computed(() => [...entries.value].sort((a, b) => a.startTime - b.startTime))
23
+ const entriesById = computed(() => new Map(entries.value.map((entry) => [entry.id, entry] as const)))
24
+
25
+ const selected = computed(() => {
26
+ if (!selectedId.value) {
27
+ return null
28
+ }
29
+
30
+ return entriesById.value.get(selectedId.value) ?? null
31
+ })
14
32
 
15
33
  const filtered = computed(() => {
16
- let list = [...entries.value]
34
+ let list = entriesSorted.value
17
35
 
18
36
  if (search.value) {
19
37
  const q = search.value.toLowerCase()
@@ -28,7 +46,7 @@ const filtered = computed(() => {
28
46
  list = list.filter((e) => e.phase === 'entered' || e.phase === 'left')
29
47
  }
30
48
 
31
- return list.sort((a, b) => a.startTime - b.startTime)
49
+ return list
32
50
  })
33
51
 
34
52
  const stats = computed(() => ({
@@ -64,6 +82,60 @@ const timelineGeometry = computed(() => {
64
82
  }))
65
83
  })
66
84
 
85
+ const virtualizedRowsEnabled = computed(() => virtualizationFlags.value.transitions)
86
+
87
+ const tableVirtualizerOptions = computed(() => ({
88
+ count: filtered.value.length,
89
+ getScrollElement: () => tableScrollRef.value,
90
+ estimateSize: () => virtualizationPreset.value.rowHeight,
91
+ overscan: virtualizationPreset.value.overscan,
92
+ }))
93
+
94
+ const tableVirtualizer = useVirtualizer(tableVirtualizerOptions)
95
+
96
+ const tableVirtualItems = computed(() => {
97
+ if (!virtualizedRowsEnabled.value) {
98
+ return []
99
+ }
100
+
101
+ return tableVirtualizer.value.getVirtualItems()
102
+ })
103
+
104
+ const topTablePadding = computed(() => {
105
+ if (!virtualizedRowsEnabled.value || tableVirtualItems.value.length === 0) {
106
+ return 0
107
+ }
108
+
109
+ return tableVirtualItems.value[0].start
110
+ })
111
+
112
+ const bottomTablePadding = computed(() => {
113
+ if (!virtualizedRowsEnabled.value || tableVirtualItems.value.length === 0) {
114
+ return 0
115
+ }
116
+
117
+ const total = tableVirtualizer.value.getTotalSize()
118
+ const last = tableVirtualItems.value[tableVirtualItems.value.length - 1]
119
+
120
+ return Math.max(0, total - last.end)
121
+ })
122
+
123
+ const visibleRows = computed(() => {
124
+ if (!virtualizedRowsEnabled.value) {
125
+ return filtered.value.map((entry, index) => ({
126
+ entry,
127
+ geometry: timelineGeometry.value[index],
128
+ }))
129
+ }
130
+
131
+ return tableVirtualItems.value
132
+ .map((item) => ({
133
+ entry: filtered.value[item.index],
134
+ geometry: timelineGeometry.value[item.index],
135
+ }))
136
+ .filter((row): row is { entry: TransitionEntry; geometry: { left: number; width: number } } => Boolean(row.entry && row.geometry))
137
+ })
138
+
67
139
  function phaseColor(phase: TransitionEntry['phase']): string {
68
140
  if (phase === 'entering' || phase === 'leaving') {
69
141
  return '#7f77dd'
@@ -166,7 +238,7 @@ function directionColor(e: TransitionEntry): string {
166
238
  <!-- Main content -->
167
239
  <div class="transition-timeline__content tracker-split">
168
240
  <!-- Timeline table -->
169
- <div class="transition-timeline__table tracker-table-wrap">
241
+ <div ref="tableScrollRef" class="transition-timeline__table tracker-table-wrap">
170
242
  <table class="data-table">
171
243
  <thead>
172
244
  <tr>
@@ -180,41 +252,56 @@ function directionColor(e: TransitionEntry): string {
180
252
  </thead>
181
253
  <tbody>
182
254
  <tr
183
- v-for="(entry, i) in filtered"
184
- :key="entry.id"
185
- :class="{ selected: selected?.id === entry.id }"
186
- @click="selected = selected?.id === entry.id ? null : entry"
255
+ v-if="virtualizedRowsEnabled && topTablePadding > 0"
256
+ class="transition-timeline__virtual-spacer-row"
257
+ aria-hidden="true"
258
+ >
259
+ <td colspan="6" :style="{ height: `${topTablePadding}px` }"></td>
260
+ </tr>
261
+ <tr
262
+ v-for="row in visibleRows"
263
+ :key="row.entry.id"
264
+ :class="{ selected: selected?.id === row.entry.id }"
265
+ @click="selectedId = selected?.id === row.entry.id ? null : row.entry.id"
187
266
  >
188
267
  <td>
189
- <span class="transition-timeline__name mono">{{ entry.transitionName }}</span>
268
+ <span class="transition-timeline__name mono">{{ row.entry.transitionName }}</span>
190
269
  </td>
191
270
  <td>
192
- <span class="transition-timeline__direction mono" :style="{ color: directionColor(entry) }">
193
- {{ directionLabel(entry) }}
271
+ <span class="transition-timeline__direction mono" :style="{ color: directionColor(row.entry) }">
272
+ {{ directionLabel(row.entry) }}
194
273
  </span>
195
274
  </td>
196
275
  <td>
197
- <span class="badge" :class="phaseBadgeClass(entry.phase)">{{ entry.phase }}</span>
276
+ <span class="badge" :class="phaseBadgeClass(row.entry.phase)">{{ row.entry.phase }}</span>
198
277
  </td>
199
278
  <td class="transition-timeline__duration mono">
200
- {{ entry.durationMs !== undefined ? entry.durationMs + 'ms' : '—' }}
279
+ {{ row.entry.durationMs !== undefined ? row.entry.durationMs + 'ms' : '—' }}
201
280
  </td>
202
- <td class="transition-timeline__component muted">{{ entry.parentComponent }}</td>
281
+ <td class="transition-timeline__component muted">{{ row.entry.parentComponent }}</td>
203
282
  <td class="transition-timeline__bar-cell">
204
283
  <div class="transition-timeline__bar-track">
205
284
  <div
206
285
  class="transition-timeline__bar-fill"
207
286
  :style="{
208
- left: timelineGeometry[i]?.left + '%',
209
- width: Math.max(timelineGeometry[i]?.width ?? 1, 1) + '%',
210
- background: phaseColor(entry.phase),
211
- opacity: entry.phase === 'entering' || entry.phase === 'leaving' ? '0.55' : '1',
287
+ left: row.geometry.left + '%',
288
+ width: Math.max(row.geometry.width, 1) + '%',
289
+ background: phaseColor(row.entry.phase),
290
+ opacity: row.entry.phase === 'entering' || row.entry.phase === 'leaving' ? '0.55' : '1',
212
291
  }"
213
292
  />
214
293
  </div>
215
294
  </td>
216
295
  </tr>
217
296
 
297
+ <tr
298
+ v-if="virtualizedRowsEnabled && bottomTablePadding > 0"
299
+ class="transition-timeline__virtual-spacer-row"
300
+ aria-hidden="true"
301
+ >
302
+ <td colspan="6" :style="{ height: `${bottomTablePadding}px` }"></td>
303
+ </tr>
304
+
218
305
  <tr v-if="!filtered.length">
219
306
  <td colspan="6" class="tracker-empty-cell">
220
307
  {{
@@ -236,7 +323,7 @@ function directionColor(e: TransitionEntry): string {
236
323
  <aside v-if="selected" class="transition-timeline__detail" :style="{ width: detailWidth + 'px' }">
237
324
  <div class="transition-timeline__detail-header">
238
325
  <span class="transition-timeline__detail-title">{{ selected.transitionName }}</span>
239
- <button class="transition-timeline__close-btn" @click="selected = null">✕</button>
326
+ <button class="transition-timeline__close-btn" @click="selectedId = null">✕</button>
240
327
  </div>
241
328
 
242
329
  <div class="transition-timeline__detail-section">
@@ -392,6 +479,12 @@ function directionColor(e: TransitionEntry): string {
392
479
  border-radius: 0;
393
480
  }
394
481
 
482
+ .transition-timeline__virtual-spacer-row td {
483
+ padding: 0;
484
+ border-bottom: 0;
485
+ background: transparent;
486
+ }
487
+
395
488
  /* ── Timeline bar ────────────────────────────────────────────────────────── */
396
489
  .transition-timeline__bar-cell {
397
490
  width: 200px;
package/dist/module.d.mts CHANGED
@@ -19,6 +19,11 @@ interface ModuleOptions {
19
19
  * @default 10000
20
20
  */
21
21
  maxPayloadBytes?: number;
22
+ /**
23
+ * Number of fetch rows to load per infinite-scroll step in the Fetch Dashboard.
24
+ * @default 20
25
+ */
26
+ fetchPageSize?: number;
22
27
  /**
23
28
  * Maximum number of transition entries to keep in memory
24
29
  * @default 500
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": "^3.0.0 || ^4.0.0"
6
6
  },
7
- "version": "0.1.31",
7
+ "version": "0.1.33",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.2",
10
10
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { defineNuxtModule, createResolver, addVitePlugin, addPlugin, addServerPlugin } from '@nuxt/kit';
1
+ import { defineNuxtModule, createResolver, addVitePlugin, addImports, addPlugin, addServerPlugin } from '@nuxt/kit';
2
2
  import { onDevToolsInitialized, extendServerRpc } from '@nuxt/devtools-kit';
3
3
  import sirv from 'sirv';
4
4
  import { parse } from '@babel/parser';
@@ -209,10 +209,6 @@ function fetchInstrumentPlugin() {
209
209
  plugins: ["typescript"]
210
210
  });
211
211
  let modified = false;
212
- let needsFetchCallHelper = false;
213
- let needsTracedAsyncDataHelper = false;
214
- const hasFetchCallImport = scriptCode.includes("__devFetchCall");
215
- const hasTracedAsyncDataImport = scriptCode.includes("useTracedAsyncData");
216
212
  traverse$1(ast, {
217
213
  CallExpression(path) {
218
214
  if (path.node.__observatoryTransformed) {
@@ -284,7 +280,6 @@ function fetchInstrumentPlugin() {
284
280
  ]);
285
281
  newCall.__observatoryTransformed = true;
286
282
  path.replaceWith(newCall);
287
- needsTracedAsyncDataHelper = true;
288
283
  modified = true;
289
284
  } else {
290
285
  const newCall = t.callExpression(t.identifier("__devFetchCall"), [
@@ -294,7 +289,6 @@ function fetchInstrumentPlugin() {
294
289
  meta
295
290
  ]);
296
291
  newCall.__observatoryTransformed = true;
297
- needsFetchCallHelper = true;
298
292
  path.replaceWith(newCall);
299
293
  modified = true;
300
294
  }
@@ -303,18 +297,12 @@ function fetchInstrumentPlugin() {
303
297
  if (!modified) {
304
298
  return null;
305
299
  }
306
- const fetchImportNames = [needsFetchCallHelper && !hasFetchCallImport ? "__devFetchCall" : ""].filter(Boolean);
307
- const fetchImportStatement = fetchImportNames.length ? `import { ${fetchImportNames.join(", ")} } from 'nuxt-devtools-observatory/runtime/fetch-registry';
308
- ` : "";
309
- const asyncDataImportStatement = needsTracedAsyncDataHelper && !hasTracedAsyncDataImport ? `import { useTracedAsyncData } from 'nuxt-devtools-observatory/runtime/async-data-instrumentation';
310
- ` : "";
311
- const importStatement = fetchImportStatement + asyncDataImportStatement;
312
300
  const output = generate$1(ast, { retainLines: true }, scriptCode);
313
301
  let finalCode;
314
302
  if (isVue) {
315
- finalCode = code.slice(0, scriptStart) + importStatement + output.code + code.slice(scriptStart + scriptCode.length);
303
+ finalCode = code.slice(0, scriptStart) + output.code + code.slice(scriptStart + scriptCode.length);
316
304
  } else {
317
- finalCode = importStatement + output.code;
305
+ finalCode = output.code;
318
306
  }
319
307
  return {
320
308
  code: finalCode,
@@ -537,6 +525,7 @@ const defaults = {
537
525
  heatmapThresholdTime: process.env.OBSERVATORY_HEATMAP_THRESHOLD_TIME ? Number(process.env.OBSERVATORY_HEATMAP_THRESHOLD_TIME) : 1600,
538
526
  maxFetchEntries: process.env.OBSERVATORY_MAX_FETCH_ENTRIES ? Number(process.env.OBSERVATORY_MAX_FETCH_ENTRIES) : 200,
539
527
  maxPayloadBytes: process.env.OBSERVATORY_MAX_PAYLOAD_BYTES ? Number(process.env.OBSERVATORY_MAX_PAYLOAD_BYTES) : 1e4,
528
+ fetchPageSize: process.env.OBSERVATORY_FETCH_PAGE_SIZE ? Number(process.env.OBSERVATORY_FETCH_PAGE_SIZE) : 20,
540
529
  maxTransitions: process.env.OBSERVATORY_MAX_TRANSITIONS ? Number(process.env.OBSERVATORY_MAX_TRANSITIONS) : 500,
541
530
  maxComposableHistory: process.env.OBSERVATORY_MAX_COMPOSABLE_HISTORY ? Number(process.env.OBSERVATORY_MAX_COMPOSABLE_HISTORY) : 50,
542
531
  maxComposableEntries: process.env.OBSERVATORY_MAX_COMPOSABLE_ENTRIES ? Number(process.env.OBSERVATORY_MAX_COMPOSABLE_ENTRIES) : 300,
@@ -576,6 +565,7 @@ const module$1 = defineNuxtModule({
576
565
  heatmapThresholdTime: options.heatmapThresholdTime ?? (process.env.OBSERVATORY_HEATMAP_THRESHOLD_TIME ? Number(process.env.OBSERVATORY_HEATMAP_THRESHOLD_TIME) : 1600),
577
566
  maxFetchEntries: options.maxFetchEntries ?? (process.env.OBSERVATORY_MAX_FETCH_ENTRIES ? Number(process.env.OBSERVATORY_MAX_FETCH_ENTRIES) : 200),
578
567
  maxPayloadBytes: options.maxPayloadBytes ?? (process.env.OBSERVATORY_MAX_PAYLOAD_BYTES ? Number(process.env.OBSERVATORY_MAX_PAYLOAD_BYTES) : 1e4),
568
+ fetchPageSize: options.fetchPageSize ?? (process.env.OBSERVATORY_FETCH_PAGE_SIZE ? Number(process.env.OBSERVATORY_FETCH_PAGE_SIZE) : 20),
579
569
  maxTransitions: options.maxTransitions ?? (process.env.OBSERVATORY_MAX_TRANSITIONS ? Number(process.env.OBSERVATORY_MAX_TRANSITIONS) : 500),
580
570
  maxComposableHistory: options.maxComposableHistory ?? (process.env.OBSERVATORY_MAX_COMPOSABLE_HISTORY ? Number(process.env.OBSERVATORY_MAX_COMPOSABLE_HISTORY) : 50),
581
571
  maxComposableEntries: options.maxComposableEntries ?? (process.env.OBSERVATORY_MAX_COMPOSABLE_ENTRIES ? Number(process.env.OBSERVATORY_MAX_COMPOSABLE_ENTRIES) : 300),
@@ -599,6 +589,10 @@ const module$1 = defineNuxtModule({
599
589
  const vitePluginScope = resolved.instrumentServer ? { server: true, client: true } : { server: false, client: true };
600
590
  if (resolved.fetchDashboard) {
601
591
  addVitePlugin(fetchInstrumentPlugin(), vitePluginScope);
592
+ addImports([
593
+ { name: "__devFetchCall", from: resolver.resolve("./runtime/composables/fetch-registry") },
594
+ { name: "useTracedAsyncData", from: resolver.resolve("./runtime/instrumentation/asyncData") }
595
+ ]);
602
596
  }
603
597
  if (resolved.provideInjectGraph) {
604
598
  addVitePlugin(provideInjectPlugin(), vitePluginScope);
@@ -612,7 +606,7 @@ const module$1 = defineNuxtModule({
612
606
  if (resolved.fetchDashboard || resolved.provideInjectGraph || resolved.composableTracker || resolved.renderHeatmap || resolved.transitionTracker) {
613
607
  addPlugin(resolver.resolve("./runtime/plugin"));
614
608
  }
615
- if (resolved.fetchDashboard) {
609
+ if (resolved.fetchDashboard || resolved.traceViewer && resolved.instrumentServer) {
616
610
  addServerPlugin(resolver.resolve("./runtime/nitro/fetch-capture"));
617
611
  }
618
612
  const base = "/__observatory";
@@ -634,6 +628,7 @@ const module$1 = defineNuxtModule({
634
628
  provideInjectGraph: !!resolved.provideInjectGraph,
635
629
  composableTracker: !!resolved.composableTracker,
636
630
  composableNavigationMode: resolved.composableNavigationMode,
631
+ fetchPageSize: resolved.fetchPageSize,
637
632
  renderHeatmap: !!resolved.renderHeatmap,
638
633
  transitionTracker: !!resolved.transitionTracker,
639
634
  traceViewer: !!resolved.traceViewer
@@ -691,13 +686,6 @@ const module$1 = defineNuxtModule({
691
686
  );
692
687
  rpc.broadcast.onSnapshot.asEvent(latestSnapshot);
693
688
  }, nuxt);
694
- nuxt.hook("render:response", (response, { url }) => {
695
- if (url.startsWith("/trackers") || url === "/" || url.startsWith("/index.html")) {
696
- const configScript = `<script>window.__observatoryConfig = ${JSON.stringify(nuxt.options.runtimeConfig.public.observatory)};<\/script>`;
697
- response.body = response.body.replace("<head>", `<head>
698
- ${configScript}`);
699
- }
700
- });
701
689
  nuxt.hook("devtools:customTabs", (tabs) => {
702
690
  if (resolved.fetchDashboard || resolved.provideInjectGraph || resolved.composableTracker || resolved.renderHeatmap || resolved.transitionTracker) {
703
691
  tabs.push({
@@ -718,6 +706,7 @@ ${configScript}`);
718
706
  traceViewer: resolved.traceViewer,
719
707
  maxFetchEntries: resolved.maxFetchEntries,
720
708
  maxPayloadBytes: resolved.maxPayloadBytes,
709
+ fetchPageSize: resolved.fetchPageSize,
721
710
  maxTransitions: resolved.maxTransitions,
722
711
  maxComposableHistory: resolved.maxComposableHistory,
723
712
  maxComposableEntries: resolved.maxComposableEntries,
@@ -22,6 +22,11 @@ export interface ComposableEntry {
22
22
  * instances of this composable — indicates module-level (global) state.
23
23
  */
24
24
  sharedKeys: string[];
25
+ /**
26
+ * Stable per-composable-name identity group id for each shared key.
27
+ * Keys are ref/reactive key names; values are group ids like `group-1`.
28
+ */
29
+ sharedKeyGroups?: Record<string, string>;
25
30
  watcherCount: number;
26
31
  intervalCount: number;
27
32
  lifecycle: {
@@ -39,6 +44,19 @@ export interface ComposableEntry {
39
44
  /** Whether this composable is called from a layout component (persists across pages). */
40
45
  isLayoutComposable?: boolean;
41
46
  }
47
+ interface SsrObservatoryEvent {
48
+ context?: {
49
+ __observatoryRequestId?: string;
50
+ __ssrFetchStart?: number;
51
+ };
52
+ }
53
+ export declare function __recordSsrComposableSpan(name: string, meta: {
54
+ file: string;
55
+ line: number;
56
+ }, startTime: number, endTime: number, opts?: {
57
+ error?: unknown;
58
+ event?: SsrObservatoryEvent;
59
+ }): void;
42
60
  /**
43
61
  * Registers a new composable entry, updates an existing one, or retrieves all entries.
44
62
  * @remarks The returned object exposes the following methods:
@@ -78,3 +96,4 @@ export declare function __trackComposable<T>(name: string, callFn: () => T, meta
78
96
  file: string;
79
97
  line: number;
80
98
  }): T;
99
+ export {};
@@ -1,4 +1,32 @@
1
1
  import { isRef, isReactive, isReadonly, unref, computed, watchEffect, getCurrentInstance, onUnmounted } from "vue";
2
+ import { addSsrPhaseSpan } from "../nitro/ssr-trace-store.js";
3
+ function nowMs() {
4
+ return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
5
+ }
6
+ export function __recordSsrComposableSpan(name, meta, startTime, endTime, opts = {}) {
7
+ const eventContext = opts.event?.context ?? globalThis.__observatorySsrContext__;
8
+ const requestId = eventContext?.__observatoryRequestId;
9
+ const requestStart = eventContext?.__ssrFetchStart;
10
+ if (!requestId || typeof requestStart !== "number") {
11
+ return;
12
+ }
13
+ const metadata = {
14
+ file: meta.file,
15
+ line: meta.line,
16
+ phase: "setup"
17
+ };
18
+ if (opts.error instanceof Error) {
19
+ metadata.errorMessage = opts.error.message;
20
+ }
21
+ addSsrPhaseSpan(requestId, {
22
+ name: `composable:${name}`,
23
+ type: "composable",
24
+ startMs: Math.max(startTime - requestStart, 0),
25
+ endMs: Math.max(endTime - requestStart, 0),
26
+ error: !!opts.error,
27
+ metadata
28
+ });
29
+ }
2
30
  export function setupComposableRegistry() {
3
31
  const entries = /* @__PURE__ */ new Map();
4
32
  const liveRefs = /* @__PURE__ */ new Map();
@@ -38,14 +66,30 @@ export function setupComposableRegistry() {
38
66
  }
39
67
  nameCache = /* @__PURE__ */ new Map();
40
68
  sharedKeysCache.set(name, nameCache);
69
+ const identityIds = /* @__PURE__ */ new WeakMap();
70
+ let nextIdentity = 1;
71
+ const getIdentityGroup = (obj) => {
72
+ if (!obj || typeof obj !== "object") {
73
+ return void 0;
74
+ }
75
+ const target = obj;
76
+ const existing = identityIds.get(target);
77
+ if (existing) {
78
+ return existing;
79
+ }
80
+ const created = `group-${nextIdentity++}`;
81
+ identityIds.set(target, created);
82
+ return created;
83
+ };
41
84
  const peers = [...entries.entries()].filter(([, e]) => e.name === name);
42
85
  for (const [eid] of peers) {
43
86
  const ownRaw = rawRefs.get(eid);
44
87
  if (!ownRaw) {
45
- nameCache.set(eid, []);
88
+ nameCache.set(eid, { keys: [], groups: {} });
46
89
  continue;
47
90
  }
48
91
  const shared = /* @__PURE__ */ new Set();
92
+ const groups = {};
49
93
  for (const [otherId] of peers) {
50
94
  if (otherId === eid) {
51
95
  continue;
@@ -57,12 +101,16 @@ export function setupComposableRegistry() {
57
101
  for (const [key, obj] of Object.entries(ownRaw)) {
58
102
  if (key in otherRaw && otherRaw[key] === obj) {
59
103
  shared.add(key);
104
+ const identity = getIdentityGroup(obj);
105
+ if (identity) {
106
+ groups[key] = identity;
107
+ }
60
108
  }
61
109
  }
62
110
  }
63
- nameCache.set(eid, [...shared]);
111
+ nameCache.set(eid, { keys: [...shared], groups });
64
112
  }
65
- return nameCache.get(id) ?? [];
113
+ return nameCache.get(id) ?? { keys: [], groups: {} };
66
114
  }
67
115
  let currentRoute = "/";
68
116
  function setRoute(path) {
@@ -197,6 +245,7 @@ export function setupComposableRegistry() {
197
245
  }
198
246
  ])
199
247
  );
248
+ const shared = getSharedKeys(entry.id, entry.name);
200
249
  return {
201
250
  id: entry.id,
202
251
  name: entry.name,
@@ -207,7 +256,8 @@ export function setupComposableRegistry() {
207
256
  leakReason: entry.leakReason,
208
257
  refs: freshRefs,
209
258
  history: entryHistory.get(entry.id) ?? [],
210
- sharedKeys: getSharedKeys(entry.id, entry.name),
259
+ sharedKeys: shared.keys,
260
+ sharedKeyGroups: shared.groups,
211
261
  watcherCount: entry.watcherCount,
212
262
  intervalCount: entry.intervalCount,
213
263
  lifecycle: entry.lifecycle,
@@ -318,7 +368,15 @@ export function __trackComposable(name, callFn, meta) {
318
368
  return callFn();
319
369
  }
320
370
  if (!import.meta.client) {
321
- return callFn();
371
+ const startTime = nowMs();
372
+ try {
373
+ const result2 = callFn();
374
+ __recordSsrComposableSpan(name, meta, startTime, nowMs());
375
+ return result2;
376
+ } catch (error) {
377
+ __recordSsrComposableSpan(name, meta, startTime, nowMs(), { error });
378
+ throw error;
379
+ }
322
380
  }
323
381
  const registry = window.__observatory__?.composable;
324
382
  if (!registry) {
@@ -8,6 +8,7 @@ export function setupRenderRegistry(nuxtApp, options = {}) {
8
8
  const HIDE_INTERNALS = config.heatmapHideInternals ?? false;
9
9
  let dirty = true;
10
10
  let cachedSnapshot = "[]";
11
+ let resetTimestamp = 0;
11
12
  const liveElements = /* @__PURE__ */ new Map();
12
13
  function markDirty() {
13
14
  dirty = true;
@@ -61,6 +62,7 @@ export function setupRenderRegistry(nuxtApp, options = {}) {
61
62
  };
62
63
  }
63
64
  function reset() {
65
+ resetTimestamp = performance.now();
64
66
  for (const entry of entries.values()) {
65
67
  entry.isPersistent = true;
66
68
  entry.rerenders = 0;
@@ -72,23 +74,30 @@ export function setupRenderRegistry(nuxtApp, options = {}) {
72
74
  markDirty();
73
75
  }
74
76
  function aggregateFromComponentSpans() {
75
- const componentSpans = traceStore.getAllTraces().flatMap((trace) => trace.spans).filter((span) => span.type === "component");
76
- const spansByUid = /* @__PURE__ */ new Map();
77
+ const componentSpans = traceStore.getAllTraces().flatMap((trace) => trace.spans).filter((span) => span.type === "render" || span.type === "component");
78
+ const allSpansByUid = /* @__PURE__ */ new Map();
79
+ const postResetSpansByUid = /* @__PURE__ */ new Map();
77
80
  for (const span of componentSpans) {
78
81
  const uidValue = span.metadata?.uid;
79
82
  const uid = typeof uidValue === "number" ? uidValue : Number(uidValue);
80
83
  if (!Number.isFinite(uid)) {
81
84
  continue;
82
85
  }
83
- const list = spansByUid.get(uid) ?? [];
84
- list.push(span);
85
- spansByUid.set(uid, list);
86
+ const allList = allSpansByUid.get(uid) ?? [];
87
+ allList.push(span);
88
+ allSpansByUid.set(uid, allList);
89
+ if (span.startTime >= resetTimestamp) {
90
+ const postList = postResetSpansByUid.get(uid) ?? [];
91
+ postList.push(span);
92
+ postResetSpansByUid.set(uid, postList);
93
+ }
86
94
  }
87
95
  for (const [uid, entry] of entries.entries()) {
88
- const spans = spansByUid.get(uid) ?? [];
89
- spans.sort((a, b) => a.startTime - b.startTime);
90
- const timeline = spans.slice(-MAX_TIMELINE).map((span) => {
91
- const lifecycle = span.metadata?.lifecycle === "mounted" ? "mount" : "update";
96
+ const allSpans = (allSpansByUid.get(uid) ?? []).sort((a, b) => a.startTime - b.startTime);
97
+ const postResetSpans = (postResetSpansByUid.get(uid) ?? []).sort((a, b) => a.startTime - b.startTime);
98
+ const timeline = postResetSpans.slice(-MAX_TIMELINE).map((span) => {
99
+ const isMountLifecycle = span.metadata?.lifecycle === "render:mount" || span.metadata?.lifecycle === "mounted";
100
+ const lifecycle = isMountLifecycle ? "mount" : "update";
92
101
  const routeValue = span.metadata?.route;
93
102
  const route = typeof routeValue === "string" && routeValue.length > 0 ? routeValue : entry.route;
94
103
  return {
@@ -98,10 +107,11 @@ export function setupRenderRegistry(nuxtApp, options = {}) {
98
107
  route
99
108
  };
100
109
  });
101
- const mountCount = spans.filter((span) => span.metadata?.lifecycle === "mounted").length;
102
- const rerenders = spans.filter((span) => span.metadata?.lifecycle !== "mounted").length;
103
- const totalMs = spans.reduce((sum, span) => sum + (span.durationMs ?? 0), 0);
104
- const eventsCount = Math.max(mountCount + rerenders, 1);
110
+ const isMountSpan = (span) => span.metadata?.lifecycle === "render:mount" || span.metadata?.lifecycle === "mounted";
111
+ const mountCount = allSpans.filter(isMountSpan).length;
112
+ const rerenders = postResetSpans.filter((span) => !isMountSpan(span)).length;
113
+ const totalMs = postResetSpans.reduce((sum, span) => sum + (span.durationMs ?? 0), 0);
114
+ const eventsCount = Math.max(postResetSpans.length, 1);
105
115
  entry.mountCount = mountCount;
106
116
  entry.rerenders = rerenders;
107
117
  entry.totalMs = Math.round(totalMs * 10) / 10;
@@ -4,6 +4,6 @@ interface AsyncDataMeta {
4
4
  line: number;
5
5
  originalFn?: string;
6
6
  }
7
- type AnyFn = (...args: any[]) => any;
7
+ type AnyFn = (...args: unknown[]) => unknown;
8
8
  export declare function useTracedAsyncData<TFn extends AnyFn>(originalFn: TFn, args: unknown[], handlerIndex: number, key: unknown, meta: AsyncDataMeta): ReturnType<TFn>;
9
9
  export {};
@@ -1,2 +1,8 @@
1
1
  import type { NuxtApp } from '#app';
2
- export declare function setupFetchInstrumentation(nuxtApp: NuxtApp): void;
2
+ import type { FetchEntry } from '../composables/fetch-registry.js';
3
+ type FetchRegistry = {
4
+ register: (entry: FetchEntry) => void;
5
+ update: (id: string, patch: Partial<FetchEntry>) => void;
6
+ };
7
+ export declare function setupFetchInstrumentation(nuxtApp: NuxtApp, fetchRegistry?: FetchRegistry): void;
8
+ export {};
@@ -27,7 +27,7 @@ function resolveErrorStatus(error) {
27
27
  return target?.response?.status ?? target?.statusCode ?? target?.status;
28
28
  }
29
29
  const WRAPPED_FETCH_FLAG = "__observatory_wrapped_fetch__";
30
- export function setupFetchInstrumentation(nuxtApp) {
30
+ export function setupFetchInstrumentation(nuxtApp, fetchRegistry) {
31
31
  const original = nuxtApp.$fetch;
32
32
  if (!original) {
33
33
  return;
@@ -39,6 +39,7 @@ export function setupFetchInstrumentation(nuxtApp) {
39
39
  const url = resolveUrl(request);
40
40
  const method = resolveMethod(request, options);
41
41
  const startedAt = performance.now();
42
+ const entryId = `$fetch::${Date.now()}::${Math.random().toString(36).slice(2, 7)}`;
42
43
  const span = startSpan({
43
44
  name: "$fetch",
44
45
  type: "fetch",
@@ -49,6 +50,15 @@ export function setupFetchInstrumentation(nuxtApp) {
49
50
  status: "pending"
50
51
  }
51
52
  });
53
+ fetchRegistry?.register({
54
+ id: entryId,
55
+ key: url,
56
+ url,
57
+ status: "pending",
58
+ origin: "csr",
59
+ startTime: startedAt,
60
+ cached: false
61
+ });
52
62
  return Promise.resolve(original(request, options)).then((result) => {
53
63
  const durationMs = Math.max(performance.now() - startedAt, 0);
54
64
  span.end({
@@ -61,6 +71,11 @@ export function setupFetchInstrumentation(nuxtApp) {
61
71
  durationMs: Math.round(durationMs * 10) / 10
62
72
  }
63
73
  });
74
+ fetchRegistry?.update(entryId, {
75
+ status: "ok",
76
+ endTime: performance.now(),
77
+ ms: Math.round(durationMs * 10) / 10
78
+ });
64
79
  return result;
65
80
  }).catch((error) => {
66
81
  const durationMs = Math.max(performance.now() - startedAt, 0);
@@ -76,6 +91,12 @@ export function setupFetchInstrumentation(nuxtApp) {
76
91
  durationMs: Math.round(durationMs * 10) / 10
77
92
  }
78
93
  });
94
+ fetchRegistry?.update(entryId, {
95
+ status: "error",
96
+ endTime: performance.now(),
97
+ ms: Math.round(durationMs * 10) / 10,
98
+ error
99
+ });
79
100
  throw error;
80
101
  });
81
102
  });
@@ -1,7 +1,6 @@
1
- import type { H3Event } from 'h3';
2
1
  interface NitroAppLike {
3
2
  hooks: {
4
- hook: (name: 'request' | 'afterResponse', handler: (event: H3Event) => void) => void;
3
+ hook: (name: string, handler: (...args: unknown[]) => void) => void;
5
4
  };
6
5
  }
7
6
  export default function fetchCapturePlugin(nitroApp: NitroAppLike): void;