nuxt-devtools-observatory 0.1.30 → 0.1.31

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 (39) hide show
  1. package/README.md +63 -4
  2. package/client/.env +2 -1
  3. package/client/.env.example +2 -1
  4. package/client/dist/assets/index-BuMXDBO9.js +17 -0
  5. package/client/dist/assets/{index-BmGW_M3W.css → index-CwcspZ6w.css} +1 -1
  6. package/client/dist/index.html +2 -2
  7. package/client/src/App.vue +4 -0
  8. package/client/src/components/Flamegraph.vue +443 -0
  9. package/client/src/components/SpanInspector.vue +446 -0
  10. package/client/src/components/TraceFilter.vue +344 -0
  11. package/client/src/components/WaterfallView.vue +443 -0
  12. package/client/src/composables/useTraceFilter.ts +164 -0
  13. package/client/src/stores/observatory.ts +5 -1
  14. package/client/src/views/TraceViewer.vue +599 -0
  15. package/client/src/views/TransitionTimeline.vue +1 -6
  16. package/dist/module.d.mts +5 -0
  17. package/dist/module.json +1 -1
  18. package/dist/module.mjs +30 -44
  19. package/dist/runtime/composables/render-registry.js +66 -110
  20. package/dist/runtime/composables/transition-registry.js +103 -28
  21. package/dist/runtime/instrumentation/asyncData.d.ts +9 -0
  22. package/dist/runtime/instrumentation/asyncData.js +49 -0
  23. package/dist/runtime/instrumentation/component.d.ts +2 -0
  24. package/dist/runtime/instrumentation/component.js +126 -0
  25. package/dist/runtime/instrumentation/fetch.d.ts +2 -0
  26. package/dist/runtime/instrumentation/fetch.js +89 -0
  27. package/dist/runtime/instrumentation/route.d.ts +6 -0
  28. package/dist/runtime/instrumentation/route.js +41 -0
  29. package/dist/runtime/plugin.js +38 -2
  30. package/dist/runtime/tracing/context.d.ts +9 -0
  31. package/dist/runtime/tracing/context.js +15 -0
  32. package/dist/runtime/tracing/trace.d.ts +25 -0
  33. package/dist/runtime/tracing/trace.js +0 -0
  34. package/dist/runtime/tracing/traceStore.d.ts +39 -0
  35. package/dist/runtime/tracing/traceStore.js +101 -0
  36. package/dist/runtime/tracing/tracing.d.ts +27 -0
  37. package/dist/runtime/tracing/tracing.js +48 -0
  38. package/package.json +1 -1
  39. package/client/dist/assets/index-BCaKoHBH.js +0 -17
@@ -0,0 +1,443 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type { TraceEntry, TraceSpan } from '@observatory/types/snapshot'
4
+
5
+ interface Props {
6
+ trace: TraceEntry
7
+ }
8
+
9
+ const props = defineProps<Props>()
10
+
11
+ const spansByType = computed(() => {
12
+ const groups: Record<string, TraceSpan[]> = {}
13
+
14
+ for (const span of props.trace.spans) {
15
+ if (!groups[span.type]) {
16
+ groups[span.type] = []
17
+ }
18
+
19
+ groups[span.type].push(span)
20
+ }
21
+
22
+ return Object.entries(groups)
23
+ .map(([type, spans]) => ({
24
+ type,
25
+ spans: spans.sort((a, b) => a.startTime - b.startTime),
26
+ }))
27
+ .sort((a, b) => {
28
+ const typeOrder: Record<string, number> = {
29
+ navigation: 0,
30
+ fetch: 1,
31
+ composable: 2,
32
+ component: 3,
33
+ render: 4,
34
+ transition: 5,
35
+ }
36
+
37
+ return (typeOrder[a.type] ?? 999) - (typeOrder[b.type] ?? 999)
38
+ })
39
+ })
40
+
41
+ const timelineDuration = computed(() => {
42
+ if (props.trace.durationMs && props.trace.durationMs > 0) {
43
+ return props.trace.durationMs
44
+ }
45
+
46
+ if (props.trace.endTime && props.trace.endTime > props.trace.startTime) {
47
+ return props.trace.endTime - props.trace.startTime
48
+ }
49
+
50
+ let maxEndOffset = 0
51
+
52
+ for (const span of props.trace.spans) {
53
+ const endTime = span.endTime ?? span.startTime + (span.durationMs ?? 0)
54
+ const endOffset = endTime - props.trace.startTime
55
+
56
+ if (endOffset > maxEndOffset) {
57
+ maxEndOffset = endOffset
58
+ }
59
+ }
60
+
61
+ return maxEndOffset || 1
62
+ })
63
+
64
+ function getSpanX(span: TraceSpan): number {
65
+ const left = ((span.startTime - props.trace.startTime) / timelineDuration.value) * 100
66
+
67
+ return Math.min(100, Math.max(0, left))
68
+ }
69
+
70
+ function getSpanWidth(span: TraceSpan): number {
71
+ const left = getSpanX(span)
72
+ const width = ((span.durationMs || 0) / timelineDuration.value) * 100
73
+
74
+ return Math.max(2, Math.min(100 - left, width))
75
+ }
76
+
77
+ function isNarrowBar(span: TraceSpan): boolean {
78
+ const width = ((span.durationMs || 0) / timelineDuration.value) * 100
79
+
80
+ return width < 5
81
+ }
82
+
83
+ function formatDuration(durationMs?: number) {
84
+ if (!durationMs) {
85
+ return '0ms'
86
+ }
87
+
88
+ if (durationMs < 1) {
89
+ return `${(durationMs * 1000).toFixed(1)}μs`
90
+ }
91
+
92
+ if (durationMs < 1000) {
93
+ return `${durationMs.toFixed(1)}ms`
94
+ }
95
+
96
+ return `${(durationMs / 1000).toFixed(2)}s`
97
+ }
98
+
99
+ function getSpanColorClass(type: string) {
100
+ const colors: Record<string, string> = {
101
+ fetch: 'bg-blue-500',
102
+ composable: 'bg-purple-500',
103
+ component: 'bg-green-500',
104
+ navigation: 'bg-yellow-500',
105
+ render: 'bg-orange-500',
106
+ transition: 'bg-pink-500',
107
+ }
108
+
109
+ return colors[type] || 'bg-gray-500'
110
+ }
111
+
112
+ function getStatusColor(status: string) {
113
+ const statusColors: Record<string, string> = {
114
+ ok: 'border-green-400',
115
+ error: 'border-red-400',
116
+ cancelled: 'border-gray-400',
117
+ active: 'border-yellow-400',
118
+ }
119
+
120
+ return statusColors[status] || 'border-gray-400'
121
+ }
122
+
123
+ function asString(val: unknown): string {
124
+ return typeof val === 'string' ? val : ''
125
+ }
126
+
127
+ function getSpanDisplayName(span: TraceSpan): string {
128
+ const m = span.metadata as Record<string, unknown> | undefined
129
+
130
+ if (!m) {
131
+ return span.name
132
+ }
133
+
134
+ const componentName = asString(m.componentName)
135
+ const lifecycle = asString(m.lifecycle)
136
+ const uid = m.uid !== undefined ? String(m.uid) : ''
137
+ const method = asString(m.method)
138
+ const url = asString(m.url)
139
+ const route = asString(m.route) || asString(m.path)
140
+
141
+ if (componentName && lifecycle) {
142
+ return uid ? `${componentName}.${lifecycle} #${uid}` : `${componentName}.${lifecycle}`
143
+ }
144
+
145
+ if (componentName) {
146
+ return uid ? `${componentName} #${uid}` : componentName
147
+ }
148
+
149
+ if (method && url) {
150
+ return `${method} ${url}`
151
+ }
152
+
153
+ if (url) {
154
+ return url
155
+ }
156
+
157
+ if (route) {
158
+ return `${span.name} (${route})`
159
+ }
160
+
161
+ return span.name
162
+ }
163
+
164
+ function getSpanTooltip(span: TraceSpan): string {
165
+ const displayName = getSpanDisplayName(span)
166
+ const duration = formatDuration(span.durationMs)
167
+ const m = span.metadata as Record<string, unknown> | undefined
168
+ const route = m ? asString(m.route) || asString(m.path) : ''
169
+ let tooltip = `${displayName} — ${duration} (${span.status})`
170
+
171
+ if (route && !displayName.includes(route)) {
172
+ tooltip += ` [${route}]`
173
+ }
174
+
175
+ return tooltip
176
+ }
177
+ </script>
178
+
179
+ <template>
180
+ <div class="waterfall">
181
+ <div class="waterfall__timeline-header">
182
+ <div class="waterfall__type-col">Type</div>
183
+ <div class="waterfall__timeline-col">
184
+ <div class="waterfall__time-markers">
185
+ <div class="waterfall__time-marker">0ms</div>
186
+ <div class="waterfall__time-marker">{{ formatDuration(timelineDuration / 4) }}</div>
187
+ <div class="waterfall__time-marker">{{ formatDuration(timelineDuration / 2) }}</div>
188
+ <div class="waterfall__time-marker">{{ formatDuration((timelineDuration * 3) / 4) }}</div>
189
+ <div class="waterfall__time-marker">{{ formatDuration(timelineDuration) }}</div>
190
+ </div>
191
+ </div>
192
+ </div>
193
+
194
+ <div class="waterfall__groups">
195
+ <div v-for="group in spansByType" :key="group.type" class="waterfall__group">
196
+ <div class="waterfall__group-header">
197
+ <span class="waterfall__group-type">
198
+ <span class="waterfall__color-dot" :class="getSpanColorClass(group.type)"></span>
199
+ {{ group.type }}
200
+ </span>
201
+ <span class="waterfall__group-count">{{ group.spans.length }}</span>
202
+ </div>
203
+
204
+ <div class="waterfall__group-spans">
205
+ <div v-for="span in group.spans" :key="span.id" class="waterfall__span-row">
206
+ <div class="waterfall__span-label">
207
+ <span :title="getSpanTooltip(span)">{{ getSpanDisplayName(span) }}</span>
208
+ </div>
209
+ <div class="waterfall__span-bar-container">
210
+ <div
211
+ class="waterfall__span-bar"
212
+ :class="[getSpanColorClass(span.type), getStatusColor(span.status)]"
213
+ :style="{
214
+ left: `${getSpanX(span)}%`,
215
+ width: `${Math.max(1, getSpanWidth(span))}%`,
216
+ }"
217
+ :title="getSpanTooltip(span)"
218
+ >
219
+ <span v-if="!isNarrowBar(span)" class="waterfall__bar-duration">{{ formatDuration(span.durationMs) }}</span>
220
+ </div>
221
+ <span
222
+ v-if="isNarrowBar(span)"
223
+ class="waterfall__bar-duration-outside"
224
+ :style="{ left: `calc(${getSpanX(span)}% + 4px)` }"
225
+ >
226
+ {{ formatDuration(span.durationMs) }}
227
+ </span>
228
+ </div>
229
+ </div>
230
+ </div>
231
+ </div>
232
+
233
+ <div v-if="spansByType.length === 0" class="waterfall__empty">No spans in this trace</div>
234
+ </div>
235
+ </div>
236
+ </template>
237
+
238
+ <style scoped>
239
+ .waterfall {
240
+ display: flex;
241
+ flex-direction: column;
242
+ height: 100%;
243
+ overflow: auto;
244
+ font-family: var(--mono);
245
+ font-size: 11px;
246
+ }
247
+
248
+ .waterfall__timeline-header {
249
+ display: flex;
250
+ position: sticky;
251
+ top: 0;
252
+ z-index: 20;
253
+ background: var(--bg2, var(--bg));
254
+ box-shadow: 0 1px 0 var(--border);
255
+ border-bottom: 1px solid var(--border);
256
+ }
257
+
258
+ .waterfall__type-col {
259
+ width: 120px;
260
+ min-width: 120px;
261
+ padding: 8px 0;
262
+ border-right: 1px solid var(--border);
263
+ }
264
+
265
+ .waterfall__timeline-col {
266
+ flex: 1;
267
+ min-width: 0;
268
+ }
269
+
270
+ .waterfall__time-markers {
271
+ display: flex;
272
+ justify-content: space-around;
273
+ padding: 8px 16px;
274
+ gap: 8px;
275
+ }
276
+
277
+ .waterfall__time-marker {
278
+ font-size: 10px;
279
+ color: var(--text2, var(--text));
280
+ flex: 1;
281
+ text-align: center;
282
+ }
283
+
284
+ .waterfall__groups {
285
+ display: flex;
286
+ flex-direction: column;
287
+ }
288
+
289
+ .waterfall__group {
290
+ border-bottom: 1px solid var(--border);
291
+ }
292
+
293
+ .waterfall__group-header {
294
+ display: flex;
295
+ align-items: center;
296
+ justify-content: space-between;
297
+ background: var(--bg2, var(--bg));
298
+ padding: 8px 16px;
299
+ font-weight: 600;
300
+ font-size: 12px;
301
+ color: var(--text2, var(--text));
302
+ }
303
+
304
+ .waterfall__group-type {
305
+ display: flex;
306
+ align-items: center;
307
+ gap: 6px;
308
+ }
309
+
310
+ .waterfall__color-dot {
311
+ display: inline-block;
312
+ width: 8px;
313
+ height: 8px;
314
+ border-radius: 2px;
315
+ }
316
+
317
+ .waterfall__group-count {
318
+ background: var(--bg);
319
+ border: 1px solid var(--border);
320
+ padding: 2px 6px;
321
+ border-radius: 3px;
322
+ font-size: 10px;
323
+ }
324
+
325
+ .waterfall__group-spans {
326
+ display: flex;
327
+ flex-direction: column;
328
+ }
329
+
330
+ .waterfall__span-row {
331
+ display: flex;
332
+ height: 28px;
333
+ align-items: center;
334
+ border-bottom: 1px solid var(--border);
335
+ }
336
+
337
+ .waterfall__span-label {
338
+ width: 120px;
339
+ min-width: 120px;
340
+ padding: 0 16px;
341
+ overflow: hidden;
342
+ text-overflow: ellipsis;
343
+ white-space: nowrap;
344
+ color: var(--text);
345
+ font-size: 11px;
346
+ border-right: 1px solid var(--border);
347
+ }
348
+
349
+ .waterfall__span-bar-container {
350
+ flex: 1;
351
+ height: 100%;
352
+ position: relative;
353
+ min-width: 0;
354
+ }
355
+
356
+ .waterfall__span-bar {
357
+ position: absolute;
358
+ top: 50%;
359
+ transform: translateY(-50%);
360
+ height: 16px;
361
+ border-radius: 2px;
362
+ border-left: 2px solid;
363
+ display: flex;
364
+ align-items: center;
365
+ justify-content: center;
366
+ color: white;
367
+ overflow: hidden;
368
+ cursor: pointer;
369
+ transition: opacity 0.2s;
370
+ padding: 0 4px;
371
+ }
372
+
373
+ .waterfall__span-bar:hover {
374
+ opacity: 0.8;
375
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.3);
376
+ }
377
+
378
+ .waterfall__bar-duration {
379
+ font-size: 9px;
380
+ white-space: nowrap;
381
+ }
382
+
383
+ .waterfall__bar-duration-outside {
384
+ position: absolute;
385
+ top: 50%;
386
+ transform: translateY(-50%);
387
+ font-size: 9px;
388
+ white-space: nowrap;
389
+ color: var(--text);
390
+ pointer-events: none;
391
+ }
392
+
393
+ .waterfall__empty {
394
+ padding: 32px 16px;
395
+ text-align: center;
396
+ color: var(--text2, var(--text));
397
+ }
398
+
399
+ /* Color utilities */
400
+ .bg-blue-500 {
401
+ background-color: #3b82f6;
402
+ }
403
+
404
+ .bg-purple-500 {
405
+ background-color: #a855f7;
406
+ }
407
+
408
+ .bg-green-500 {
409
+ background-color: #22c55e;
410
+ }
411
+
412
+ .bg-yellow-500 {
413
+ background-color: #eab308;
414
+ }
415
+
416
+ .bg-orange-500 {
417
+ background-color: #f97316;
418
+ }
419
+
420
+ .bg-pink-500 {
421
+ background-color: #ec4899;
422
+ }
423
+
424
+ .bg-gray-500 {
425
+ background-color: #6b7280;
426
+ }
427
+
428
+ .border-green-400 {
429
+ border-color: #4ade80;
430
+ }
431
+
432
+ .border-red-400 {
433
+ border-color: #f87171;
434
+ }
435
+
436
+ .border-gray-400 {
437
+ border-color: #9ca3af;
438
+ }
439
+
440
+ .border-yellow-400 {
441
+ border-color: #facc15;
442
+ }
443
+ </style>
@@ -0,0 +1,164 @@
1
+ import { computed, ref } from 'vue'
2
+ import type { TraceEntry } from '@observatory/types/snapshot'
3
+
4
+ export function getSpanTypesFromTraces(traces: TraceEntry[]): string[] {
5
+ const types = new Set<string>()
6
+
7
+ for (const trace of traces) {
8
+ for (const span of trace.spans) {
9
+ types.add(span.type)
10
+ }
11
+ }
12
+
13
+ return Array.from(types).sort()
14
+ }
15
+
16
+ export function useTraceFilter() {
17
+ const searchQuery = ref<string>('')
18
+ const selectedSpanTypes = ref<Set<string>>(new Set())
19
+ const minDuration = ref<number>(0)
20
+ const maxDuration = ref<number>(Infinity)
21
+ const routeFilter = ref<string>('')
22
+
23
+ function matchesSearch(trace: TraceEntry, query: string): boolean {
24
+ if (!query) {
25
+ return true
26
+ }
27
+
28
+ const lowerQuery = query.toLowerCase()
29
+
30
+ // Search in trace name
31
+ if (trace.name.toLowerCase().includes(lowerQuery)) {
32
+ return true
33
+ }
34
+
35
+ // Search in span names
36
+ for (const span of trace.spans) {
37
+ if (span.name.toLowerCase().includes(lowerQuery)) {
38
+ return true
39
+ }
40
+ }
41
+
42
+ // Search in metadata (e.g., route, URL endpoint)
43
+ for (const span of trace.spans) {
44
+ if (span.metadata) {
45
+ for (const value of Object.values(span.metadata)) {
46
+ if (typeof value === 'string' && value.toLowerCase().includes(lowerQuery)) {
47
+ return true
48
+ }
49
+ }
50
+ }
51
+ }
52
+
53
+ return false
54
+ }
55
+
56
+ function matchesSpanTypeFilter(trace: TraceEntry, types: Set<string>): boolean {
57
+ if (types.size === 0) return true
58
+
59
+ for (const span of trace.spans) {
60
+ if (types.has(span.type)) {
61
+ return true
62
+ }
63
+ }
64
+
65
+ return false
66
+ }
67
+
68
+ function matchesDurationFilter(
69
+ trace: TraceEntry,
70
+ min: number,
71
+ max: number
72
+ ): boolean {
73
+ const hasExplicitDurationFilter = min > 0 || max < Infinity
74
+
75
+ if (trace.durationMs === undefined) {
76
+ return !hasExplicitDurationFilter
77
+ }
78
+
79
+ return trace.durationMs >= min && trace.durationMs <= max
80
+ }
81
+
82
+ function matchesRouteFilter(trace: TraceEntry, route: string): boolean {
83
+ if (!route) {
84
+ return true
85
+ }
86
+
87
+ // Check trace metadata for route
88
+ if (trace.metadata?.route && typeof trace.metadata.route === 'string') {
89
+ if (trace.metadata.route.toLowerCase().includes(route.toLowerCase())) {
90
+ return true
91
+ }
92
+ }
93
+
94
+ // Check span metadata for route or path
95
+ for (const span of trace.spans) {
96
+ if (span.metadata?.route) {
97
+ const routeValue = String(span.metadata.route).toLowerCase()
98
+
99
+ if (routeValue.includes(route.toLowerCase())) {
100
+ return true
101
+ }
102
+ }
103
+
104
+ if (span.metadata?.path) {
105
+ const pathValue = String(span.metadata.path).toLowerCase()
106
+
107
+ if (pathValue.includes(route.toLowerCase())) {
108
+ return true
109
+ }
110
+ }
111
+ }
112
+
113
+ return false
114
+ }
115
+
116
+ function filterTraces(traces: TraceEntry[]): TraceEntry[] {
117
+ return traces.filter((trace) => {
118
+ return (
119
+ matchesSearch(trace, searchQuery.value) &&
120
+ matchesSpanTypeFilter(trace, selectedSpanTypes.value) &&
121
+ matchesDurationFilter(trace, minDuration.value, maxDuration.value) &&
122
+ matchesRouteFilter(trace, routeFilter.value)
123
+ )
124
+ })
125
+ }
126
+
127
+ function toggleSpanType(type: string) {
128
+ if (selectedSpanTypes.value.has(type)) {
129
+ selectedSpanTypes.value.delete(type)
130
+ } else {
131
+ selectedSpanTypes.value.add(type)
132
+ }
133
+ }
134
+
135
+ function clearFilters() {
136
+ searchQuery.value = ''
137
+ selectedSpanTypes.value.clear()
138
+ minDuration.value = 0
139
+ maxDuration.value = Infinity
140
+ routeFilter.value = ''
141
+ }
142
+
143
+ const hasActiveFilters = computed(() => {
144
+ return (
145
+ searchQuery.value.length > 0 ||
146
+ selectedSpanTypes.value.size > 0 ||
147
+ minDuration.value > 0 ||
148
+ maxDuration.value < Infinity ||
149
+ routeFilter.value.length > 0
150
+ )
151
+ })
152
+
153
+ return {
154
+ searchQuery,
155
+ selectedSpanTypes,
156
+ minDuration,
157
+ maxDuration,
158
+ routeFilter,
159
+ filterTraces,
160
+ toggleSpanType,
161
+ clearFilters,
162
+ hasActiveFilters,
163
+ }
164
+ }
@@ -1,7 +1,7 @@
1
1
  import { ref } from 'vue'
2
2
  import { useDevtoolsClient, onDevtoolsClientConnected } from '@nuxt/devtools-kit/iframe-client'
3
3
  import type { ObservatorySnapshot, ObservatoryServerFunctions, ObservatoryClientFunctions } from '@observatory/types/rpc'
4
- import type { FetchEntry, ProvideEntry, InjectEntry, ComposableEntry, RenderEntry, TransitionEntry } from '@observatory/types/snapshot'
4
+ import type { FetchEntry, ProvideEntry, InjectEntry, ComposableEntry, RenderEntry, TransitionEntry, TraceEntry } from '@observatory/types/snapshot'
5
5
 
6
6
  type ProvideInjectSnapshot = { provides: ProvideEntry[]; injects: InjectEntry[] }
7
7
 
@@ -10,6 +10,7 @@ const provideInject = ref<ProvideInjectSnapshot>({ provides: [], injects: [] })
10
10
  const composables = ref<ComposableEntry[]>([])
11
11
  const renders = ref<RenderEntry[]>([])
12
12
  const transitions = ref<TransitionEntry[]>([])
13
+ const traces = ref<TraceEntry[]>([])
13
14
  const connected = ref(false)
14
15
  const features = ref<ObservatorySnapshot['features']>({})
15
16
  const debugRpc = typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('debugRpc')
@@ -50,6 +51,7 @@ function applySnapshot(data: ObservatorySnapshot) {
50
51
  composables.value = cloneArray(data.composables as ComposableEntry[] | undefined)
51
52
  renders.value = normalizeRenderEntries(data.renders as RenderEntry[] | undefined)
52
53
  transitions.value = cloneArray(data.transitions as TransitionEntry[] | undefined)
54
+ traces.value = cloneArray(data.traces as TraceEntry[] | undefined)
53
55
  features.value = data.features || {}
54
56
 
55
57
  // If the server snapshot disagrees with the user's requested mode,
@@ -79,6 +81,7 @@ function applySnapshot(data: ObservatorySnapshot) {
79
81
  composables: composables.value.length,
80
82
  renders: renders.value.length,
81
83
  transitions: transitions.value.length,
84
+ traces: traces.value.length,
82
85
  })
83
86
  }
84
87
  }
@@ -224,6 +227,7 @@ export function useObservatoryData() {
224
227
  composables,
225
228
  renders,
226
229
  transitions,
230
+ traces,
227
231
  features,
228
232
  connected,
229
233
  refresh,