@aryasaatvik/motel 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +180 -0
- package/LICENSE +21 -0
- package/README.md +137 -0
- package/package.json +119 -0
- package/skills/motel-debug/SKILL.md +203 -0
- package/skills/motel-debug/references/effect.md +38 -0
- package/src/App.tsx +414 -0
- package/src/StartupGate.test.ts +80 -0
- package/src/StartupGate.tsx +263 -0
- package/src/cli.ts +205 -0
- package/src/config.ts +45 -0
- package/src/daemon.test.ts +547 -0
- package/src/daemon.ts +486 -0
- package/src/domain.test.ts +62 -0
- package/src/domain.ts +291 -0
- package/src/httpApi.ts +391 -0
- package/src/httpListPolicy.test.ts +76 -0
- package/src/httpListPolicy.ts +129 -0
- package/src/index.tsx +25 -0
- package/src/instructions.ts +72 -0
- package/src/launchAgent.test.ts +430 -0
- package/src/launchAgent.ts +462 -0
- package/src/localServer.ts +568 -0
- package/src/locator.ts +138 -0
- package/src/mcp.ts +433 -0
- package/src/motel.test.ts +176 -0
- package/src/motel.ts +88 -0
- package/src/motelClient.ts +353 -0
- package/src/opentui-jsx.d.ts +11 -0
- package/src/otlp.test.ts +65 -0
- package/src/otlp.ts +162 -0
- package/src/otlpProtobuf.ts +35 -0
- package/src/queryFilters.ts +39 -0
- package/src/registry.ts +115 -0
- package/src/runtime.ts +40 -0
- package/src/server.ts +11 -0
- package/src/services/AsyncIngest.ts +82 -0
- package/src/services/LogQueryService.ts +29 -0
- package/src/services/TelemetryQuery.ts +62 -0
- package/src/services/TelemetryStore.ts +2554 -0
- package/src/services/TraceQueryService.ts +43 -0
- package/src/services/ingestRpc.ts +39 -0
- package/src/services/queryRpc.ts +15 -0
- package/src/services/telemetryQueryWorker.ts +32 -0
- package/src/services/telemetryWorker.ts +59 -0
- package/src/startupBench.ts +19 -0
- package/src/startupLifecycle.ts +57 -0
- package/src/storybook/aiChatStory.tsx +244 -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 +1053 -0
- package/src/ui/AiChatView.tsx +308 -0
- package/src/ui/AttrFilterModal.tsx +120 -0
- package/src/ui/ServiceLogs.tsx +115 -0
- package/src/ui/SpanContentView.tsx +184 -0
- package/src/ui/SpanDetail.tsx +215 -0
- package/src/ui/SpanDetailFull.tsx +224 -0
- package/src/ui/SpanDetailPane.tsx +90 -0
- package/src/ui/TraceDetailsPane.tsx +175 -0
- package/src/ui/TraceList.tsx +254 -0
- package/src/ui/Waterfall.tsx +389 -0
- package/src/ui/aiChatModel.test.ts +391 -0
- package/src/ui/aiChatModel.ts +773 -0
- package/src/ui/aiState.ts +59 -0
- package/src/ui/app/TraceListPane.tsx +39 -0
- package/src/ui/app/TraceWorkspace.tsx +452 -0
- package/src/ui/app/useAppLayout.ts +101 -0
- package/src/ui/app/useTraceScreenData.ts +590 -0
- package/src/ui/atoms.ts +131 -0
- package/src/ui/cachedLoader.test.ts +23 -0
- package/src/ui/cachedLoader.ts +60 -0
- package/src/ui/filterParser.test.ts +56 -0
- package/src/ui/filterParser.ts +45 -0
- package/src/ui/format.ts +128 -0
- package/src/ui/loaders.ts +101 -0
- package/src/ui/persistence.ts +41 -0
- package/src/ui/primitives.tsx +198 -0
- package/src/ui/state.ts +6 -0
- package/src/ui/theme.ts +160 -0
- package/src/ui/traceDetailsWidth.repro.test.ts +126 -0
- package/src/ui/traceSortNav.repro.seed.ts +62 -0
- package/src/ui/traceSortNav.repro.test.ts +231 -0
- package/src/ui/useAttrFilterPicker.ts +89 -0
- package/src/ui/useKeyboardNav.ts +1023 -0
- 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.repro.seed.ts +86 -0
- package/src/ui/waterfallNav.repro.test.ts +271 -0
- package/src/ui/waterfallNav.test.ts +453 -0
- package/src/ui/waterfallNav.ts +75 -0
- package/web/dist/assets/index-B01z9BaO.css +2 -0
- package/web/dist/assets/index-CYlBaY9O.js +22 -0
- package/web/dist/index.html +13 -0
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
import { useAtom } from "@effect/atom-react"
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
3
|
+
import { config } from "../../config.js"
|
|
4
|
+
import type { LogItem, TraceItem, TraceSummaryItem } from "../../domain.ts"
|
|
5
|
+
import {
|
|
6
|
+
activeAttrKeyAtom,
|
|
7
|
+
activeAttrValueAtom,
|
|
8
|
+
autoRefreshAtom,
|
|
9
|
+
collapsedSpanIdsAtom,
|
|
10
|
+
detailViewAtom,
|
|
11
|
+
filterModeAtom,
|
|
12
|
+
filterTextAtom,
|
|
13
|
+
initialLogState,
|
|
14
|
+
initialServiceLogState,
|
|
15
|
+
initialTraceDetailState,
|
|
16
|
+
logStateAtom,
|
|
17
|
+
refreshNonceAtom,
|
|
18
|
+
selectedAttrIndexAtom,
|
|
19
|
+
selectedServiceLogIndexAtom,
|
|
20
|
+
selectedSpanIndexAtom,
|
|
21
|
+
selectedTraceIndexAtom,
|
|
22
|
+
selectedTraceServiceAtom,
|
|
23
|
+
serviceLogStateAtom,
|
|
24
|
+
showHelpAtom,
|
|
25
|
+
traceDetailStateAtom,
|
|
26
|
+
type TraceSortMode,
|
|
27
|
+
traceSortAtom,
|
|
28
|
+
traceStateAtom,
|
|
29
|
+
} from "../atoms.ts"
|
|
30
|
+
import {
|
|
31
|
+
aiCallDetailStateAtom,
|
|
32
|
+
chatDetailChunkIdAtom,
|
|
33
|
+
chatDetailScrollOffsetAtom,
|
|
34
|
+
ensureAiCallDetail,
|
|
35
|
+
getCachedAiCallDetail,
|
|
36
|
+
initialAiCallDetailState,
|
|
37
|
+
invalidateAiCallDetailCache,
|
|
38
|
+
selectedChatChunkIdAtom,
|
|
39
|
+
} from "../aiState.ts"
|
|
40
|
+
import {
|
|
41
|
+
ensureTraceAttributeKeys,
|
|
42
|
+
ensureTraceAttributeValues,
|
|
43
|
+
loadFilteredTraceSummaries,
|
|
44
|
+
loadRecentTraceSummaries,
|
|
45
|
+
loadServiceLogs,
|
|
46
|
+
loadTraceDetail,
|
|
47
|
+
loadTraceLogs,
|
|
48
|
+
loadTraceServices,
|
|
49
|
+
} from "../loaders.ts"
|
|
50
|
+
import { persistSelectedService } from "../persistence.ts"
|
|
51
|
+
import { isAiSpan } from "../../domain.ts"
|
|
52
|
+
import { buildChunks, type Chunk } from "../aiChatModel.ts"
|
|
53
|
+
import { parseFilterText } from "../filterParser.ts"
|
|
54
|
+
import { getVisibleSpans } from "../waterfallModel.ts"
|
|
55
|
+
|
|
56
|
+
const clampSelectionIndex = (index: number, length: number) => {
|
|
57
|
+
if (length === 0) return 0
|
|
58
|
+
return Math.max(0, Math.min(index, length - 1))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const resolveEffectiveService = (
|
|
62
|
+
services: readonly string[],
|
|
63
|
+
selectedTraceService: string | null,
|
|
64
|
+
) => services.includes(selectedTraceService ?? "")
|
|
65
|
+
? selectedTraceService
|
|
66
|
+
: selectedTraceService ?? services[0] ?? config.otel.serviceName
|
|
67
|
+
|
|
68
|
+
const loadTraceSummariesForService = (
|
|
69
|
+
serviceName: string | null,
|
|
70
|
+
filters: {
|
|
71
|
+
readonly activeAttrKey: string | null
|
|
72
|
+
readonly activeAttrValue: string | null
|
|
73
|
+
readonly debouncedAiText: string | null
|
|
74
|
+
},
|
|
75
|
+
) => {
|
|
76
|
+
if (!serviceName) return Promise.resolve([] as readonly TraceSummaryItem[])
|
|
77
|
+
const hasAttrFilter = Boolean(filters.activeAttrKey && filters.activeAttrValue)
|
|
78
|
+
const hasAiFilter = Boolean(filters.debouncedAiText)
|
|
79
|
+
if (!hasAttrFilter && !hasAiFilter) return loadRecentTraceSummaries(serviceName)
|
|
80
|
+
return loadFilteredTraceSummaries(serviceName, {
|
|
81
|
+
attributeFilters: hasAttrFilter ? { [filters.activeAttrKey as string]: filters.activeAttrValue as string } : undefined,
|
|
82
|
+
aiText: hasAiFilter ? filters.debouncedAiText : null,
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const applyClientTraceFilters = (
|
|
87
|
+
traces: readonly TraceSummaryItem[],
|
|
88
|
+
filterText: string,
|
|
89
|
+
parsedFilter: ReturnType<typeof parseFilterText>,
|
|
90
|
+
) => filterText
|
|
91
|
+
? traces.filter((trace) => {
|
|
92
|
+
if (parsedFilter.errorOnly && trace.errorCount === 0) return false
|
|
93
|
+
if (parsedFilter.operationNeedle && !trace.rootOperationName.toLowerCase().includes(parsedFilter.operationNeedle)) return false
|
|
94
|
+
return true
|
|
95
|
+
})
|
|
96
|
+
: traces
|
|
97
|
+
|
|
98
|
+
const sortTraceSummaries = (traces: readonly TraceSummaryItem[], traceSort: TraceSortMode) => {
|
|
99
|
+
if (traceSort === "recent") return traces
|
|
100
|
+
return [...traces].sort((a, b) => {
|
|
101
|
+
if (traceSort === "slowest") return b.durationMs - a.durationMs
|
|
102
|
+
if (traceSort === "errors") return b.errorCount - a.errorCount || b.startedAt.getTime() - a.startedAt.getTime()
|
|
103
|
+
return 0
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const getSelectedVisibleSpan = (
|
|
108
|
+
spans: readonly TraceItem["spans"][number][],
|
|
109
|
+
selectedSpanIndex: number | null,
|
|
110
|
+
) => selectedSpanIndex === null ? null : spans[selectedSpanIndex] ?? null
|
|
111
|
+
|
|
112
|
+
export const useTraceScreenData = () => {
|
|
113
|
+
const [traceState, setTraceState] = useAtom(traceStateAtom)
|
|
114
|
+
const [traceDetailState, setTraceDetailState] = useAtom(traceDetailStateAtom)
|
|
115
|
+
const [logState, setLogState] = useAtom(logStateAtom)
|
|
116
|
+
const [serviceLogState, setServiceLogState] = useAtom(serviceLogStateAtom)
|
|
117
|
+
const [selectedServiceLogIndex, setSelectedServiceLogIndex] = useAtom(selectedServiceLogIndexAtom)
|
|
118
|
+
const [selectedTraceIndex, setSelectedTraceIndex] = useAtom(selectedTraceIndexAtom)
|
|
119
|
+
const [selectedTraceService, setSelectedTraceService] = useAtom(selectedTraceServiceAtom)
|
|
120
|
+
const [refreshNonce, setRefreshNonce] = useAtom(refreshNonceAtom)
|
|
121
|
+
const [selectedSpanIndex, setSelectedSpanIndex] = useAtom(selectedSpanIndexAtom)
|
|
122
|
+
const [, setSelectedAttrIndex] = useAtom(selectedAttrIndexAtom)
|
|
123
|
+
const [, setChatDetailChunkId] = useAtom(chatDetailChunkIdAtom)
|
|
124
|
+
const [, setChatDetailScrollOffset] = useAtom(chatDetailScrollOffsetAtom)
|
|
125
|
+
const [selectedChatChunkId, setSelectedChatChunkId] = useAtom(selectedChatChunkIdAtom)
|
|
126
|
+
const [aiCallDetailState, setAiCallDetailState] = useAtom(aiCallDetailStateAtom)
|
|
127
|
+
const [detailView, setDetailView] = useAtom(detailViewAtom)
|
|
128
|
+
const [showHelp, setShowHelp] = useAtom(showHelpAtom)
|
|
129
|
+
const [collapsedSpanIds, setCollapsedSpanIds] = useAtom(collapsedSpanIdsAtom)
|
|
130
|
+
const [autoRefresh] = useAtom(autoRefreshAtom)
|
|
131
|
+
const [filterMode] = useAtom(filterModeAtom)
|
|
132
|
+
const [filterText] = useAtom(filterTextAtom)
|
|
133
|
+
const [activeAttrKey] = useAtom(activeAttrKeyAtom)
|
|
134
|
+
const [activeAttrValue] = useAtom(activeAttrValueAtom)
|
|
135
|
+
const [traceSort] = useAtom(traceSortAtom)
|
|
136
|
+
|
|
137
|
+
// `:ai <query>` is parsed out of the filter text and debounced so
|
|
138
|
+
// typing doesn't hammer FTS. The other modifiers (:error, operation
|
|
139
|
+
// needle) stay client-side since we already have those on trace
|
|
140
|
+
// summaries. 250ms feels responsive without firing on every keystroke.
|
|
141
|
+
const parsedFilter = useMemo(() => parseFilterText(filterText), [filterText])
|
|
142
|
+
const [debouncedAiText, setDebouncedAiText] = useState<string | null>(parsedFilter.aiText)
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
const handle = setTimeout(() => setDebouncedAiText(parsedFilter.aiText), 250)
|
|
145
|
+
return () => clearTimeout(handle)
|
|
146
|
+
}, [parsedFilter.aiText])
|
|
147
|
+
|
|
148
|
+
const selectedTraceRef = useRef<string | null>(null)
|
|
149
|
+
const cacheEpochRef = useRef(0)
|
|
150
|
+
const traceDetailCacheRef = useRef(new Map<string, { data: TraceItem | null; fetchedAt: Date }>())
|
|
151
|
+
const traceLogCacheRef = useRef(new Map<string, { data: readonly LogItem[]; fetchedAt: Date }>())
|
|
152
|
+
const serviceLogCacheRef = useRef(new Map<string, { data: readonly LogItem[]; fetchedAt: Date }>())
|
|
153
|
+
const traceDetailInflightRef = useRef(new Map<string, Promise<{ readonly error: string | null }>>())
|
|
154
|
+
const traceLogInflightRef = useRef(new Map<string, Promise<{ readonly error: string | null }>>())
|
|
155
|
+
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
if (selectedTraceService) persistSelectedService(selectedTraceService)
|
|
158
|
+
}, [selectedTraceService])
|
|
159
|
+
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
if (!autoRefresh) return
|
|
162
|
+
const id = setInterval(() => setRefreshNonce((n) => n + 1), 5000)
|
|
163
|
+
return () => clearInterval(id)
|
|
164
|
+
}, [autoRefresh, setRefreshNonce])
|
|
165
|
+
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
cacheEpochRef.current += 1
|
|
168
|
+
traceDetailCacheRef.current.clear()
|
|
169
|
+
traceLogCacheRef.current.clear()
|
|
170
|
+
serviceLogCacheRef.current.clear()
|
|
171
|
+
traceDetailInflightRef.current.clear()
|
|
172
|
+
traceLogInflightRef.current.clear()
|
|
173
|
+
invalidateAiCallDetailCache()
|
|
174
|
+
}, [refreshNonce])
|
|
175
|
+
|
|
176
|
+
// Pre-warm the attribute picker facet keys for the currently-selected
|
|
177
|
+
// service so pressing `f` feels instant. Once keys are known, also
|
|
178
|
+
// prefetch the value lists for the first few visible keys so the common
|
|
179
|
+
// path of opening `f`, picking a top key, and reopening again stays
|
|
180
|
+
// near-instant. Fire-and-forget; errors are surfaced when the user
|
|
181
|
+
// actually opens the picker.
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
if (!selectedTraceService) return
|
|
184
|
+
void ensureTraceAttributeKeys(selectedTraceService)
|
|
185
|
+
.then((entry) => Promise.allSettled(
|
|
186
|
+
entry.data
|
|
187
|
+
.slice(0, 6)
|
|
188
|
+
.map((row) => ensureTraceAttributeValues(selectedTraceService, row.value)),
|
|
189
|
+
))
|
|
190
|
+
.catch(() => {})
|
|
191
|
+
}, [selectedTraceService])
|
|
192
|
+
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
let cancelled = false
|
|
195
|
+
|
|
196
|
+
const load = async () => {
|
|
197
|
+
setTraceState((current) => ({ ...current, status: current.fetchedAt === null ? "loading" : "ready", error: null }))
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const services = await loadTraceServices()
|
|
201
|
+
if (cancelled) return
|
|
202
|
+
|
|
203
|
+
const effectiveService = resolveEffectiveService(services, selectedTraceService)
|
|
204
|
+
|
|
205
|
+
if (effectiveService !== selectedTraceService) {
|
|
206
|
+
setSelectedTraceService(effectiveService)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const traces = await loadTraceSummariesForService(effectiveService, {
|
|
210
|
+
activeAttrKey,
|
|
211
|
+
activeAttrValue,
|
|
212
|
+
debouncedAiText,
|
|
213
|
+
})
|
|
214
|
+
if (cancelled) return
|
|
215
|
+
|
|
216
|
+
const prevTraceId = selectedTraceRef.current
|
|
217
|
+
setTraceState({ status: "ready", services, data: traces, error: null, fetchedAt: new Date() })
|
|
218
|
+
if (prevTraceId) {
|
|
219
|
+
const newIndex = traces.findIndex((t) => t.traceId === prevTraceId)
|
|
220
|
+
if (newIndex >= 0) setSelectedTraceIndex(newIndex)
|
|
221
|
+
}
|
|
222
|
+
} catch (error) {
|
|
223
|
+
if (cancelled) return
|
|
224
|
+
setTraceState((current) => ({
|
|
225
|
+
...current,
|
|
226
|
+
status: "error",
|
|
227
|
+
error: error instanceof Error ? error.message : String(error),
|
|
228
|
+
}))
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
void load()
|
|
233
|
+
return () => {
|
|
234
|
+
cancelled = true
|
|
235
|
+
}
|
|
236
|
+
}, [refreshNonce, selectedTraceService, activeAttrKey, activeAttrValue, debouncedAiText, setSelectedTraceIndex, setSelectedTraceService, setTraceState])
|
|
237
|
+
|
|
238
|
+
useEffect(() => {
|
|
239
|
+
setSelectedTraceIndex((current) => {
|
|
240
|
+
return clampSelectionIndex(current, traceState.data.length)
|
|
241
|
+
})
|
|
242
|
+
}, [traceState.data.length, setSelectedTraceIndex])
|
|
243
|
+
|
|
244
|
+
const selectedTraceSummary = traceState.data[selectedTraceIndex] ?? null
|
|
245
|
+
const selectedTraceId = selectedTraceSummary?.traceId ?? null
|
|
246
|
+
const selectedTrace = traceDetailState.traceId === selectedTraceId ? traceDetailState.data : null
|
|
247
|
+
const selectedVisibleSpans = useMemo(
|
|
248
|
+
() => selectedTrace ? getVisibleSpans(selectedTrace.spans, collapsedSpanIds) : [],
|
|
249
|
+
[selectedTrace, collapsedSpanIds],
|
|
250
|
+
)
|
|
251
|
+
const selectedVisibleSpan = getSelectedVisibleSpan(selectedVisibleSpans, selectedSpanIndex)
|
|
252
|
+
selectedTraceRef.current = selectedTraceId
|
|
253
|
+
|
|
254
|
+
const warmTraceDetail = useCallback((traceId: string, hydrateSelection: boolean) => {
|
|
255
|
+
const cached = traceDetailCacheRef.current.get(traceId)
|
|
256
|
+
if (cached) {
|
|
257
|
+
if (hydrateSelection && selectedTraceRef.current === traceId) {
|
|
258
|
+
setTraceDetailState({ status: "ready", traceId, data: cached.data, error: null, fetchedAt: cached.fetchedAt })
|
|
259
|
+
}
|
|
260
|
+
return Promise.resolve({ error: null })
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const existing = traceDetailInflightRef.current.get(traceId)
|
|
264
|
+
if (existing) {
|
|
265
|
+
if (hydrateSelection) {
|
|
266
|
+
void existing.then(({ error }) => {
|
|
267
|
+
if (selectedTraceRef.current !== traceId) return
|
|
268
|
+
const ready = traceDetailCacheRef.current.get(traceId)
|
|
269
|
+
if (ready) {
|
|
270
|
+
setTraceDetailState({ status: "ready", traceId, data: ready.data, error: null, fetchedAt: ready.fetchedAt })
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
if (error) {
|
|
274
|
+
setTraceDetailState({ status: "error", traceId, data: null, error, fetchedAt: null })
|
|
275
|
+
}
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
return existing
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const epoch = cacheEpochRef.current
|
|
282
|
+
const request = loadTraceDetail(traceId)
|
|
283
|
+
.then((trace) => {
|
|
284
|
+
if (cacheEpochRef.current !== epoch) return { error: null }
|
|
285
|
+
const fetchedAt = new Date()
|
|
286
|
+
traceDetailCacheRef.current.set(traceId, { data: trace, fetchedAt })
|
|
287
|
+
if (hydrateSelection && selectedTraceRef.current === traceId) {
|
|
288
|
+
setTraceDetailState({ status: "ready", traceId, data: trace, error: null, fetchedAt })
|
|
289
|
+
}
|
|
290
|
+
return { error: null }
|
|
291
|
+
})
|
|
292
|
+
.catch((error) => {
|
|
293
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
294
|
+
if (cacheEpochRef.current === epoch && hydrateSelection && selectedTraceRef.current === traceId) {
|
|
295
|
+
setTraceDetailState({ status: "error", traceId, data: null, error: message, fetchedAt: null })
|
|
296
|
+
}
|
|
297
|
+
return { error: message }
|
|
298
|
+
})
|
|
299
|
+
.finally(() => {
|
|
300
|
+
traceDetailInflightRef.current.delete(traceId)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
traceDetailInflightRef.current.set(traceId, request)
|
|
304
|
+
return request
|
|
305
|
+
}, [setTraceDetailState])
|
|
306
|
+
|
|
307
|
+
const warmTraceLogs = useCallback((traceId: string, hydrateSelection: boolean) => {
|
|
308
|
+
const cached = traceLogCacheRef.current.get(traceId)
|
|
309
|
+
if (cached) {
|
|
310
|
+
if (hydrateSelection && selectedTraceRef.current === traceId) {
|
|
311
|
+
setLogState({ status: "ready", traceId, data: cached.data, error: null, fetchedAt: cached.fetchedAt })
|
|
312
|
+
}
|
|
313
|
+
return Promise.resolve({ error: null })
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const existing = traceLogInflightRef.current.get(traceId)
|
|
317
|
+
if (existing) {
|
|
318
|
+
if (hydrateSelection) {
|
|
319
|
+
void existing.then(({ error }) => {
|
|
320
|
+
if (selectedTraceRef.current !== traceId) return
|
|
321
|
+
const ready = traceLogCacheRef.current.get(traceId)
|
|
322
|
+
if (ready) {
|
|
323
|
+
setLogState({ status: "ready", traceId, data: ready.data, error: null, fetchedAt: ready.fetchedAt })
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
if (error) {
|
|
327
|
+
setLogState({ status: "error", traceId, data: [], error, fetchedAt: null })
|
|
328
|
+
}
|
|
329
|
+
})
|
|
330
|
+
}
|
|
331
|
+
return existing
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const epoch = cacheEpochRef.current
|
|
335
|
+
const request = loadTraceLogs(traceId)
|
|
336
|
+
.then((logs) => {
|
|
337
|
+
if (cacheEpochRef.current !== epoch) return { error: null }
|
|
338
|
+
const fetchedAt = new Date()
|
|
339
|
+
traceLogCacheRef.current.set(traceId, { data: logs, fetchedAt })
|
|
340
|
+
if (hydrateSelection && selectedTraceRef.current === traceId) {
|
|
341
|
+
setLogState({ status: "ready", traceId, data: logs, error: null, fetchedAt })
|
|
342
|
+
}
|
|
343
|
+
return { error: null }
|
|
344
|
+
})
|
|
345
|
+
.catch((error) => {
|
|
346
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
347
|
+
if (cacheEpochRef.current === epoch && hydrateSelection && selectedTraceRef.current === traceId) {
|
|
348
|
+
setLogState({ status: "error", traceId, data: [], error: message, fetchedAt: null })
|
|
349
|
+
}
|
|
350
|
+
return { error: message }
|
|
351
|
+
})
|
|
352
|
+
.finally(() => {
|
|
353
|
+
traceLogInflightRef.current.delete(traceId)
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
traceLogInflightRef.current.set(traceId, request)
|
|
357
|
+
return request
|
|
358
|
+
}, [setLogState])
|
|
359
|
+
|
|
360
|
+
useEffect(() => {
|
|
361
|
+
if (!selectedTraceId) {
|
|
362
|
+
setTraceDetailState(initialTraceDetailState)
|
|
363
|
+
return
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const cached = traceDetailCacheRef.current.get(selectedTraceId)
|
|
367
|
+
if (cached) {
|
|
368
|
+
setTraceDetailState({ status: "ready", traceId: selectedTraceId, data: cached.data, error: null, fetchedAt: cached.fetchedAt })
|
|
369
|
+
return
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
setTraceDetailState((current) => ({
|
|
373
|
+
status: current.traceId === selectedTraceId && current.fetchedAt !== null ? "ready" : "loading",
|
|
374
|
+
traceId: selectedTraceId,
|
|
375
|
+
data: current.traceId === selectedTraceId ? current.data : null,
|
|
376
|
+
error: null,
|
|
377
|
+
fetchedAt: current.traceId === selectedTraceId ? current.fetchedAt : null,
|
|
378
|
+
}))
|
|
379
|
+
|
|
380
|
+
void warmTraceDetail(selectedTraceId, true)
|
|
381
|
+
}, [refreshNonce, selectedTraceId, setTraceDetailState, warmTraceDetail])
|
|
382
|
+
|
|
383
|
+
useEffect(() => {
|
|
384
|
+
setCollapsedSpanIds(new Set())
|
|
385
|
+
setSelectedSpanIndex(null)
|
|
386
|
+
}, [selectedTraceId, setCollapsedSpanIds, setSelectedSpanIndex])
|
|
387
|
+
|
|
388
|
+
// Reset the attribute cursor whenever the span selection moves. Without
|
|
389
|
+
// this, drilling from span A (with 34 tags) to span B (with 3 tags)
|
|
390
|
+
// would leave the cursor pointing past the end of B's tag list until
|
|
391
|
+
// the user hit `j`/`k` again.
|
|
392
|
+
useEffect(() => {
|
|
393
|
+
setSelectedAttrIndex(0)
|
|
394
|
+
setChatDetailChunkId(null)
|
|
395
|
+
setChatDetailScrollOffset(0)
|
|
396
|
+
// New span → drop chunk selection and any open detail modal.
|
|
397
|
+
// The effect below will re-select the first chunk once the
|
|
398
|
+
// detail loads.
|
|
399
|
+
setSelectedChatChunkId(null)
|
|
400
|
+
}, [selectedSpanIndex, selectedTraceId, setSelectedAttrIndex, setChatDetailChunkId, setChatDetailScrollOffset, setSelectedChatChunkId])
|
|
401
|
+
|
|
402
|
+
// Load the parsed AI call detail for the currently-selected span when
|
|
403
|
+
// it's an AI span and the user is drilled into L2. Cached module-level
|
|
404
|
+
// so re-entering the chat view for a span we already loaded is free.
|
|
405
|
+
const selectedSpanId = selectedVisibleSpan?.spanId ?? null
|
|
406
|
+
const shouldLoadAiDetail = detailView === "span-detail" && selectedVisibleSpan !== null && isAiSpan(selectedVisibleSpan.tags)
|
|
407
|
+
|
|
408
|
+
useEffect(() => {
|
|
409
|
+
if (!shouldLoadAiDetail || !selectedSpanId) {
|
|
410
|
+
setAiCallDetailState(initialAiCallDetailState)
|
|
411
|
+
return
|
|
412
|
+
}
|
|
413
|
+
const cached = getCachedAiCallDetail(selectedSpanId)
|
|
414
|
+
if (cached !== undefined) {
|
|
415
|
+
setAiCallDetailState({ status: "ready", spanId: selectedSpanId, data: cached, error: null })
|
|
416
|
+
return
|
|
417
|
+
}
|
|
418
|
+
setAiCallDetailState({ status: "loading", spanId: selectedSpanId, data: null, error: null })
|
|
419
|
+
let cancelled = false
|
|
420
|
+
ensureAiCallDetail(selectedSpanId)
|
|
421
|
+
.then((data) => {
|
|
422
|
+
if (cancelled) return
|
|
423
|
+
setAiCallDetailState({ status: "ready", spanId: selectedSpanId, data, error: null })
|
|
424
|
+
})
|
|
425
|
+
.catch((err) => {
|
|
426
|
+
if (cancelled) return
|
|
427
|
+
setAiCallDetailState({
|
|
428
|
+
status: "error",
|
|
429
|
+
spanId: selectedSpanId,
|
|
430
|
+
data: null,
|
|
431
|
+
error: err instanceof Error ? err.message : String(err),
|
|
432
|
+
})
|
|
433
|
+
})
|
|
434
|
+
return () => { cancelled = true }
|
|
435
|
+
}, [shouldLoadAiDetail, selectedSpanId, setAiCallDetailState])
|
|
436
|
+
|
|
437
|
+
// Chunk model — rebuilt whenever the detail payload changes.
|
|
438
|
+
// Width-independent, so this lives here instead of in the view.
|
|
439
|
+
const aiChatChunks = useMemo<readonly Chunk[]>(() => {
|
|
440
|
+
if (!shouldLoadAiDetail || !aiCallDetailState.data) return []
|
|
441
|
+
return buildChunks(aiCallDetailState.data)
|
|
442
|
+
}, [shouldLoadAiDetail, aiCallDetailState.data])
|
|
443
|
+
|
|
444
|
+
// Once chunks are available, pin selection to the first chunk unless
|
|
445
|
+
// the user has already chosen one. Also handles the "chunk list
|
|
446
|
+
// changed and the previous selection disappeared" case.
|
|
447
|
+
useEffect(() => {
|
|
448
|
+
if (aiChatChunks.length === 0) return
|
|
449
|
+
const stillValid = selectedChatChunkId !== null
|
|
450
|
+
&& aiChatChunks.some((c) => c.id === selectedChatChunkId)
|
|
451
|
+
if (!stillValid) setSelectedChatChunkId(aiChatChunks[0]!.id)
|
|
452
|
+
}, [aiChatChunks, selectedChatChunkId, setSelectedChatChunkId])
|
|
453
|
+
|
|
454
|
+
useEffect(() => {
|
|
455
|
+
if (selectedSpanIndex === null) return
|
|
456
|
+
if (!selectedTrace || selectedTrace.spans.length === 0) {
|
|
457
|
+
setSelectedSpanIndex(null)
|
|
458
|
+
setDetailView("waterfall")
|
|
459
|
+
return
|
|
460
|
+
}
|
|
461
|
+
const visibleCount = selectedVisibleSpans.length
|
|
462
|
+
if (selectedSpanIndex >= visibleCount) {
|
|
463
|
+
setSelectedSpanIndex(visibleCount - 1)
|
|
464
|
+
}
|
|
465
|
+
}, [selectedTrace, selectedSpanIndex, selectedVisibleSpans.length, setDetailView, setSelectedSpanIndex])
|
|
466
|
+
|
|
467
|
+
useEffect(() => {
|
|
468
|
+
const traceId = selectedTraceId
|
|
469
|
+
if (!traceId) {
|
|
470
|
+
setLogState(initialLogState)
|
|
471
|
+
return
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const cached = traceLogCacheRef.current.get(traceId)
|
|
475
|
+
if (cached) {
|
|
476
|
+
setLogState({ status: "ready", traceId, data: cached.data, error: null, fetchedAt: cached.fetchedAt })
|
|
477
|
+
return
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
setLogState((current) => ({
|
|
481
|
+
status: current.traceId === traceId && current.fetchedAt !== null ? "ready" : "loading",
|
|
482
|
+
traceId,
|
|
483
|
+
data: current.traceId === traceId ? current.data : [],
|
|
484
|
+
error: null,
|
|
485
|
+
fetchedAt: current.traceId === traceId ? current.fetchedAt : null,
|
|
486
|
+
}))
|
|
487
|
+
|
|
488
|
+
void warmTraceLogs(traceId, true)
|
|
489
|
+
}, [refreshNonce, selectedTraceId, setLogState, warmTraceLogs])
|
|
490
|
+
|
|
491
|
+
useEffect(() => {
|
|
492
|
+
if (detailView !== "service-logs") return
|
|
493
|
+
const serviceName = selectedTraceService
|
|
494
|
+
if (!serviceName) {
|
|
495
|
+
setServiceLogState(initialServiceLogState)
|
|
496
|
+
return
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const cached = serviceLogCacheRef.current.get(serviceName)
|
|
500
|
+
if (cached) {
|
|
501
|
+
setServiceLogState({ status: "ready", serviceName, data: cached.data, error: null, fetchedAt: cached.fetchedAt })
|
|
502
|
+
return
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
let cancelled = false
|
|
506
|
+
setServiceLogState((current) => ({
|
|
507
|
+
status: current.serviceName === serviceName && current.fetchedAt !== null ? "ready" : "loading",
|
|
508
|
+
serviceName,
|
|
509
|
+
data: current.serviceName === serviceName ? current.data : [],
|
|
510
|
+
error: null,
|
|
511
|
+
fetchedAt: current.serviceName === serviceName ? current.fetchedAt : null,
|
|
512
|
+
}))
|
|
513
|
+
|
|
514
|
+
void (async () => {
|
|
515
|
+
try {
|
|
516
|
+
const logs = await loadServiceLogs(serviceName)
|
|
517
|
+
const fetchedAt = new Date()
|
|
518
|
+
serviceLogCacheRef.current.set(serviceName, { data: logs, fetchedAt })
|
|
519
|
+
if (cancelled) return
|
|
520
|
+
setServiceLogState({ status: "ready", serviceName, data: logs, error: null, fetchedAt })
|
|
521
|
+
} catch (error) {
|
|
522
|
+
if (cancelled) return
|
|
523
|
+
setServiceLogState({ status: "error", serviceName, data: [], error: error instanceof Error ? error.message : String(error), fetchedAt: null })
|
|
524
|
+
}
|
|
525
|
+
})()
|
|
526
|
+
|
|
527
|
+
return () => {
|
|
528
|
+
cancelled = true
|
|
529
|
+
}
|
|
530
|
+
}, [detailView, refreshNonce, selectedTraceService, setServiceLogState])
|
|
531
|
+
|
|
532
|
+
useEffect(() => {
|
|
533
|
+
setSelectedServiceLogIndex((current) => {
|
|
534
|
+
return clampSelectionIndex(current, serviceLogState.data.length)
|
|
535
|
+
})
|
|
536
|
+
}, [serviceLogState.data.length, setSelectedServiceLogIndex])
|
|
537
|
+
|
|
538
|
+
// Client-side filters: `:error` + operation-name needle both run
|
|
539
|
+
// against already-loaded summaries (no server round-trip). The `:ai`
|
|
540
|
+
// query, by contrast, is applied server-side in the load effect
|
|
541
|
+
// above so we don't need to re-filter it here.
|
|
542
|
+
const filteredTraces = useMemo(() => {
|
|
543
|
+
const preFiltered = applyClientTraceFilters(traceState.data, filterText, parsedFilter)
|
|
544
|
+
return sortTraceSummaries(preFiltered, traceSort)
|
|
545
|
+
}, [filterText, parsedFilter, traceSort, traceState.data])
|
|
546
|
+
|
|
547
|
+
useEffect(() => {
|
|
548
|
+
if (!selectedTraceId || filteredTraces.length === 0) return
|
|
549
|
+
const currentIndex = filteredTraces.findIndex((trace) => trace.traceId === selectedTraceId)
|
|
550
|
+
if (currentIndex < 0) return
|
|
551
|
+
|
|
552
|
+
for (const offset of [-1, 1] as const) {
|
|
553
|
+
const neighborId = filteredTraces[currentIndex + offset]?.traceId
|
|
554
|
+
if (!neighborId) continue
|
|
555
|
+
void warmTraceDetail(neighborId, false)
|
|
556
|
+
void warmTraceLogs(neighborId, false)
|
|
557
|
+
}
|
|
558
|
+
}, [filteredTraces, selectedTraceId, warmTraceDetail, warmTraceLogs])
|
|
559
|
+
|
|
560
|
+
return {
|
|
561
|
+
traceState,
|
|
562
|
+
traceDetailState,
|
|
563
|
+
logState,
|
|
564
|
+
serviceLogState,
|
|
565
|
+
selectedServiceLogIndex,
|
|
566
|
+
setSelectedServiceLogIndex,
|
|
567
|
+
selectedTraceIndex,
|
|
568
|
+
setSelectedTraceIndex,
|
|
569
|
+
selectedTraceService,
|
|
570
|
+
selectedSpanIndex,
|
|
571
|
+
setSelectedSpanIndex,
|
|
572
|
+
detailView,
|
|
573
|
+
setDetailView,
|
|
574
|
+
showHelp,
|
|
575
|
+
setShowHelp,
|
|
576
|
+
collapsedSpanIds,
|
|
577
|
+
autoRefresh,
|
|
578
|
+
filterMode,
|
|
579
|
+
filterText,
|
|
580
|
+
activeAttrKey,
|
|
581
|
+
activeAttrValue,
|
|
582
|
+
traceSort,
|
|
583
|
+
selectedTraceSummary,
|
|
584
|
+
selectedTrace,
|
|
585
|
+
selectedTraceId,
|
|
586
|
+
filteredTraces,
|
|
587
|
+
aiCallDetailState,
|
|
588
|
+
aiChatChunks,
|
|
589
|
+
} as const
|
|
590
|
+
}
|