@kitlangton/motel 0.1.3 → 0.2.1
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/AGENTS.md +11 -1
- package/package.json +5 -3
- package/src/App.tsx +239 -59
- package/src/daemon.test.ts +144 -7
- package/src/daemon.ts +113 -8
- package/src/domain.test.ts +62 -0
- package/src/domain.ts +62 -4
- package/src/httpApi.ts +4 -1
- package/src/localServer.ts +112 -121
- package/src/mcp.ts +172 -0
- package/src/motelClient.ts +166 -14
- package/src/registry.ts +26 -23
- package/src/runtime.ts +8 -2
- package/src/server.ts +10 -9
- package/src/services/AsyncIngest.ts +52 -0
- package/src/services/TelemetryStore.ts +285 -27
- package/src/services/TraceQueryService.ts +4 -2
- package/src/services/ingestRpc.ts +41 -0
- package/src/services/telemetryWorker.ts +62 -0
- package/src/storybook/aiChatStory.tsx +243 -0
- package/src/storybook/fixtures/errorState.ts +44 -0
- package/src/storybook/fixtures/imagePaste.ts +34 -0
- package/src/storybook/fixtures/index.ts +62 -0
- package/src/storybook/fixtures/kitchenSink.ts +148 -0
- package/src/storybook/fixtures/rawPrompt.ts +15 -0
- package/src/storybook/fixtures/short.ts +27 -0
- package/src/storybook/fixtures/toolHeavy.ts +65 -0
- package/src/telemetry.test.ts +61 -0
- package/src/ui/AiChatView.tsx +292 -0
- package/src/ui/SpanContentView.tsx +181 -0
- package/src/ui/SpanDetail.tsx +98 -17
- package/src/ui/TraceDetailsPane.tsx +35 -3
- package/src/ui/Waterfall.tsx +94 -167
- package/src/ui/aiChatModel.test.ts +347 -0
- package/src/ui/aiChatModel.ts +736 -0
- package/src/ui/aiState.ts +71 -0
- package/src/ui/app/TraceWorkspace.tsx +295 -120
- package/src/ui/app/useAppLayout.ts +14 -11
- package/src/ui/app/useTraceScreenData.ts +191 -35
- package/src/ui/atoms.ts +131 -0
- package/src/ui/filterParser.test.ts +56 -0
- package/src/ui/filterParser.ts +45 -0
- package/src/ui/loaders.ts +120 -0
- package/src/ui/persistence.ts +41 -0
- package/src/ui/primitives.tsx +47 -21
- package/src/ui/state.ts +4 -169
- package/src/ui/useAttrFilterPicker.ts +63 -23
- package/src/ui/useKeyboardNav.ts +576 -300
- package/src/ui/waterfallFilter.test.ts +84 -0
- package/src/ui/waterfallFilter.ts +59 -0
- package/src/ui/waterfallModel.ts +130 -0
- package/src/ui/waterfallNav.test.ts +17 -1
- package/src/ui/waterfallNav.ts +1 -1
- package/web/dist/assets/{index-DKinj-OE.js → index-DnyVo03x.js} +1 -1
- package/web/dist/index.html +1 -1
|
@@ -1,18 +1,27 @@
|
|
|
1
1
|
import { useAtom } from "@effect/atom-react"
|
|
2
|
-
import { useCallback, useEffect, useMemo, useRef } from "react"
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
3
3
|
import { config } from "../../config.js"
|
|
4
|
-
import type { LogItem, TraceItem } from "../../domain.ts"
|
|
4
|
+
import type { LogItem, TraceItem, TraceSummaryItem } from "../../domain.ts"
|
|
5
5
|
import {
|
|
6
6
|
activeAttrKeyAtom,
|
|
7
7
|
activeAttrValueAtom,
|
|
8
|
+
aiCallDetailStateAtom,
|
|
8
9
|
autoRefreshAtom,
|
|
10
|
+
chatDetailChunkIdAtom,
|
|
11
|
+
chatDetailScrollOffsetAtom,
|
|
9
12
|
collapsedSpanIdsAtom,
|
|
10
13
|
detailViewAtom,
|
|
14
|
+
ensureAiCallDetail,
|
|
15
|
+
ensureTraceAttributeKeys,
|
|
11
16
|
filterModeAtom,
|
|
12
17
|
filterTextAtom,
|
|
18
|
+
getCachedAiCallDetail,
|
|
19
|
+
initialAiCallDetailState,
|
|
13
20
|
initialLogState,
|
|
14
21
|
initialServiceLogState,
|
|
15
22
|
initialTraceDetailState,
|
|
23
|
+
invalidateAiCallDetailCache,
|
|
24
|
+
invalidateFacetCaches,
|
|
16
25
|
loadFilteredTraceSummaries,
|
|
17
26
|
loadRecentTraceSummaries,
|
|
18
27
|
loadServiceLogs,
|
|
@@ -22,6 +31,8 @@ import {
|
|
|
22
31
|
logStateAtom,
|
|
23
32
|
persistSelectedService,
|
|
24
33
|
refreshNonceAtom,
|
|
34
|
+
selectedAttrIndexAtom,
|
|
35
|
+
selectedChatChunkIdAtom,
|
|
25
36
|
selectedServiceLogIndexAtom,
|
|
26
37
|
selectedSpanIndexAtom,
|
|
27
38
|
selectedTraceIndexAtom,
|
|
@@ -29,11 +40,71 @@ import {
|
|
|
29
40
|
serviceLogStateAtom,
|
|
30
41
|
showHelpAtom,
|
|
31
42
|
traceDetailStateAtom,
|
|
43
|
+
type TraceSortMode,
|
|
32
44
|
traceSortAtom,
|
|
33
45
|
traceStateAtom,
|
|
34
46
|
} from "../state.ts"
|
|
47
|
+
import { isAiSpan } from "../../domain.ts"
|
|
48
|
+
import { buildChunks, type Chunk } from "../aiChatModel.ts"
|
|
49
|
+
import { parseFilterText } from "../filterParser.ts"
|
|
35
50
|
import { getVisibleSpans } from "../Waterfall.tsx"
|
|
36
51
|
|
|
52
|
+
const clampSelectionIndex = (index: number, length: number) => {
|
|
53
|
+
if (length === 0) return 0
|
|
54
|
+
return Math.max(0, Math.min(index, length - 1))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const resolveEffectiveService = (
|
|
58
|
+
services: readonly string[],
|
|
59
|
+
selectedTraceService: string | null,
|
|
60
|
+
) => services.includes(selectedTraceService ?? "")
|
|
61
|
+
? selectedTraceService
|
|
62
|
+
: selectedTraceService ?? services[0] ?? config.otel.serviceName
|
|
63
|
+
|
|
64
|
+
const loadTraceSummariesForService = (
|
|
65
|
+
serviceName: string | null,
|
|
66
|
+
filters: {
|
|
67
|
+
readonly activeAttrKey: string | null
|
|
68
|
+
readonly activeAttrValue: string | null
|
|
69
|
+
readonly debouncedAiText: string | null
|
|
70
|
+
},
|
|
71
|
+
) => {
|
|
72
|
+
if (!serviceName) return Promise.resolve([] as readonly TraceSummaryItem[])
|
|
73
|
+
const hasAttrFilter = Boolean(filters.activeAttrKey && filters.activeAttrValue)
|
|
74
|
+
const hasAiFilter = Boolean(filters.debouncedAiText)
|
|
75
|
+
if (!hasAttrFilter && !hasAiFilter) return loadRecentTraceSummaries(serviceName)
|
|
76
|
+
return loadFilteredTraceSummaries(serviceName, {
|
|
77
|
+
attributeFilters: hasAttrFilter ? { [filters.activeAttrKey as string]: filters.activeAttrValue as string } : undefined,
|
|
78
|
+
aiText: hasAiFilter ? filters.debouncedAiText : null,
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const applyClientTraceFilters = (
|
|
83
|
+
traces: readonly TraceSummaryItem[],
|
|
84
|
+
filterText: string,
|
|
85
|
+
parsedFilter: ReturnType<typeof parseFilterText>,
|
|
86
|
+
) => filterText
|
|
87
|
+
? traces.filter((trace) => {
|
|
88
|
+
if (parsedFilter.errorOnly && trace.errorCount === 0) return false
|
|
89
|
+
if (parsedFilter.operationNeedle && !trace.rootOperationName.toLowerCase().includes(parsedFilter.operationNeedle)) return false
|
|
90
|
+
return true
|
|
91
|
+
})
|
|
92
|
+
: traces
|
|
93
|
+
|
|
94
|
+
const sortTraceSummaries = (traces: readonly TraceSummaryItem[], traceSort: TraceSortMode) => {
|
|
95
|
+
if (traceSort === "recent") return traces
|
|
96
|
+
return [...traces].sort((a, b) => {
|
|
97
|
+
if (traceSort === "slowest") return b.durationMs - a.durationMs
|
|
98
|
+
if (traceSort === "errors") return b.errorCount - a.errorCount || b.startedAt.getTime() - a.startedAt.getTime()
|
|
99
|
+
return 0
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const getSelectedVisibleSpan = (
|
|
104
|
+
spans: readonly TraceItem["spans"][number][],
|
|
105
|
+
selectedSpanIndex: number | null,
|
|
106
|
+
) => selectedSpanIndex === null ? null : spans[selectedSpanIndex] ?? null
|
|
107
|
+
|
|
37
108
|
export const useTraceScreenData = () => {
|
|
38
109
|
const [traceState, setTraceState] = useAtom(traceStateAtom)
|
|
39
110
|
const [traceDetailState, setTraceDetailState] = useAtom(traceDetailStateAtom)
|
|
@@ -44,6 +115,11 @@ export const useTraceScreenData = () => {
|
|
|
44
115
|
const [selectedTraceService, setSelectedTraceService] = useAtom(selectedTraceServiceAtom)
|
|
45
116
|
const [refreshNonce, setRefreshNonce] = useAtom(refreshNonceAtom)
|
|
46
117
|
const [selectedSpanIndex, setSelectedSpanIndex] = useAtom(selectedSpanIndexAtom)
|
|
118
|
+
const [, setSelectedAttrIndex] = useAtom(selectedAttrIndexAtom)
|
|
119
|
+
const [, setChatDetailChunkId] = useAtom(chatDetailChunkIdAtom)
|
|
120
|
+
const [, setChatDetailScrollOffset] = useAtom(chatDetailScrollOffsetAtom)
|
|
121
|
+
const [selectedChatChunkId, setSelectedChatChunkId] = useAtom(selectedChatChunkIdAtom)
|
|
122
|
+
const [aiCallDetailState, setAiCallDetailState] = useAtom(aiCallDetailStateAtom)
|
|
47
123
|
const [detailView, setDetailView] = useAtom(detailViewAtom)
|
|
48
124
|
const [showHelp, setShowHelp] = useAtom(showHelpAtom)
|
|
49
125
|
const [collapsedSpanIds, setCollapsedSpanIds] = useAtom(collapsedSpanIdsAtom)
|
|
@@ -54,6 +130,17 @@ export const useTraceScreenData = () => {
|
|
|
54
130
|
const [activeAttrValue] = useAtom(activeAttrValueAtom)
|
|
55
131
|
const [traceSort] = useAtom(traceSortAtom)
|
|
56
132
|
|
|
133
|
+
// `:ai <query>` is parsed out of the filter text and debounced so
|
|
134
|
+
// typing doesn't hammer FTS. The other modifiers (:error, operation
|
|
135
|
+
// needle) stay client-side since we already have those on trace
|
|
136
|
+
// summaries. 250ms feels responsive without firing on every keystroke.
|
|
137
|
+
const parsedFilter = useMemo(() => parseFilterText(filterText), [filterText])
|
|
138
|
+
const [debouncedAiText, setDebouncedAiText] = useState<string | null>(parsedFilter.aiText)
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
const handle = setTimeout(() => setDebouncedAiText(parsedFilter.aiText), 250)
|
|
141
|
+
return () => clearTimeout(handle)
|
|
142
|
+
}, [parsedFilter.aiText])
|
|
143
|
+
|
|
57
144
|
const selectedTraceRef = useRef<string | null>(null)
|
|
58
145
|
const cacheEpochRef = useRef(0)
|
|
59
146
|
const traceDetailCacheRef = useRef(new Map<string, { data: TraceItem | null; fetchedAt: Date }>())
|
|
@@ -79,8 +166,18 @@ export const useTraceScreenData = () => {
|
|
|
79
166
|
serviceLogCacheRef.current.clear()
|
|
80
167
|
traceDetailInflightRef.current.clear()
|
|
81
168
|
traceLogInflightRef.current.clear()
|
|
169
|
+
invalidateFacetCaches()
|
|
170
|
+
invalidateAiCallDetailCache()
|
|
82
171
|
}, [refreshNonce])
|
|
83
172
|
|
|
173
|
+
// Pre-warm the attribute picker facet keys for the currently-selected
|
|
174
|
+
// service so pressing `f` feels instant. Fire-and-forget; errors are
|
|
175
|
+
// surfaced when the user actually opens the picker.
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
if (!selectedTraceService) return
|
|
178
|
+
void ensureTraceAttributeKeys(selectedTraceService).catch(() => {})
|
|
179
|
+
}, [selectedTraceService, refreshNonce])
|
|
180
|
+
|
|
84
181
|
useEffect(() => {
|
|
85
182
|
let cancelled = false
|
|
86
183
|
|
|
@@ -91,19 +188,17 @@ export const useTraceScreenData = () => {
|
|
|
91
188
|
const services = await loadTraceServices()
|
|
92
189
|
if (cancelled) return
|
|
93
190
|
|
|
94
|
-
const effectiveService = services
|
|
95
|
-
? selectedTraceService
|
|
96
|
-
: selectedTraceService ?? services[0] ?? config.otel.serviceName
|
|
191
|
+
const effectiveService = resolveEffectiveService(services, selectedTraceService)
|
|
97
192
|
|
|
98
193
|
if (effectiveService !== selectedTraceService) {
|
|
99
194
|
setSelectedTraceService(effectiveService)
|
|
100
195
|
}
|
|
101
196
|
|
|
102
|
-
const traces = effectiveService
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
197
|
+
const traces = await loadTraceSummariesForService(effectiveService, {
|
|
198
|
+
activeAttrKey,
|
|
199
|
+
activeAttrValue,
|
|
200
|
+
debouncedAiText,
|
|
201
|
+
})
|
|
107
202
|
if (cancelled) return
|
|
108
203
|
|
|
109
204
|
const prevTraceId = selectedTraceRef.current
|
|
@@ -126,18 +221,22 @@ export const useTraceScreenData = () => {
|
|
|
126
221
|
return () => {
|
|
127
222
|
cancelled = true
|
|
128
223
|
}
|
|
129
|
-
}, [refreshNonce, selectedTraceService, activeAttrKey, activeAttrValue, setSelectedTraceIndex, setSelectedTraceService, setTraceState])
|
|
224
|
+
}, [refreshNonce, selectedTraceService, activeAttrKey, activeAttrValue, debouncedAiText, setSelectedTraceIndex, setSelectedTraceService, setTraceState])
|
|
130
225
|
|
|
131
226
|
useEffect(() => {
|
|
132
227
|
setSelectedTraceIndex((current) => {
|
|
133
|
-
|
|
134
|
-
return Math.max(0, Math.min(current, traceState.data.length - 1))
|
|
228
|
+
return clampSelectionIndex(current, traceState.data.length)
|
|
135
229
|
})
|
|
136
230
|
}, [traceState.data.length, setSelectedTraceIndex])
|
|
137
231
|
|
|
138
232
|
const selectedTraceSummary = traceState.data[selectedTraceIndex] ?? null
|
|
139
233
|
const selectedTraceId = selectedTraceSummary?.traceId ?? null
|
|
140
234
|
const selectedTrace = traceDetailState.traceId === selectedTraceId ? traceDetailState.data : null
|
|
235
|
+
const selectedVisibleSpans = useMemo(
|
|
236
|
+
() => selectedTrace ? getVisibleSpans(selectedTrace.spans, collapsedSpanIds) : [],
|
|
237
|
+
[selectedTrace, collapsedSpanIds],
|
|
238
|
+
)
|
|
239
|
+
const selectedVisibleSpan = getSelectedVisibleSpan(selectedVisibleSpans, selectedSpanIndex)
|
|
141
240
|
selectedTraceRef.current = selectedTraceId
|
|
142
241
|
|
|
143
242
|
const warmTraceDetail = useCallback((traceId: string, hydrateSelection: boolean) => {
|
|
@@ -274,6 +373,72 @@ export const useTraceScreenData = () => {
|
|
|
274
373
|
setSelectedSpanIndex(null)
|
|
275
374
|
}, [selectedTraceId, setCollapsedSpanIds, setSelectedSpanIndex])
|
|
276
375
|
|
|
376
|
+
// Reset the attribute cursor whenever the span selection moves. Without
|
|
377
|
+
// this, drilling from span A (with 34 tags) to span B (with 3 tags)
|
|
378
|
+
// would leave the cursor pointing past the end of B's tag list until
|
|
379
|
+
// the user hit `j`/`k` again.
|
|
380
|
+
useEffect(() => {
|
|
381
|
+
setSelectedAttrIndex(0)
|
|
382
|
+
setChatDetailChunkId(null)
|
|
383
|
+
setChatDetailScrollOffset(0)
|
|
384
|
+
// New span → drop chunk selection and any open detail modal.
|
|
385
|
+
// The effect below will re-select the first chunk once the
|
|
386
|
+
// detail loads.
|
|
387
|
+
setSelectedChatChunkId(null)
|
|
388
|
+
}, [selectedSpanIndex, selectedTraceId, setSelectedAttrIndex, setChatDetailChunkId, setChatDetailScrollOffset, setSelectedChatChunkId])
|
|
389
|
+
|
|
390
|
+
// Load the parsed AI call detail for the currently-selected span when
|
|
391
|
+
// it's an AI span and the user is drilled into L2. Cached module-level
|
|
392
|
+
// so re-entering the chat view for a span we already loaded is free.
|
|
393
|
+
const selectedSpanId = selectedVisibleSpan?.spanId ?? null
|
|
394
|
+
const shouldLoadAiDetail = detailView === "span-detail" && selectedVisibleSpan !== null && isAiSpan(selectedVisibleSpan.tags)
|
|
395
|
+
|
|
396
|
+
useEffect(() => {
|
|
397
|
+
if (!shouldLoadAiDetail || !selectedSpanId) {
|
|
398
|
+
setAiCallDetailState(initialAiCallDetailState)
|
|
399
|
+
return
|
|
400
|
+
}
|
|
401
|
+
const cached = getCachedAiCallDetail(selectedSpanId)
|
|
402
|
+
if (cached !== undefined) {
|
|
403
|
+
setAiCallDetailState({ status: "ready", spanId: selectedSpanId, data: cached, error: null })
|
|
404
|
+
return
|
|
405
|
+
}
|
|
406
|
+
setAiCallDetailState({ status: "loading", spanId: selectedSpanId, data: null, error: null })
|
|
407
|
+
let cancelled = false
|
|
408
|
+
ensureAiCallDetail(selectedSpanId)
|
|
409
|
+
.then((data) => {
|
|
410
|
+
if (cancelled) return
|
|
411
|
+
setAiCallDetailState({ status: "ready", spanId: selectedSpanId, data, error: null })
|
|
412
|
+
})
|
|
413
|
+
.catch((err) => {
|
|
414
|
+
if (cancelled) return
|
|
415
|
+
setAiCallDetailState({
|
|
416
|
+
status: "error",
|
|
417
|
+
spanId: selectedSpanId,
|
|
418
|
+
data: null,
|
|
419
|
+
error: err instanceof Error ? err.message : String(err),
|
|
420
|
+
})
|
|
421
|
+
})
|
|
422
|
+
return () => { cancelled = true }
|
|
423
|
+
}, [shouldLoadAiDetail, selectedSpanId, setAiCallDetailState])
|
|
424
|
+
|
|
425
|
+
// Chunk model — rebuilt whenever the detail payload changes.
|
|
426
|
+
// Width-independent, so this lives here instead of in the view.
|
|
427
|
+
const aiChatChunks = useMemo<readonly Chunk[]>(() => {
|
|
428
|
+
if (!shouldLoadAiDetail || !aiCallDetailState.data) return []
|
|
429
|
+
return buildChunks(aiCallDetailState.data)
|
|
430
|
+
}, [shouldLoadAiDetail, aiCallDetailState.data])
|
|
431
|
+
|
|
432
|
+
// Once chunks are available, pin selection to the first chunk unless
|
|
433
|
+
// the user has already chosen one. Also handles the "chunk list
|
|
434
|
+
// changed and the previous selection disappeared" case.
|
|
435
|
+
useEffect(() => {
|
|
436
|
+
if (aiChatChunks.length === 0) return
|
|
437
|
+
const stillValid = selectedChatChunkId !== null
|
|
438
|
+
&& aiChatChunks.some((c) => c.id === selectedChatChunkId)
|
|
439
|
+
if (!stillValid) setSelectedChatChunkId(aiChatChunks[0]!.id)
|
|
440
|
+
}, [aiChatChunks, selectedChatChunkId, setSelectedChatChunkId])
|
|
441
|
+
|
|
277
442
|
useEffect(() => {
|
|
278
443
|
if (selectedSpanIndex === null) return
|
|
279
444
|
if (!selectedTrace || selectedTrace.spans.length === 0) {
|
|
@@ -281,11 +446,11 @@ export const useTraceScreenData = () => {
|
|
|
281
446
|
setDetailView("waterfall")
|
|
282
447
|
return
|
|
283
448
|
}
|
|
284
|
-
const visibleCount =
|
|
449
|
+
const visibleCount = selectedVisibleSpans.length
|
|
285
450
|
if (selectedSpanIndex >= visibleCount) {
|
|
286
451
|
setSelectedSpanIndex(visibleCount - 1)
|
|
287
452
|
}
|
|
288
|
-
}, [selectedTrace, selectedSpanIndex,
|
|
453
|
+
}, [selectedTrace, selectedSpanIndex, selectedVisibleSpans.length, setDetailView, setSelectedSpanIndex])
|
|
289
454
|
|
|
290
455
|
useEffect(() => {
|
|
291
456
|
const traceId = selectedTraceId
|
|
@@ -354,29 +519,18 @@ export const useTraceScreenData = () => {
|
|
|
354
519
|
|
|
355
520
|
useEffect(() => {
|
|
356
521
|
setSelectedServiceLogIndex((current) => {
|
|
357
|
-
|
|
358
|
-
return Math.max(0, Math.min(current, serviceLogState.data.length - 1))
|
|
522
|
+
return clampSelectionIndex(current, serviceLogState.data.length)
|
|
359
523
|
})
|
|
360
524
|
}, [serviceLogState.data.length, setSelectedServiceLogIndex])
|
|
361
525
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
})
|
|
371
|
-
: traceState.data
|
|
372
|
-
|
|
373
|
-
const filteredTraces = traceSort === "recent"
|
|
374
|
-
? preFilterTraces
|
|
375
|
-
: [...preFilterTraces].sort((a, b) => {
|
|
376
|
-
if (traceSort === "slowest") return b.durationMs - a.durationMs
|
|
377
|
-
if (traceSort === "errors") return b.errorCount - a.errorCount || b.startedAt.getTime() - a.startedAt.getTime()
|
|
378
|
-
return 0
|
|
379
|
-
})
|
|
526
|
+
// Client-side filters: `:error` + operation-name needle both run
|
|
527
|
+
// against already-loaded summaries (no server round-trip). The `:ai`
|
|
528
|
+
// query, by contrast, is applied server-side in the load effect
|
|
529
|
+
// above so we don't need to re-filter it here.
|
|
530
|
+
const filteredTraces = useMemo(() => {
|
|
531
|
+
const preFiltered = applyClientTraceFilters(traceState.data, filterText, parsedFilter)
|
|
532
|
+
return sortTraceSummaries(preFiltered, traceSort)
|
|
533
|
+
}, [filterText, parsedFilter, traceSort, traceState.data])
|
|
380
534
|
|
|
381
535
|
useEffect(() => {
|
|
382
536
|
if (!selectedTraceId || filteredTraces.length === 0) return
|
|
@@ -418,5 +572,7 @@ export const useTraceScreenData = () => {
|
|
|
418
572
|
selectedTrace,
|
|
419
573
|
selectedTraceId,
|
|
420
574
|
filteredTraces,
|
|
575
|
+
aiCallDetailState,
|
|
576
|
+
aiChatChunks,
|
|
421
577
|
} as const
|
|
422
578
|
}
|
package/src/ui/atoms.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import * as Atom from "effect/unstable/reactivity/Atom"
|
|
2
|
+
import { config } from "../config.ts"
|
|
3
|
+
import type { LogItem, TraceItem, TraceSummaryItem } from "../domain.ts"
|
|
4
|
+
import type { ThemeName } from "./theme.ts"
|
|
5
|
+
import { readLastService, readLastTheme } from "./persistence.ts"
|
|
6
|
+
|
|
7
|
+
export type LoadStatus = "loading" | "ready" | "error"
|
|
8
|
+
export type DetailView = "waterfall" | "span-detail" | "service-logs"
|
|
9
|
+
|
|
10
|
+
export interface TraceState {
|
|
11
|
+
readonly status: LoadStatus
|
|
12
|
+
readonly services: readonly string[]
|
|
13
|
+
readonly data: readonly TraceSummaryItem[]
|
|
14
|
+
readonly error: string | null
|
|
15
|
+
readonly fetchedAt: Date | null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TraceDetailState {
|
|
19
|
+
readonly status: LoadStatus
|
|
20
|
+
readonly traceId: string | null
|
|
21
|
+
readonly data: TraceItem | null
|
|
22
|
+
readonly error: string | null
|
|
23
|
+
readonly fetchedAt: Date | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface LogState {
|
|
27
|
+
readonly status: LoadStatus
|
|
28
|
+
readonly traceId: string | null
|
|
29
|
+
readonly data: readonly LogItem[]
|
|
30
|
+
readonly error: string | null
|
|
31
|
+
readonly fetchedAt: Date | null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ServiceLogState {
|
|
35
|
+
readonly status: LoadStatus
|
|
36
|
+
readonly serviceName: string | null
|
|
37
|
+
readonly data: readonly LogItem[]
|
|
38
|
+
readonly error: string | null
|
|
39
|
+
readonly fetchedAt: Date | null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const initialTraceState: TraceState = {
|
|
43
|
+
status: "loading",
|
|
44
|
+
services: [],
|
|
45
|
+
data: [],
|
|
46
|
+
error: null,
|
|
47
|
+
fetchedAt: null,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const initialLogState: LogState = {
|
|
51
|
+
status: "ready",
|
|
52
|
+
traceId: null,
|
|
53
|
+
data: [],
|
|
54
|
+
error: null,
|
|
55
|
+
fetchedAt: null,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const initialTraceDetailState: TraceDetailState = {
|
|
59
|
+
status: "ready",
|
|
60
|
+
traceId: null,
|
|
61
|
+
data: null,
|
|
62
|
+
error: null,
|
|
63
|
+
fetchedAt: null,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const initialServiceLogState: ServiceLogState = {
|
|
67
|
+
status: "ready",
|
|
68
|
+
serviceName: null,
|
|
69
|
+
data: [],
|
|
70
|
+
error: null,
|
|
71
|
+
fetchedAt: null,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const traceStateAtom = Atom.make(initialTraceState).pipe(Atom.keepAlive)
|
|
75
|
+
export const traceDetailStateAtom = Atom.make(initialTraceDetailState).pipe(Atom.keepAlive)
|
|
76
|
+
export const logStateAtom = Atom.make(initialLogState).pipe(Atom.keepAlive)
|
|
77
|
+
export const serviceLogStateAtom = Atom.make(initialServiceLogState).pipe(Atom.keepAlive)
|
|
78
|
+
export const selectedServiceLogIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
|
|
79
|
+
export const selectedTraceIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
|
|
80
|
+
export const selectedTraceServiceAtom = Atom.make<string | null>(readLastService() ?? config.otel.serviceName).pipe(Atom.keepAlive)
|
|
81
|
+
export const refreshNonceAtom = Atom.make(0).pipe(Atom.keepAlive)
|
|
82
|
+
export const noticeAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
|
|
83
|
+
export const selectedSpanIndexAtom = Atom.make<number | null>(null).pipe(Atom.keepAlive)
|
|
84
|
+
// Cursor inside the full-screen span content view (detailView === "span-detail").
|
|
85
|
+
// Tracks which span tag is currently selected for copy / drill-in. Reset to 0
|
|
86
|
+
// on each new span so the cursor doesn't point past a shorter tag list.
|
|
87
|
+
export const selectedAttrIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
|
|
88
|
+
export const detailViewAtom = Atom.make<DetailView>("waterfall").pipe(Atom.keepAlive)
|
|
89
|
+
export const showHelpAtom = Atom.make(false).pipe(Atom.keepAlive)
|
|
90
|
+
export const autoRefreshAtom = Atom.make(false).pipe(Atom.keepAlive)
|
|
91
|
+
export const filterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
|
|
92
|
+
export const filterTextAtom = Atom.make("").pipe(Atom.keepAlive)
|
|
93
|
+
|
|
94
|
+
// Waterfall-scoped filter: the `/` key while drilled into a trace
|
|
95
|
+
// (viewLevel >= 1) opens this filter instead of the trace-list one.
|
|
96
|
+
// Purely client-side — dims spans whose operation name and attribute
|
|
97
|
+
// values don't contain the needle.
|
|
98
|
+
export const waterfallFilterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
|
|
99
|
+
export const waterfallFilterTextAtom = Atom.make("").pipe(Atom.keepAlive)
|
|
100
|
+
|
|
101
|
+
// Attribute filter (F key): pick a span-attribute key + exact value to restrict the trace list.
|
|
102
|
+
export type AttrPickerMode = "off" | "keys" | "values"
|
|
103
|
+
export const attrPickerModeAtom = Atom.make<AttrPickerMode>("off").pipe(Atom.keepAlive)
|
|
104
|
+
export const attrPickerInputAtom = Atom.make("").pipe(Atom.keepAlive)
|
|
105
|
+
export const attrPickerIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
|
|
106
|
+
|
|
107
|
+
export interface AttrFacetState {
|
|
108
|
+
readonly status: LoadStatus
|
|
109
|
+
readonly key: string | null
|
|
110
|
+
readonly data: readonly { readonly value: string; readonly count: number }[]
|
|
111
|
+
readonly error: string | null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const initialAttrFacetState: AttrFacetState = {
|
|
115
|
+
status: "ready",
|
|
116
|
+
key: null,
|
|
117
|
+
data: [],
|
|
118
|
+
error: null,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export const attrFacetStateAtom = Atom.make(initialAttrFacetState).pipe(Atom.keepAlive)
|
|
122
|
+
|
|
123
|
+
// Applied filter (drives trace list query)
|
|
124
|
+
export const activeAttrKeyAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
|
|
125
|
+
export const activeAttrValueAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
|
|
126
|
+
|
|
127
|
+
export const selectedThemeAtom = Atom.make<ThemeName>(readLastTheme()).pipe(Atom.keepAlive)
|
|
128
|
+
|
|
129
|
+
export type TraceSortMode = "recent" | "slowest" | "errors"
|
|
130
|
+
export const traceSortAtom = Atom.make<TraceSortMode>("recent").pipe(Atom.keepAlive)
|
|
131
|
+
export const collapsedSpanIdsAtom = Atom.make(new Set<string>() as ReadonlySet<string>).pipe(Atom.keepAlive)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import { parseFilterText } from "./filterParser.ts"
|
|
3
|
+
|
|
4
|
+
describe("parseFilterText", () => {
|
|
5
|
+
it("returns empty state for empty input", () => {
|
|
6
|
+
expect(parseFilterText("")).toEqual({ aiText: null, errorOnly: false, operationNeedle: "" })
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it("extracts a bare operation-name needle", () => {
|
|
10
|
+
expect(parseFilterText("streamText")).toEqual({
|
|
11
|
+
aiText: null, errorOnly: false, operationNeedle: "streamtext",
|
|
12
|
+
})
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it("recognizes :error modifier", () => {
|
|
16
|
+
expect(parseFilterText(":error")).toEqual({
|
|
17
|
+
aiText: null, errorOnly: true, operationNeedle: "",
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it("composes :error with an operation needle", () => {
|
|
22
|
+
expect(parseFilterText("llm :error")).toEqual({
|
|
23
|
+
aiText: null, errorOnly: true, operationNeedle: "llm",
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("extracts :ai query up to end of string", () => {
|
|
28
|
+
expect(parseFilterText(":ai rate limit")).toEqual({
|
|
29
|
+
aiText: "rate limit", errorOnly: false, operationNeedle: "",
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("extracts :ai query stopping at the next modifier", () => {
|
|
34
|
+
expect(parseFilterText(":ai tool_use :error")).toEqual({
|
|
35
|
+
aiText: "tool_use", errorOnly: true, operationNeedle: "",
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("composes operation needle + :ai + :error", () => {
|
|
40
|
+
expect(parseFilterText("stream :ai rate :error")).toEqual({
|
|
41
|
+
aiText: "rate", errorOnly: true, operationNeedle: "stream",
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("ignores :ai with empty query", () => {
|
|
46
|
+
expect(parseFilterText(":ai ")).toEqual({
|
|
47
|
+
aiText: null, errorOnly: false, operationNeedle: "",
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("is case-insensitive for modifiers", () => {
|
|
52
|
+
expect(parseFilterText(":AI foo :ERROR")).toEqual({
|
|
53
|
+
aiText: "foo", errorOnly: true, operationNeedle: "",
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses the `/` filter input in the trace list into its component
|
|
3
|
+
* modifiers. Supports composable tokens:
|
|
4
|
+
*
|
|
5
|
+
* - `:error` — restricts to traces with at least one failed span
|
|
6
|
+
* - `:ai <query...>` — FTS-backed search against LLM prompt/response
|
|
7
|
+
* content (AI_FTS_KEYS) across every span in the
|
|
8
|
+
* trace. The query runs up to the next `:modifier`
|
|
9
|
+
* or end of input, so `"/ :ai rate limit :error"`
|
|
10
|
+
* passes `"rate limit"` as the aiText and also
|
|
11
|
+
* sets errorOnly.
|
|
12
|
+
* - bare text — case-insensitive substring match against the
|
|
13
|
+
* trace's root operation name (client-side)
|
|
14
|
+
*
|
|
15
|
+
* Keep this a pure function: it's unit-tested separately and the React
|
|
16
|
+
* hook just calls it per render.
|
|
17
|
+
*/
|
|
18
|
+
export interface ParsedFilter {
|
|
19
|
+
readonly aiText: string | null
|
|
20
|
+
readonly errorOnly: boolean
|
|
21
|
+
readonly operationNeedle: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const parseFilterText = (raw: string): ParsedFilter => {
|
|
25
|
+
const text = raw ?? ""
|
|
26
|
+
|
|
27
|
+
// `:ai <query>` — greedy up to the next `:` modifier or end. The
|
|
28
|
+
// leading `(^|\s)` guard prevents matching a stray `:ai` glued to
|
|
29
|
+
// non-space characters (shouldn't happen in practice but cheap).
|
|
30
|
+
const aiMatch = text.match(/(?:^|\s):ai\s+([^:]*?)(?=\s:|$)/i)
|
|
31
|
+
const aiText = aiMatch?.[1]?.trim() || null
|
|
32
|
+
|
|
33
|
+
const errorOnly = /(?:^|\s):error(?=\s|$)/i.test(text)
|
|
34
|
+
|
|
35
|
+
// Whatever remains after removing the recognized modifiers becomes the
|
|
36
|
+
// operation-name needle. Lowercased here so the call site can do a
|
|
37
|
+
// plain includes() without re-lowercasing.
|
|
38
|
+
const operationNeedle = text
|
|
39
|
+
.replace(/(?:^|\s):ai\s+[^:]*?(?=\s:|$)/i, " ")
|
|
40
|
+
.replace(/(?:^|\s):error(?=\s|$)/i, " ")
|
|
41
|
+
.trim()
|
|
42
|
+
.toLowerCase()
|
|
43
|
+
|
|
44
|
+
return { aiText, errorOnly, operationNeedle }
|
|
45
|
+
}
|