nuxt-devtools-observatory 0.1.30 → 0.1.32

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 (51) hide show
  1. package/README.md +117 -30
  2. package/client/.env.example +2 -1
  3. package/client/dist/assets/index-5Wl1XYRH.js +17 -0
  4. package/client/dist/assets/index-DT_QUiIh.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 +442 -0
  8. package/client/src/components/SpanInspector.vue +446 -0
  9. package/client/src/components/TraceFilter.vue +342 -0
  10. package/client/src/components/WaterfallView.vue +443 -0
  11. package/client/src/composables/composable-search.ts +124 -0
  12. package/client/src/composables/trace-render-aggregation.ts +254 -0
  13. package/client/src/composables/useExportImport.ts +63 -0
  14. package/client/src/composables/useTraceFilter.ts +160 -0
  15. package/client/src/stores/observatory.ts +13 -1
  16. package/client/src/views/ComposableTracker.vue +65 -30
  17. package/client/src/views/RenderHeatmap.vue +63 -1
  18. package/client/src/views/TraceViewer.vue +1212 -0
  19. package/client/src/views/TransitionTimeline.vue +1 -6
  20. package/dist/module.d.mts +5 -0
  21. package/dist/module.json +1 -1
  22. package/dist/module.mjs +31 -45
  23. package/dist/runtime/composables/composable-registry.d.ts +19 -0
  24. package/dist/runtime/composables/composable-registry.js +63 -5
  25. package/dist/runtime/composables/render-registry.js +74 -110
  26. package/dist/runtime/composables/transition-registry.js +103 -28
  27. package/dist/runtime/instrumentation/asyncData.d.ts +9 -0
  28. package/dist/runtime/instrumentation/asyncData.js +49 -0
  29. package/dist/runtime/instrumentation/component.d.ts +2 -0
  30. package/dist/runtime/instrumentation/component.js +126 -0
  31. package/dist/runtime/instrumentation/fetch.d.ts +2 -0
  32. package/dist/runtime/instrumentation/fetch.js +89 -0
  33. package/dist/runtime/instrumentation/route.d.ts +6 -0
  34. package/dist/runtime/instrumentation/route.js +41 -0
  35. package/dist/runtime/nitro/fetch-capture.d.ts +1 -2
  36. package/dist/runtime/nitro/fetch-capture.js +85 -7
  37. package/dist/runtime/nitro/ssr-trace-store.d.ts +85 -0
  38. package/dist/runtime/nitro/ssr-trace-store.js +84 -0
  39. package/dist/runtime/plugin.js +82 -2
  40. package/dist/runtime/tracing/context.d.ts +9 -0
  41. package/dist/runtime/tracing/context.js +15 -0
  42. package/dist/runtime/tracing/trace.d.ts +25 -0
  43. package/dist/runtime/tracing/trace.js +0 -0
  44. package/dist/runtime/tracing/traceStore.d.ts +39 -0
  45. package/dist/runtime/tracing/traceStore.js +101 -0
  46. package/dist/runtime/tracing/tracing.d.ts +27 -0
  47. package/dist/runtime/tracing/tracing.js +48 -0
  48. package/package.json +5 -1
  49. package/client/.env +0 -16
  50. package/client/dist/assets/index-BCaKoHBH.js +0 -17
  51. package/client/dist/assets/index-BmGW_M3W.css +0 -1
@@ -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 rgb(255 255 255 / 30%);
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,124 @@
1
+ import type { ComposableEntry } from '@observatory/types/snapshot'
2
+
3
+ const MAX_SEARCH_DEPTH = 6
4
+ const MAX_SEARCH_NODES = 1500
5
+
6
+ interface SearchBudget {
7
+ nodes: number
8
+ }
9
+
10
+ function valueMatchesQuery(value: unknown, query: string, seen: WeakSet<object>, budget: SearchBudget, depth = 0): boolean {
11
+ if (budget.nodes >= MAX_SEARCH_NODES) {
12
+ return false
13
+ }
14
+
15
+ budget.nodes++
16
+
17
+ if (value === null || value === undefined) {
18
+ return false
19
+ }
20
+
21
+ const valueType = typeof value
22
+
23
+ if (valueType === 'string' || valueType === 'number' || valueType === 'boolean' || valueType === 'bigint') {
24
+ return String(value).toLowerCase().includes(query)
25
+ }
26
+
27
+ if (valueType !== 'object') {
28
+ return false
29
+ }
30
+
31
+ if (depth >= MAX_SEARCH_DEPTH) {
32
+ return false
33
+ }
34
+
35
+ const objectValue = value as object
36
+
37
+ if (seen.has(objectValue)) {
38
+ return false
39
+ }
40
+
41
+ seen.add(objectValue)
42
+
43
+ if (Array.isArray(value)) {
44
+ return value.some((item) => valueMatchesQuery(item, query, seen, budget, depth + 1))
45
+ }
46
+
47
+ if (value instanceof Map) {
48
+ for (const [mapKey, mapValue] of value.entries()) {
49
+ if (valueMatchesQuery(mapKey, query, seen, budget, depth + 1)) {
50
+ return true
51
+ }
52
+
53
+ if (valueMatchesQuery(mapValue, query, seen, budget, depth + 1)) {
54
+ return true
55
+ }
56
+ }
57
+
58
+ return false
59
+ }
60
+
61
+ if (value instanceof Set) {
62
+ for (const setValue of value.values()) {
63
+ if (valueMatchesQuery(setValue, query, seen, budget, depth + 1)) {
64
+ return true
65
+ }
66
+ }
67
+
68
+ return false
69
+ }
70
+
71
+ try {
72
+ const entries = Object.entries(value as Record<string, unknown>)
73
+
74
+ for (const [key, nestedValue] of entries) {
75
+ if (key.toLowerCase().includes(query)) {
76
+ return true
77
+ }
78
+
79
+ if (valueMatchesQuery(nestedValue, query, seen, budget, depth + 1)) {
80
+ return true
81
+ }
82
+ }
83
+ } catch {
84
+ // Ignore objects that throw on entry access and continue matching safely.
85
+ return false
86
+ }
87
+
88
+ return false
89
+ }
90
+
91
+ /**
92
+ * Returns true when a composable entry matches the search query.
93
+ * Search scope includes name, file, ref keys, and nested reactive key/value content.
94
+ */
95
+ export function matchesComposableEntryQuery(entry: ComposableEntry, query: string): boolean {
96
+ const normalizedQuery = query.trim().toLowerCase()
97
+
98
+ if (!normalizedQuery) {
99
+ return true
100
+ }
101
+
102
+ if (entry.name.toLowerCase().includes(normalizedQuery)) {
103
+ return true
104
+ }
105
+
106
+ if (entry.componentFile.toLowerCase().includes(normalizedQuery)) {
107
+ return true
108
+ }
109
+
110
+ const seen = new WeakSet<object>()
111
+ const budget: SearchBudget = { nodes: 0 }
112
+
113
+ for (const [refKey, refInfo] of Object.entries(entry.refs)) {
114
+ if (refKey.toLowerCase().includes(normalizedQuery)) {
115
+ return true
116
+ }
117
+
118
+ if (valueMatchesQuery(refInfo.value, normalizedQuery, seen, budget)) {
119
+ return true
120
+ }
121
+ }
122
+
123
+ return false
124
+ }