@kitlangton/motel 0.2.4 → 0.2.5
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 +12 -0
- package/package.json +5 -1
- package/src/App.tsx +9 -0
- package/src/StartupGate.tsx +291 -0
- package/src/daemon.ts +2 -1
- package/src/index.tsx +9 -2
- package/src/localServer.ts +26 -16
- package/src/motel.ts +0 -2
- package/src/services/AsyncIngest.ts +9 -7
- package/src/services/LogQueryService.ts +2 -2
- package/src/services/TelemetryStore.ts +183 -52
- package/src/services/TraceQueryService.ts +6 -6
- package/src/startupBench.ts +19 -0
- package/src/ui/app/useTraceScreenData.ts +14 -6
- package/src/ui/atoms.ts +1 -1
- package/src/ui/persistence.ts +3 -3
- package/src/ui/theme.ts +7 -5
- package/src/ui/useKeyboardNav.ts +27 -1
package/AGENTS.md
CHANGED
|
@@ -31,6 +31,18 @@
|
|
|
31
31
|
- Effect LSP diagnostics over the whole project: `bunx effect-language-service diagnostics --project tsconfig.json --format text`
|
|
32
32
|
- Effect LSP interactive setup wizard: `bunx effect-language-service setup`
|
|
33
33
|
|
|
34
|
+
## Release Strategy
|
|
35
|
+
- npm package: `@kitlangton/motel`
|
|
36
|
+
- Current published npm `latest`: `0.2.4` (`npm view @kitlangton/motel dist-tags --json`)
|
|
37
|
+
- Tags are versioned as `vX.Y.Z` (`git tag --sort=-version:refname` shows `v0.2.4`, `v0.2.3`, ...)
|
|
38
|
+
- Publishing is handled by GitHub Actions in `.github/workflows/publish.yml`, not by local manual `npm publish`
|
|
39
|
+
- The publish workflow triggers on `git push` of tags matching `v*` or via manual `workflow_dispatch`
|
|
40
|
+
- The workflow runs `bun install --frozen-lockfile`, `bun run typecheck`, `bun run test`, then `npm publish --provenance`
|
|
41
|
+
- `npm publish` runs `prepublishOnly`, which builds the web UI via `bun run web:build`
|
|
42
|
+
- Before tagging a release, make sure the committed `package.json` version matches the intended git tag exactly
|
|
43
|
+
- Preferred release flow: update `package.json` version, commit the release changes, create tag `v<package.json version>`, push the commit and tag, then verify the GitHub Actions publish and npm dist-tags
|
|
44
|
+
- Do not create or push release tags from a dirty worktree with unrelated uncommitted changes; ask before including unrelated edits in a release
|
|
45
|
+
|
|
34
46
|
## Effect LSP
|
|
35
47
|
The repo is wired up with `@effect/language-service` as a `tsconfig.json` `plugins` entry. Editors that pick up the TypeScript workspace plugin (Zed, VSCode, Cursor, NVim via vtsls) will surface Effect-specific diagnostics, quick fixes, and refactors inline. In Zed this requires selecting the workspace TypeScript version — it does so automatically when `node_modules/typescript` is present.
|
|
36
48
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kitlangton/motel",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "A local OpenTelemetry ingest + TUI viewer for development, backed by SQLite.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -68,6 +68,10 @@
|
|
|
68
68
|
"web:dev": "bun run --cwd web dev",
|
|
69
69
|
"web:build": "bun run --cwd web build",
|
|
70
70
|
"story:chat": "bun run src/storybook/aiChatStory.tsx",
|
|
71
|
+
"bench:motel-cold-start": "bun run scripts/bench-motel-cold-start.ts",
|
|
72
|
+
"bench:ingest-logs": "bun run scripts/bench-ingest-logs.ts",
|
|
73
|
+
"bench:ingest-traces": "bun run scripts/bench-ingest-traces.ts",
|
|
74
|
+
"bench:search-spans": "bun run scripts/bench-search-spans.ts",
|
|
71
75
|
"typecheck": "tsc --noEmit",
|
|
72
76
|
"prepublishOnly": "bun run web:build"
|
|
73
77
|
},
|
package/src/App.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import { Divider, FooterHints, HelpModal, PlainLine, SplitDivider, TextLine } fr
|
|
|
7
7
|
import { useAppLayout } from "./ui/app/useAppLayout.ts"
|
|
8
8
|
import { useTraceScreenData } from "./ui/app/useTraceScreenData.ts"
|
|
9
9
|
import { TraceWorkspace } from "./ui/app/TraceWorkspace.tsx"
|
|
10
|
+
import { startupBenchMark } from "./startupBench.js"
|
|
10
11
|
import {
|
|
11
12
|
type AttrFacetState,
|
|
12
13
|
attrPickerIndexAtom,
|
|
@@ -32,6 +33,8 @@ import { getVisibleSpans } from "./ui/waterfallModel.ts"
|
|
|
32
33
|
|
|
33
34
|
const NOTICE_TIMEOUT_MS = 2500
|
|
34
35
|
|
|
36
|
+
startupBenchMark("app_module_loaded")
|
|
37
|
+
|
|
35
38
|
const buildHeaderModel = ({
|
|
36
39
|
headerFooterWidth,
|
|
37
40
|
selectedTraceService,
|
|
@@ -177,6 +180,7 @@ const AppOverlays = ({
|
|
|
177
180
|
)
|
|
178
181
|
|
|
179
182
|
export const App = () => {
|
|
183
|
+
startupBenchMark("app_render_started")
|
|
180
184
|
const { width, height } = useTerminalDimensions()
|
|
181
185
|
const [notice, setNotice] = useAtom(noticeAtom)
|
|
182
186
|
const [selectedTheme] = useAtom(selectedThemeAtom)
|
|
@@ -261,6 +265,10 @@ export const App = () => {
|
|
|
261
265
|
persistSelectedTheme(selectedTheme)
|
|
262
266
|
}, [selectedTheme])
|
|
263
267
|
|
|
268
|
+
useEffect(() => {
|
|
269
|
+
startupBenchMark("app_effects_committed")
|
|
270
|
+
}, [])
|
|
271
|
+
|
|
264
272
|
const { spanNavActive } = useKeyboardNav({
|
|
265
273
|
selectedTrace,
|
|
266
274
|
filteredTraces,
|
|
@@ -337,6 +345,7 @@ export const App = () => {
|
|
|
337
345
|
// above an empty column and leaves a visible stale sliver when
|
|
338
346
|
// toggling tab back and forth with the trace view.
|
|
339
347
|
const showSplit = isWideLayout && detailView !== "service-logs"
|
|
348
|
+
startupBenchMark("app_render_ready")
|
|
340
349
|
|
|
341
350
|
return (
|
|
342
351
|
<box width={width ?? 100} height={height ?? 24} flexGrow={1} flexDirection="column" backgroundColor={RGBA.fromHex(colors.screenBg)}>
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { RGBA, TextAttributes } from "@opentui/core"
|
|
3
|
+
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
|
|
4
|
+
import { useEffect, useMemo, useState } from "react"
|
|
5
|
+
import { App } from "./App.js"
|
|
6
|
+
import { createDaemonManager, ensureManagedDaemon, getManagedDaemonStatus, type DaemonStatus } from "./daemon.js"
|
|
7
|
+
import { MOTEL_SERVICE_ID } from "./registry.js"
|
|
8
|
+
import { Divider, PlainLine, TextLine } from "./ui/primitives.tsx"
|
|
9
|
+
import { colors } from "./ui/theme.ts"
|
|
10
|
+
|
|
11
|
+
type ConflictStatus = DaemonStatus & {
|
|
12
|
+
readonly service: typeof MOTEL_SERVICE_ID
|
|
13
|
+
readonly pid: number
|
|
14
|
+
readonly workdir: string
|
|
15
|
+
readonly reason: string
|
|
16
|
+
readonly sameWorkdir: false
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type ConflictScreenState = {
|
|
20
|
+
kind: "conflict"
|
|
21
|
+
message: string
|
|
22
|
+
status: ConflictStatus
|
|
23
|
+
busy: boolean
|
|
24
|
+
notice: string | null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type ErrorScreenState = {
|
|
28
|
+
kind: "error"
|
|
29
|
+
message: string
|
|
30
|
+
busy: boolean
|
|
31
|
+
notice: string | null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type StartupState =
|
|
35
|
+
| { kind: "loading"; message: string }
|
|
36
|
+
| { kind: "ready" }
|
|
37
|
+
| ConflictScreenState
|
|
38
|
+
| ErrorScreenState
|
|
39
|
+
|
|
40
|
+
type RecoveryAction = {
|
|
41
|
+
readonly key: string
|
|
42
|
+
readonly label: string
|
|
43
|
+
readonly run: () => Promise<void>
|
|
44
|
+
readonly disabled?: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const readStatus = () => Effect.runPromise(getManagedDaemonStatus)
|
|
48
|
+
const startDaemon = () => Effect.runPromise(ensureManagedDaemon)
|
|
49
|
+
|
|
50
|
+
const parsePort = (url: string) => {
|
|
51
|
+
try {
|
|
52
|
+
const port = Number(new URL(url).port)
|
|
53
|
+
return Number.isFinite(port) && port > 0 ? port : undefined
|
|
54
|
+
} catch {
|
|
55
|
+
return undefined
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const isRecoverableConflict = (status: DaemonStatus | null): status is ConflictStatus =>
|
|
60
|
+
status !== null &&
|
|
61
|
+
status.service === MOTEL_SERVICE_ID &&
|
|
62
|
+
status.pid !== null &&
|
|
63
|
+
status.workdir !== null &&
|
|
64
|
+
status.reason !== null &&
|
|
65
|
+
!status.sameWorkdir
|
|
66
|
+
|
|
67
|
+
const stopConflictingDaemon = async (status: ConflictStatus) => {
|
|
68
|
+
const port = parsePort(status.url)
|
|
69
|
+
const manager = createDaemonManager({
|
|
70
|
+
workdir: status.workdir ?? undefined,
|
|
71
|
+
databasePath: status.databasePath,
|
|
72
|
+
port,
|
|
73
|
+
})
|
|
74
|
+
await Effect.runPromise(manager.stop)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const LoadingScreen = ({ width, height, message }: { width: number; height: number; message: string }) => {
|
|
78
|
+
const panelWidth = Math.min(76, Math.max(50, width - 8))
|
|
79
|
+
const left = Math.max(0, Math.floor((width - panelWidth) / 2))
|
|
80
|
+
const top = Math.max(0, Math.floor((height - 5) / 2))
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<box width={width} height={height} backgroundColor={RGBA.fromHex(colors.screenBg)}>
|
|
84
|
+
<box position="absolute" left={left} top={top} width={panelWidth} flexDirection="column">
|
|
85
|
+
<TextLine>
|
|
86
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>MOTEL</span>
|
|
87
|
+
<span fg={colors.separator}>{" · "}</span>
|
|
88
|
+
<span fg={colors.muted}>starting up...</span>
|
|
89
|
+
</TextLine>
|
|
90
|
+
<Divider width={panelWidth} />
|
|
91
|
+
<box paddingTop={1}>
|
|
92
|
+
<PlainLine text={message} fg={colors.count} />
|
|
93
|
+
</box>
|
|
94
|
+
</box>
|
|
95
|
+
</box>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const RecoveryScreen = ({
|
|
100
|
+
title,
|
|
101
|
+
message,
|
|
102
|
+
width,
|
|
103
|
+
height,
|
|
104
|
+
detailLines,
|
|
105
|
+
actions,
|
|
106
|
+
selectedIndex,
|
|
107
|
+
notice,
|
|
108
|
+
busy,
|
|
109
|
+
}: {
|
|
110
|
+
readonly title: string
|
|
111
|
+
readonly message: string
|
|
112
|
+
readonly width: number
|
|
113
|
+
readonly height: number
|
|
114
|
+
readonly detailLines: readonly string[]
|
|
115
|
+
readonly actions: readonly RecoveryAction[]
|
|
116
|
+
readonly selectedIndex: number
|
|
117
|
+
readonly notice: string | null
|
|
118
|
+
readonly busy: boolean
|
|
119
|
+
}) => {
|
|
120
|
+
const panelWidth = Math.min(96, Math.max(64, width - 8))
|
|
121
|
+
const left = Math.max(0, Math.floor((width - panelWidth) / 2))
|
|
122
|
+
const bodyHeight = 9 + detailLines.length + actions.length + (notice ? 2 : 0)
|
|
123
|
+
const top = Math.max(0, Math.floor((height - bodyHeight) / 2))
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<box width={width} height={height} backgroundColor={RGBA.fromHex(colors.screenBg)}>
|
|
127
|
+
<box position="absolute" left={left} top={top} width={panelWidth} flexDirection="column">
|
|
128
|
+
<TextLine>
|
|
129
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>MOTEL</span>
|
|
130
|
+
<span fg={colors.separator}>{" · "}</span>
|
|
131
|
+
<span fg={colors.error} attributes={TextAttributes.BOLD}>{title}</span>
|
|
132
|
+
</TextLine>
|
|
133
|
+
<Divider width={panelWidth} />
|
|
134
|
+
<box paddingTop={1} paddingBottom={1} flexDirection="column">
|
|
135
|
+
<PlainLine text={message} fg={colors.text} />
|
|
136
|
+
</box>
|
|
137
|
+
{detailLines.map((line, index) => (
|
|
138
|
+
<PlainLine key={`${index}:${line}`} text={line} fg={colors.muted} />
|
|
139
|
+
))}
|
|
140
|
+
<box paddingTop={1} flexDirection="column">
|
|
141
|
+
{actions.map((action, index) => {
|
|
142
|
+
const selected = index === selectedIndex
|
|
143
|
+
const prefix = selected ? ">" : " "
|
|
144
|
+
const text = `${prefix} [${action.key}] ${action.label}${action.disabled ? " (unavailable)" : ""}`
|
|
145
|
+
return (
|
|
146
|
+
<TextLine key={action.key} bg={selected ? colors.selectedBg : undefined}>
|
|
147
|
+
<span fg={selected ? colors.selectedText : colors.text}>{text}</span>
|
|
148
|
+
</TextLine>
|
|
149
|
+
)
|
|
150
|
+
})}
|
|
151
|
+
</box>
|
|
152
|
+
<box paddingTop={1} flexDirection="column">
|
|
153
|
+
{notice ? <PlainLine text={notice} fg={busy ? colors.warning : colors.count} /> : null}
|
|
154
|
+
<PlainLine text={busy ? "Working..." : "j/k or ↑↓ select · enter run · r retry · k kill conflicting daemon · q quit"} fg={colors.count} />
|
|
155
|
+
</box>
|
|
156
|
+
</box>
|
|
157
|
+
</box>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export const StartupGate = () => {
|
|
162
|
+
const renderer = useRenderer()
|
|
163
|
+
const { width = 100, height = 24 } = useTerminalDimensions()
|
|
164
|
+
const [startupState, setStartupState] = useState<StartupState>({ kind: "loading", message: "Checking managed daemon..." })
|
|
165
|
+
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
166
|
+
|
|
167
|
+
const attemptStart = async () => {
|
|
168
|
+
setStartupState({ kind: "loading", message: "Checking managed daemon..." })
|
|
169
|
+
try {
|
|
170
|
+
await startDaemon()
|
|
171
|
+
setStartupState({ kind: "ready" })
|
|
172
|
+
} catch (error) {
|
|
173
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
174
|
+
const status = await readStatus().catch(() => null)
|
|
175
|
+
if (isRecoverableConflict(status)) {
|
|
176
|
+
setSelectedIndex(0)
|
|
177
|
+
setStartupState({ kind: "conflict", message, status, busy: false, notice: null })
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
setSelectedIndex(0)
|
|
181
|
+
setStartupState({ kind: "error", message, busy: false, notice: null })
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
void attemptStart()
|
|
187
|
+
}, [])
|
|
188
|
+
|
|
189
|
+
const actions = useMemo<readonly RecoveryAction[]>(() => {
|
|
190
|
+
if (startupState.kind === "conflict") {
|
|
191
|
+
return [
|
|
192
|
+
{ key: "r", label: "Retry startup", run: attemptStart },
|
|
193
|
+
{
|
|
194
|
+
key: "k",
|
|
195
|
+
label: `Stop conflicting daemon (${startupState.status.pid})`,
|
|
196
|
+
run: async () => {
|
|
197
|
+
setStartupState((current) => current.kind === "conflict"
|
|
198
|
+
? { ...current, busy: true, notice: `Stopping daemon ${current.status.pid}...` }
|
|
199
|
+
: current)
|
|
200
|
+
try {
|
|
201
|
+
await stopConflictingDaemon(startupState.status)
|
|
202
|
+
await attemptStart()
|
|
203
|
+
} catch (error) {
|
|
204
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
205
|
+
setStartupState((current) => current.kind === "conflict"
|
|
206
|
+
? { ...current, busy: false, notice: message }
|
|
207
|
+
: current)
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
{ key: "q", label: "Quit", run: async () => { renderer.destroy() } },
|
|
212
|
+
]
|
|
213
|
+
}
|
|
214
|
+
if (startupState.kind === "error") {
|
|
215
|
+
return [
|
|
216
|
+
{ key: "r", label: "Retry startup", run: attemptStart },
|
|
217
|
+
{ key: "q", label: "Quit", run: async () => { renderer.destroy() } },
|
|
218
|
+
]
|
|
219
|
+
}
|
|
220
|
+
return []
|
|
221
|
+
}, [renderer, startupState])
|
|
222
|
+
|
|
223
|
+
useKeyboard((key) => {
|
|
224
|
+
if (startupState.kind === "ready" || startupState.kind === "loading" || key.repeated) return
|
|
225
|
+
if (key.name === "q" || (key.ctrl && key.name === "c")) {
|
|
226
|
+
renderer.destroy()
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
if (key.name === "up" || key.name === "k") {
|
|
230
|
+
setSelectedIndex((current) => (current + actions.length - 1) % actions.length)
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
if (key.name === "down" || key.name === "j") {
|
|
234
|
+
setSelectedIndex((current) => (current + 1) % actions.length)
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
if (key.name === "r") {
|
|
238
|
+
void actions.find((action) => action.key === "r")?.run()
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
if (key.name === "k") {
|
|
242
|
+
void actions.find((action) => action.key === "k")?.run()
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
if (key.name === "return" || key.name === "enter") {
|
|
246
|
+
void actions[selectedIndex]?.run()
|
|
247
|
+
}
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
if (startupState.kind === "ready") return <App />
|
|
251
|
+
if (startupState.kind === "loading") return <LoadingScreen width={width} height={height} message={startupState.message} />
|
|
252
|
+
if (startupState.kind === "conflict") {
|
|
253
|
+
const status = startupState.status
|
|
254
|
+
const detailLines = [
|
|
255
|
+
`Port: ${status.url}`,
|
|
256
|
+
`Conflicting workdir: ${status.workdir}`,
|
|
257
|
+
`Conflicting pid: ${status.pid}`,
|
|
258
|
+
`Database: ${status.databasePath}`,
|
|
259
|
+
status.workdir.startsWith("/tmp") || status.workdir.startsWith("/private/tmp")
|
|
260
|
+
? "This looks like a temp/test daemon."
|
|
261
|
+
: "This looks like a real motel daemon started from another project.",
|
|
262
|
+
]
|
|
263
|
+
return (
|
|
264
|
+
<RecoveryScreen
|
|
265
|
+
title="Daemon Conflict"
|
|
266
|
+
message={startupState.message}
|
|
267
|
+
width={width}
|
|
268
|
+
height={height}
|
|
269
|
+
detailLines={detailLines}
|
|
270
|
+
actions={actions}
|
|
271
|
+
selectedIndex={selectedIndex}
|
|
272
|
+
notice={startupState.notice}
|
|
273
|
+
busy={startupState.busy}
|
|
274
|
+
/>
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return (
|
|
279
|
+
<RecoveryScreen
|
|
280
|
+
title="Startup Error"
|
|
281
|
+
message={startupState.message}
|
|
282
|
+
width={width}
|
|
283
|
+
height={height}
|
|
284
|
+
detailLines={["Retry startup or quit the TUI."]}
|
|
285
|
+
actions={actions}
|
|
286
|
+
selectedIndex={selectedIndex}
|
|
287
|
+
notice={startupState.notice}
|
|
288
|
+
busy={startupState.busy}
|
|
289
|
+
/>
|
|
290
|
+
)
|
|
291
|
+
}
|
package/src/daemon.ts
CHANGED
|
@@ -10,6 +10,7 @@ const DEFAULT_PORT = 27686
|
|
|
10
10
|
const START_TIMEOUT_MS = 30_000
|
|
11
11
|
const STOP_TIMEOUT_MS = 10_000
|
|
12
12
|
const LOCK_TIMEOUT_MS = 10_000
|
|
13
|
+
const START_POLL_INTERVAL_MS = 25
|
|
13
14
|
const POLL_INTERVAL_MS = 150
|
|
14
15
|
/** Fast probe used inside the waitForHealthy poll loop — we call it
|
|
15
16
|
* every POLL_INTERVAL_MS, so a generous budget would stall the loop. */
|
|
@@ -329,7 +330,7 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
|
|
|
329
330
|
}
|
|
330
331
|
throw new Error(`Daemon process ${pid} exited before becoming healthy. See ${config.logPath}.`)
|
|
331
332
|
}
|
|
332
|
-
await sleep(
|
|
333
|
+
await sleep(START_POLL_INTERVAL_MS)
|
|
333
334
|
}
|
|
334
335
|
throw new Error(`Timed out waiting for daemon health at ${config.baseUrl}/api/health. See ${config.logPath}.`)
|
|
335
336
|
}
|
package/src/index.tsx
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { RegistryProvider } from "@effect/atom-react"
|
|
2
2
|
import { createCliRenderer } from "@opentui/core"
|
|
3
3
|
import { createRoot } from "@opentui/react"
|
|
4
|
-
import {
|
|
4
|
+
import { startupBenchMark } from "./startupBench.js"
|
|
5
|
+
import { StartupGate } from "./StartupGate.js"
|
|
6
|
+
|
|
7
|
+
startupBenchMark("index_module_loaded")
|
|
5
8
|
|
|
6
9
|
const renderer = await createCliRenderer({
|
|
7
10
|
exitOnCtrlC: false,
|
|
@@ -11,8 +14,12 @@ const renderer = await createCliRenderer({
|
|
|
11
14
|
},
|
|
12
15
|
})
|
|
13
16
|
|
|
17
|
+
startupBenchMark("renderer_ready")
|
|
18
|
+
|
|
14
19
|
createRoot(renderer).render(
|
|
15
20
|
<RegistryProvider>
|
|
16
|
-
<
|
|
21
|
+
<StartupGate />
|
|
17
22
|
</RegistryProvider>,
|
|
18
23
|
)
|
|
24
|
+
|
|
25
|
+
startupBenchMark("root_render_called")
|
package/src/localServer.ts
CHANGED
|
@@ -12,7 +12,9 @@ import { MotelHttpApi } from "./httpApi.js"
|
|
|
12
12
|
import { attributeFiltersFromEntries, attributeContainsFiltersFromEntries } from "./queryFilters.js"
|
|
13
13
|
import { MOTEL_SERVICE_ID, MOTEL_VERSION, removeRegistryEntry, writeRegistryEntry } from "./registry.js"
|
|
14
14
|
import { AsyncIngest, AsyncIngestLive } from "./services/AsyncIngest.js"
|
|
15
|
-
import {
|
|
15
|
+
import { LogQueryService, LogQueryServiceLive } from "./services/LogQueryService.js"
|
|
16
|
+
import { TelemetryStore, TelemetryStoreLive, TelemetryStoreReadonlyLive } from "./services/TelemetryStore.js"
|
|
17
|
+
import { TraceQueryService, TraceQueryServiceLive } from "./services/TraceQueryService.js"
|
|
16
18
|
import type { LogItem, TraceItem, TraceSummaryItem } from "./domain.js"
|
|
17
19
|
import { lifecycleLabel } from "./ui/format.js"
|
|
18
20
|
|
|
@@ -39,6 +41,8 @@ const htmlResponse = (value: string) => HttpServerResponse.html(value)
|
|
|
39
41
|
const notFoundResponse = (message = "Not found") => jsonResponse({ error: message }, 404)
|
|
40
42
|
const requestUrl = (request: { readonly url: string }) => new URL(request.url, config.otel.baseUrl)
|
|
41
43
|
const withStore = <A>(f: (store: TelemetryStore["Service"]) => Effect.Effect<A, Error>) => Effect.flatMap(TelemetryStore.asEffect(), f)
|
|
44
|
+
const withTraceQuery = <A>(f: (query: TraceQueryService["Service"]) => Effect.Effect<A, Error>) => Effect.flatMap(TraceQueryService.asEffect(), f)
|
|
45
|
+
const withLogQuery = <A>(f: (query: LogQueryService["Service"]) => Effect.Effect<A, Error>) => Effect.flatMap(LogQueryService.asEffect(), f)
|
|
42
46
|
// Response-building helpers are generic in R so a handler can depend
|
|
43
47
|
// on TelemetryStore (query path) or AsyncIngest (worker-RPC path)
|
|
44
48
|
// without forcing every handler onto the same service surface.
|
|
@@ -149,7 +153,7 @@ const loadLogsPage = (input: {
|
|
|
149
153
|
readonly lookbackMinutes: number
|
|
150
154
|
readonly cursor: CursorShape | null
|
|
151
155
|
}) =>
|
|
152
|
-
Effect.flatMap(
|
|
156
|
+
Effect.flatMap(LogQueryService.asEffect(), (store) =>
|
|
153
157
|
Effect.map(
|
|
154
158
|
store.searchLogs({
|
|
155
159
|
serviceName: input.serviceName,
|
|
@@ -312,7 +316,7 @@ const TelemetryGroupLive = HttpApiBuilder.group(
|
|
|
312
316
|
),
|
|
313
317
|
),
|
|
314
318
|
)
|
|
315
|
-
.handleRaw("services", () => respondJson(Effect.map(
|
|
319
|
+
.handleRaw("services", () => respondJson(Effect.map(withTraceQuery((store) => store.listServices), (data) => ({ data }))))
|
|
316
320
|
.handleRaw("traces", ({ request }) =>
|
|
317
321
|
respondRaw(Effect.gen(function*() {
|
|
318
322
|
const url = requestUrl(request)
|
|
@@ -320,7 +324,7 @@ const TelemetryGroupLive = HttpApiBuilder.group(
|
|
|
320
324
|
const limit = parseBoundedLimit(url.searchParams.get("limit"), TRACE_DEFAULT_LIMIT, TRACE_MAX_LIMIT)
|
|
321
325
|
const lookbackMinutes = parseBoundedLookbackMinutes(url.searchParams.get("lookback"), TRACE_DEFAULT_LOOKBACK, TRACE_MAX_LOOKBACK)
|
|
322
326
|
const cursor = decodeCursor(url.searchParams.get("cursor"))
|
|
323
|
-
const data = yield*
|
|
327
|
+
const data = yield* withTraceQuery((store) => store.listTraceSummaries(service, {
|
|
324
328
|
limit: limit + 1,
|
|
325
329
|
lookbackMinutes,
|
|
326
330
|
cursorStartedAtMs: cursor?.kind === "trace" ? cursor.startedAt : undefined,
|
|
@@ -336,7 +340,7 @@ const TelemetryGroupLive = HttpApiBuilder.group(
|
|
|
336
340
|
const limit = parseBoundedLimit(url.searchParams.get("limit"), TRACE_DEFAULT_LIMIT, TRACE_MAX_LIMIT)
|
|
337
341
|
const lookbackMinutes = parseBoundedLookbackMinutes(url.searchParams.get("lookback"), TRACE_DEFAULT_LOOKBACK, TRACE_MAX_LOOKBACK)
|
|
338
342
|
const cursor = decodeCursor(url.searchParams.get("cursor"))
|
|
339
|
-
const data = yield*
|
|
343
|
+
const data = yield* withTraceQuery((store) =>
|
|
340
344
|
store.searchTraceSummaries({
|
|
341
345
|
serviceName: url.searchParams.get("service"),
|
|
342
346
|
operation: url.searchParams.get("operation"),
|
|
@@ -363,7 +367,7 @@ const TelemetryGroupLive = HttpApiBuilder.group(
|
|
|
363
367
|
if (!groupBy || (agg !== "count" && agg !== "avg_duration" && agg !== "p95_duration" && agg !== "error_rate")) {
|
|
364
368
|
return jsonResponse({ error: "Expected groupBy and agg=count|avg_duration|p95_duration|error_rate" }, 400)
|
|
365
369
|
}
|
|
366
|
-
const data = yield*
|
|
370
|
+
const data = yield* withTraceQuery((store) =>
|
|
367
371
|
store.traceStats({
|
|
368
372
|
groupBy,
|
|
369
373
|
agg,
|
|
@@ -386,7 +390,7 @@ const TelemetryGroupLive = HttpApiBuilder.group(
|
|
|
386
390
|
const attributeContainsFilters = attributeContainsFiltersFromQuery(url)
|
|
387
391
|
const limit = parseBoundedLimit(url.searchParams.get("limit"), SPAN_DEFAULT_LIMIT, SPAN_MAX_LIMIT)
|
|
388
392
|
const lookbackMinutes = parseBoundedLookbackMinutes(url.searchParams.get("lookback"), TRACE_DEFAULT_LOOKBACK, TRACE_MAX_LOOKBACK)
|
|
389
|
-
const data = yield*
|
|
393
|
+
const data = yield* withTraceQuery((store) =>
|
|
390
394
|
store.searchSpans({
|
|
391
395
|
serviceName: url.searchParams.get("service"),
|
|
392
396
|
traceId: url.searchParams.get("traceId"),
|
|
@@ -423,7 +427,7 @@ const TelemetryGroupLive = HttpApiBuilder.group(
|
|
|
423
427
|
})),
|
|
424
428
|
)
|
|
425
429
|
.handleRaw("traceSpans", ({ params }) =>
|
|
426
|
-
respondJson(Effect.map(
|
|
430
|
+
respondJson(Effect.map(withTraceQuery((store) => store.listTraceSpans(params.traceId)), (data) => ({ data }))),
|
|
427
431
|
)
|
|
428
432
|
.handleRaw("spanLogs", ({ params, request }) =>
|
|
429
433
|
respondRaw(Effect.gen(function*() {
|
|
@@ -436,14 +440,14 @@ const TelemetryGroupLive = HttpApiBuilder.group(
|
|
|
436
440
|
)
|
|
437
441
|
.handleRaw("span", ({ params }) =>
|
|
438
442
|
respondRaw(
|
|
439
|
-
Effect.flatMap(
|
|
443
|
+
Effect.flatMap(withTraceQuery((store) => store.getSpan(params.spanId)), (data) =>
|
|
440
444
|
Effect.succeed(data ? jsonResponse({ data }) : notFoundResponse("Span not found")),
|
|
441
445
|
),
|
|
442
446
|
),
|
|
443
447
|
)
|
|
444
448
|
.handleRaw("trace", ({ params }) =>
|
|
445
449
|
respondRaw(
|
|
446
|
-
Effect.flatMap(
|
|
450
|
+
Effect.flatMap(withTraceQuery((store) => store.getTrace(params.traceId)), (data) =>
|
|
447
451
|
Effect.succeed(data ? jsonResponse({ data }) : notFoundResponse("Trace not found")),
|
|
448
452
|
),
|
|
449
453
|
),
|
|
@@ -460,7 +464,7 @@ const TelemetryGroupLive = HttpApiBuilder.group(
|
|
|
460
464
|
if (!groupBy || agg !== "count") {
|
|
461
465
|
return jsonResponse({ error: "Expected groupBy and agg=count" }, 400)
|
|
462
466
|
}
|
|
463
|
-
const data = yield*
|
|
467
|
+
const data = yield* withLogQuery((store) =>
|
|
464
468
|
store.logStats({
|
|
465
469
|
groupBy,
|
|
466
470
|
agg: "count",
|
|
@@ -508,7 +512,7 @@ const TelemetryGroupLive = HttpApiBuilder.group(
|
|
|
508
512
|
if ((type !== "traces" && type !== "logs") || !field) {
|
|
509
513
|
return jsonResponse({ error: "Expected type=traces|logs and field=<name>" }, 400)
|
|
510
514
|
}
|
|
511
|
-
const data = yield*
|
|
515
|
+
const data = yield* withTraceQuery((store) =>
|
|
512
516
|
store.listFacets({
|
|
513
517
|
type,
|
|
514
518
|
field,
|
|
@@ -586,9 +590,9 @@ const TelemetryGroupLive = HttpApiBuilder.group(
|
|
|
586
590
|
)
|
|
587
591
|
.handleRaw("tracePage", ({ params }) =>
|
|
588
592
|
respondRaw(
|
|
589
|
-
Effect.flatMap(
|
|
593
|
+
Effect.flatMap(withTraceQuery((store) => store.getTrace(params.traceId)), (trace) =>
|
|
590
594
|
trace
|
|
591
|
-
? Effect.map(
|
|
595
|
+
? Effect.map(withLogQuery((store) => store.listTraceLogs(params.traceId)), (logs) => htmlResponse(renderTracePage(trace, logs)))
|
|
592
596
|
: Effect.succeed(notFoundResponse("Trace not found")),
|
|
593
597
|
),
|
|
594
598
|
),
|
|
@@ -606,6 +610,10 @@ const ApiLayer = HttpApiBuilder.layer(MotelHttpApi, { openapiPath: "/openapi.jso
|
|
|
606
610
|
Layer.provide(HttpApiScalar.layer(MotelHttpApi, { scalar: { forceDarkModeState: "dark", showOperationId: true } })),
|
|
607
611
|
)
|
|
608
612
|
|
|
613
|
+
const QueryServicesLive = Layer.mergeAll(TraceQueryServiceLive, LogQueryServiceLive).pipe(
|
|
614
|
+
Layer.provideMerge(TelemetryStoreReadonlyLive),
|
|
615
|
+
)
|
|
616
|
+
|
|
609
617
|
// Web UI: Vite-built SPA served from web/dist. HttpStaticServer.layer
|
|
610
618
|
// handles GET /*, filesystem lookup under `root`, and SPA fallback to
|
|
611
619
|
// index.html for unknown paths — replacing the hand-rolled serveWebUi
|
|
@@ -675,9 +683,11 @@ export const ServerLive = HttpRouter.serve(
|
|
|
675
683
|
Layer.provide(HttpMiddleware.layerTracerDisabledForUrls(["/v1/traces", "/v1/logs"])),
|
|
676
684
|
// AsyncIngest spawns the telemetry worker — keeps the main-thread
|
|
677
685
|
// event loop free during heavy SQLite writes. Provided alongside
|
|
678
|
-
// the
|
|
679
|
-
//
|
|
686
|
+
// the writer TelemetryStore for ingest / maintenance. Query endpoints
|
|
687
|
+
// resolve through readonly TraceQueryService / LogQueryService so
|
|
688
|
+
// reads do not contend with the writer connection.
|
|
680
689
|
Layer.provideMerge(AsyncIngestLive),
|
|
690
|
+
Layer.provideMerge(QueryServicesLive),
|
|
681
691
|
Layer.provideMerge(TelemetryStoreLive),
|
|
682
692
|
Layer.provideMerge(BunHttpServer.layer({
|
|
683
693
|
port: config.otel.port,
|
package/src/motel.ts
CHANGED
|
@@ -12,7 +12,6 @@ case undefined:
|
|
|
12
12
|
case "tui":
|
|
13
13
|
case "ui": {
|
|
14
14
|
await run(applyManagedDaemonEnv)
|
|
15
|
-
await run(ensureManagedDaemon)
|
|
16
15
|
await import("./index.js")
|
|
17
16
|
break
|
|
18
17
|
}
|
|
@@ -42,7 +41,6 @@ case "restart": {
|
|
|
42
41
|
// and want the TUI to reconnect to the new binary in one command.
|
|
43
42
|
await run(stopManagedDaemon)
|
|
44
43
|
await run(applyManagedDaemonEnv)
|
|
45
|
-
await run(ensureManagedDaemon)
|
|
46
44
|
await import("./index.js")
|
|
47
45
|
break
|
|
48
46
|
}
|
|
@@ -55,14 +55,16 @@ export const AsyncIngestLive = Layer.effect(
|
|
|
55
55
|
// spawn the worker and make /api/health wait on the worker's SQLite
|
|
56
56
|
// bootstrap. Cache a lazy initializer instead so the worker only starts
|
|
57
57
|
// on the first ingest request, but is still shared thereafter.
|
|
58
|
-
const getClient = yield*
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
58
|
+
const getClient = yield* Effect.gen(function*() {
|
|
59
|
+
const protocolContext = yield* Layer.buildWithScope(WorkerProtocol, scope)
|
|
60
|
+
return yield* RpcClient.make(IngestRpcs).pipe(
|
|
61
|
+
Effect.provide(protocolContext),
|
|
62
|
+
Effect.provideService(Scope.Scope, scope),
|
|
63
|
+
)
|
|
64
|
+
}).pipe(Effect.cached)
|
|
63
65
|
return {
|
|
64
|
-
ingestTraces: (input, options) => Effect.flatMap(
|
|
65
|
-
ingestLogs: (input, options) => Effect.flatMap(
|
|
66
|
+
ingestTraces: (input, options) => Effect.flatMap(getClient, (client) => client.ingestTraces(input, options)),
|
|
67
|
+
ingestLogs: (input, options) => Effect.flatMap(getClient, (client) => client.ingestLogs(input, options)),
|
|
66
68
|
}
|
|
67
69
|
}),
|
|
68
70
|
)
|
|
@@ -7,8 +7,8 @@ export class LogQueryService extends Context.Service<
|
|
|
7
7
|
{
|
|
8
8
|
readonly listRecentLogs: (serviceName: string) => Effect.Effect<readonly LogItem[], Error>
|
|
9
9
|
readonly listTraceLogs: (traceId: string) => Effect.Effect<readonly LogItem[], Error>
|
|
10
|
-
readonly searchLogs: (input: { readonly serviceName?: string; readonly traceId?: string; readonly spanId?: string; readonly body?: string; readonly limit?: number; readonly attributeFilters?: Readonly<Record<string, string>> }) => Effect.Effect<readonly LogItem[], Error>
|
|
11
|
-
readonly logStats: (input: { readonly groupBy: string; readonly agg: "count"; readonly serviceName?: string | null; readonly traceId?: string | null; readonly spanId?: string | null; readonly body?: string | null; readonly limit?: number; readonly attributeFilters?: Readonly<Record<string, string>> }) => Effect.Effect<readonly { readonly group: string; readonly value: number; readonly count: number }[], Error>
|
|
10
|
+
readonly searchLogs: (input: { readonly serviceName?: string | null; readonly severity?: string | null; readonly traceId?: string | null; readonly spanId?: string | null; readonly body?: string | null; readonly lookbackMinutes?: number; readonly limit?: number; readonly cursorTimestampMs?: number; readonly cursorId?: string; readonly attributeFilters?: Readonly<Record<string, string>>; readonly attributeContainsFilters?: Readonly<Record<string, string>> }) => Effect.Effect<readonly LogItem[], Error>
|
|
11
|
+
readonly logStats: (input: { readonly groupBy: string; readonly agg: "count"; readonly serviceName?: string | null; readonly traceId?: string | null; readonly spanId?: string | null; readonly body?: string | null; readonly lookbackMinutes?: number; readonly limit?: number; readonly attributeFilters?: Readonly<Record<string, string>>; readonly attributeContainsFilters?: Readonly<Record<string, string>> }) => Effect.Effect<readonly { readonly group: string; readonly value: number; readonly count: number }[], Error>
|
|
12
12
|
readonly listFacets: (input: { readonly type: "traces" | "logs"; readonly field: string; readonly serviceName?: string | null; readonly lookbackMinutes?: number; readonly limit?: number }) => Effect.Effect<readonly { readonly value: string; readonly count: number }[], Error>
|
|
13
13
|
}
|
|
14
14
|
>()("motel/LogQueryService") {}
|
|
@@ -797,10 +797,82 @@ export const makeTelemetryStoreLayer = (opts: TelemetryStoreOptions) => Layer.ef
|
|
|
797
797
|
|
|
798
798
|
const deleteSpanAttributes = db.query(`DELETE FROM span_attributes WHERE trace_id = ? AND span_id = ?`)
|
|
799
799
|
const insertSpanAttribute = db.query(`INSERT INTO span_attributes (trace_id, span_id, key, value) VALUES (?, ?, ?, ?)`)
|
|
800
|
+
const spanAttributeInsertManyByCount = new Map<number, ReturnType<Database["query"]>>()
|
|
801
|
+
const insertSpanAttributesMany = (traceId: string, spanId: string, attributes: Readonly<Record<string, string>>) => {
|
|
802
|
+
const entries = Object.entries(attributes)
|
|
803
|
+
if (entries.length === 0) return
|
|
804
|
+
if (entries.length === 1) {
|
|
805
|
+
const [key, value] = entries[0]!
|
|
806
|
+
insertSpanAttribute.run(traceId, spanId, key, value)
|
|
807
|
+
return
|
|
808
|
+
}
|
|
809
|
+
let query = spanAttributeInsertManyByCount.get(entries.length)
|
|
810
|
+
if (!query) {
|
|
811
|
+
query = db.query(`INSERT INTO span_attributes (trace_id, span_id, key, value) VALUES ${entries.map(() => "(?, ?, ?, ?)").join(", ")}`)
|
|
812
|
+
spanAttributeInsertManyByCount.set(entries.length, query)
|
|
813
|
+
}
|
|
814
|
+
query.run(...entries.flatMap(([key, value]) => [traceId, spanId, key, value]))
|
|
815
|
+
}
|
|
800
816
|
const deleteSpanOperationSearch = db.query(`DELETE FROM span_operation_fts WHERE trace_id = ? AND span_id = ?`)
|
|
801
817
|
const insertSpanOperationSearch = db.query(`INSERT INTO span_operation_fts (trace_id, span_id, operation_name) VALUES (?, ?, ?)`)
|
|
818
|
+
const deleteSpanOperationSearchManyByCount = new Map<number, ReturnType<Database["query"]>>()
|
|
819
|
+
const insertSpanOperationSearchManyByCount = new Map<number, ReturnType<Database["query"]>>()
|
|
820
|
+
const updateSpanOperationSearchMany = (operations: ReadonlyArray<readonly [string, string, string]>) => {
|
|
821
|
+
if (operations.length === 0) return
|
|
822
|
+
if (operations.length === 1) {
|
|
823
|
+
const [traceId, spanId, operationName] = operations[0]!
|
|
824
|
+
deleteSpanOperationSearch.run(traceId, spanId)
|
|
825
|
+
insertSpanOperationSearch.run(traceId, spanId, operationName)
|
|
826
|
+
return
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
let deleteQuery = deleteSpanOperationSearchManyByCount.get(operations.length)
|
|
830
|
+
if (!deleteQuery) {
|
|
831
|
+
deleteQuery = db.query(`DELETE FROM span_operation_fts WHERE ${operations.map(() => "(trace_id = ? AND span_id = ?)").join(" OR ")}`)
|
|
832
|
+
deleteSpanOperationSearchManyByCount.set(operations.length, deleteQuery)
|
|
833
|
+
}
|
|
834
|
+
deleteQuery.run(...operations.flatMap(([traceId, spanId]) => [traceId, spanId]))
|
|
835
|
+
|
|
836
|
+
let insertQuery = insertSpanOperationSearchManyByCount.get(operations.length)
|
|
837
|
+
if (!insertQuery) {
|
|
838
|
+
insertQuery = db.query(`INSERT INTO span_operation_fts (trace_id, span_id, operation_name) VALUES ${operations.map(() => "(?, ?, ?)").join(", ")}`)
|
|
839
|
+
insertSpanOperationSearchManyByCount.set(operations.length, insertQuery)
|
|
840
|
+
}
|
|
841
|
+
insertQuery.run(...operations.flatMap(([traceId, spanId, operationName]) => [traceId, spanId, operationName]))
|
|
842
|
+
}
|
|
802
843
|
const insertLogAttribute = db.query(`INSERT INTO log_attributes (log_id, key, value) VALUES (?, ?, ?)`)
|
|
844
|
+
const logAttributeInsertManyByCount = new Map<number, ReturnType<Database["query"]>>()
|
|
845
|
+
const insertLogAttributesMany = (logId: number, attributes: Readonly<Record<string, string>>) => {
|
|
846
|
+
const entries = Object.entries(attributes)
|
|
847
|
+
if (entries.length === 0) return
|
|
848
|
+
if (entries.length === 1) {
|
|
849
|
+
const [key, value] = entries[0]!
|
|
850
|
+
insertLogAttribute.run(logId, key, value)
|
|
851
|
+
return
|
|
852
|
+
}
|
|
853
|
+
let query = logAttributeInsertManyByCount.get(entries.length)
|
|
854
|
+
if (!query) {
|
|
855
|
+
query = db.query(`INSERT INTO log_attributes (log_id, key, value) VALUES ${entries.map(() => "(?, ?, ?)").join(", ")}`)
|
|
856
|
+
logAttributeInsertManyByCount.set(entries.length, query)
|
|
857
|
+
}
|
|
858
|
+
query.run(...entries.flatMap(([key, value]) => [logId, key, value]))
|
|
859
|
+
}
|
|
803
860
|
const insertLogBodySearch = db.query(`INSERT INTO log_body_fts (log_id, body) VALUES (?, ?)`)
|
|
861
|
+
const insertLogBodySearchManyByCount = new Map<number, ReturnType<Database["query"]>>()
|
|
862
|
+
const insertLogBodySearchMany = (entries: ReadonlyArray<readonly [string, string]>) => {
|
|
863
|
+
if (entries.length === 0) return
|
|
864
|
+
if (entries.length === 1) {
|
|
865
|
+
const [logId, body] = entries[0]!
|
|
866
|
+
insertLogBodySearch.run(logId, body)
|
|
867
|
+
return
|
|
868
|
+
}
|
|
869
|
+
let query = insertLogBodySearchManyByCount.get(entries.length)
|
|
870
|
+
if (!query) {
|
|
871
|
+
query = db.query(`INSERT INTO log_body_fts (log_id, body) VALUES ${entries.map(() => "(?, ?)").join(", ")}`)
|
|
872
|
+
insertLogBodySearchManyByCount.set(entries.length, query)
|
|
873
|
+
}
|
|
874
|
+
query.run(...entries.flatMap(([logId, body]) => [logId, body]))
|
|
875
|
+
}
|
|
804
876
|
|
|
805
877
|
const maxDbSizeBytes = config.otel.maxDbSizeMb * 1024 * 1024
|
|
806
878
|
|
|
@@ -961,6 +1033,7 @@ export const makeTelemetryStoreLayer = (opts: TelemetryStoreOptions) => Layer.ef
|
|
|
961
1033
|
let insertedSpans = 0
|
|
962
1034
|
const transaction = db.transaction((request: OtlpTraceExportRequest) => {
|
|
963
1035
|
const touchedTraceIds = new Set<string>()
|
|
1036
|
+
const touchedOperations: Array<readonly [string, string, string]> = []
|
|
964
1037
|
for (const resourceSpans of request.resourceSpans ?? []) {
|
|
965
1038
|
const resourceAttributes = attributeMap(resourceSpans.resource?.attributes)
|
|
966
1039
|
const serviceName = resourceAttributes["service.name"] || resourceAttributes["service_name"] || "unknown"
|
|
@@ -996,20 +1069,21 @@ export const makeTelemetryStoreLayer = (opts: TelemetryStoreOptions) => Layer.ef
|
|
|
996
1069
|
JSON.stringify(events),
|
|
997
1070
|
)
|
|
998
1071
|
deleteSpanAttributes.run(span.traceId, span.spanId)
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
}
|
|
1002
|
-
try {
|
|
1003
|
-
deleteSpanOperationSearch.run(span.traceId, span.spanId)
|
|
1004
|
-
insertSpanOperationSearch.run(span.traceId, span.spanId, span.name ?? "unknown")
|
|
1005
|
-
} catch {
|
|
1006
|
-
// FTS is optional.
|
|
1007
|
-
}
|
|
1072
|
+
insertSpanAttributesMany(span.traceId, span.spanId, mergedAttributes)
|
|
1073
|
+
touchedOperations.push([span.traceId, span.spanId, span.name ?? "unknown"])
|
|
1008
1074
|
touchedTraceIds.add(span.traceId)
|
|
1009
1075
|
insertedSpans += 1
|
|
1010
1076
|
}
|
|
1011
1077
|
}
|
|
1012
1078
|
}
|
|
1079
|
+
try {
|
|
1080
|
+
const BATCH_SIZE = 500
|
|
1081
|
+
for (let offset = 0; offset < touchedOperations.length; offset += BATCH_SIZE) {
|
|
1082
|
+
updateSpanOperationSearchMany(touchedOperations.slice(offset, offset + BATCH_SIZE))
|
|
1083
|
+
}
|
|
1084
|
+
} catch {
|
|
1085
|
+
// FTS is optional.
|
|
1086
|
+
}
|
|
1013
1087
|
for (const traceId of touchedTraceIds) {
|
|
1014
1088
|
upsertTraceSummary.run(traceId)
|
|
1015
1089
|
}
|
|
@@ -1024,6 +1098,7 @@ export const makeTelemetryStoreLayer = (opts: TelemetryStoreOptions) => Layer.ef
|
|
|
1024
1098
|
return yield* Effect.sync(() => {
|
|
1025
1099
|
let insertedLogs = 0
|
|
1026
1100
|
const transaction = db.transaction((request: OtlpLogExportRequest) => {
|
|
1101
|
+
const touchedLogBodies: Array<readonly [string, string]> = []
|
|
1027
1102
|
for (const resourceLogs of request.resourceLogs ?? []) {
|
|
1028
1103
|
const resourceAttributes = attributeMap(resourceLogs.resource?.attributes)
|
|
1029
1104
|
const serviceName = resourceAttributes["service.name"] || resourceAttributes["service_name"] || "unknown"
|
|
@@ -1048,18 +1123,20 @@ export const makeTelemetryStoreLayer = (opts: TelemetryStoreOptions) => Layer.ef
|
|
|
1048
1123
|
JSON.stringify(resourceAttributes),
|
|
1049
1124
|
)
|
|
1050
1125
|
const logId = Number((result as { lastInsertRowid: number | bigint }).lastInsertRowid)
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
}
|
|
1054
|
-
try {
|
|
1055
|
-
insertLogBodySearch.run(String(logId), body)
|
|
1056
|
-
} catch {
|
|
1057
|
-
// FTS is optional.
|
|
1058
|
-
}
|
|
1126
|
+
insertLogAttributesMany(logId, mergedAttributes)
|
|
1127
|
+
touchedLogBodies.push([String(logId), body])
|
|
1059
1128
|
insertedLogs += 1
|
|
1060
1129
|
}
|
|
1061
1130
|
}
|
|
1062
1131
|
}
|
|
1132
|
+
try {
|
|
1133
|
+
const BATCH_SIZE = 500
|
|
1134
|
+
for (let offset = 0; offset < touchedLogBodies.length; offset += BATCH_SIZE) {
|
|
1135
|
+
insertLogBodySearchMany(touchedLogBodies.slice(offset, offset + BATCH_SIZE))
|
|
1136
|
+
}
|
|
1137
|
+
} catch {
|
|
1138
|
+
// FTS is optional.
|
|
1139
|
+
}
|
|
1063
1140
|
})
|
|
1064
1141
|
|
|
1065
1142
|
transaction(payload)
|
|
@@ -1282,6 +1359,8 @@ export const makeTelemetryStoreLayer = (opts: TelemetryStoreOptions) => Layer.ef
|
|
|
1282
1359
|
const candidateLimit = hasContainsFilters ? Math.max(limit * 20, 500) : Math.max(limit * 10, 200)
|
|
1283
1360
|
|
|
1284
1361
|
return yield* Effect.sync(() => {
|
|
1362
|
+
let fromSql = "FROM spans AS s"
|
|
1363
|
+
const joinParams: Array<string | number> = []
|
|
1285
1364
|
const clauses: string[] = ["s.start_time_ms >= ?"]
|
|
1286
1365
|
const params: Array<string | number> = [cutoff]
|
|
1287
1366
|
|
|
@@ -1296,8 +1375,8 @@ export const makeTelemetryStoreLayer = (opts: TelemetryStoreOptions) => Layer.ef
|
|
|
1296
1375
|
if (input.operation) {
|
|
1297
1376
|
const ftsQuery = toFtsMatchQuery(input.operation)
|
|
1298
1377
|
if (hasFts && ftsQuery) {
|
|
1299
|
-
|
|
1300
|
-
|
|
1378
|
+
fromSql += ` INNER JOIN (SELECT trace_id, span_id FROM span_operation_fts WHERE span_operation_fts MATCH ?) AS span_operation_match ON span_operation_match.trace_id = s.trace_id AND span_operation_match.span_id = s.span_id`
|
|
1379
|
+
joinParams.push(ftsQuery)
|
|
1301
1380
|
} else {
|
|
1302
1381
|
clauses.push("s.operation_name LIKE ? COLLATE NOCASE")
|
|
1303
1382
|
params.push(`%${input.operation}%`)
|
|
@@ -1321,42 +1400,90 @@ export const makeTelemetryStoreLayer = (opts: TelemetryStoreOptions) => Layer.ef
|
|
|
1321
1400
|
}
|
|
1322
1401
|
|
|
1323
1402
|
const rows = db.query(`
|
|
1324
|
-
SELECT
|
|
1325
|
-
|
|
1403
|
+
SELECT *
|
|
1404
|
+
${fromSql}
|
|
1326
1405
|
WHERE ${clauses.join(" AND ")}
|
|
1327
1406
|
ORDER BY s.start_time_ms DESC
|
|
1328
1407
|
LIMIT ?
|
|
1329
|
-
`).all(...params, candidateLimit) as
|
|
1408
|
+
`).all(...joinParams, ...params, candidateLimit) as SpanRow[]
|
|
1330
1409
|
|
|
1331
1410
|
const traceIds = [...new Set(rows.map((row) => row.trace_id))]
|
|
1332
1411
|
if (traceIds.length === 0) return [] as readonly SpanItem[]
|
|
1333
1412
|
|
|
1413
|
+
const keyOf = (traceId: string, spanId: string) => `${traceId}:${spanId}`
|
|
1414
|
+
const spanContextById = new Map<string, { readonly parentSpanId: string | null; readonly operationName: string }>()
|
|
1415
|
+
for (const row of rows) {
|
|
1416
|
+
spanContextById.set(keyOf(row.trace_id, row.span_id), {
|
|
1417
|
+
parentSpanId: row.parent_span_id,
|
|
1418
|
+
operationName: row.operation_name,
|
|
1419
|
+
})
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1334
1422
|
const placeholders = traceIds.map(() => "?").join(", ")
|
|
1335
|
-
const
|
|
1336
|
-
SELECT
|
|
1337
|
-
|
|
1423
|
+
const rootRows = db.query(`
|
|
1424
|
+
SELECT trace_id, operation_name
|
|
1425
|
+
FROM spans
|
|
1426
|
+
WHERE trace_id IN (${placeholders}) AND parent_span_id IS NULL
|
|
1338
1427
|
ORDER BY start_time_ms ASC
|
|
1339
|
-
`).all(...traceIds) as
|
|
1340
|
-
|
|
1341
|
-
const
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
grouped.set(row.trace_id, group)
|
|
1428
|
+
`).all(...traceIds) as Array<{ trace_id: string; operation_name: string }>
|
|
1429
|
+
const rootOperationByTraceId = new Map<string, string>()
|
|
1430
|
+
for (const row of rootRows) {
|
|
1431
|
+
if (!rootOperationByTraceId.has(row.trace_id)) {
|
|
1432
|
+
rootOperationByTraceId.set(row.trace_id, row.operation_name)
|
|
1433
|
+
}
|
|
1346
1434
|
}
|
|
1347
1435
|
|
|
1348
|
-
const
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1436
|
+
const spanContextLookup = db.query(`
|
|
1437
|
+
SELECT parent_span_id, operation_name
|
|
1438
|
+
FROM spans
|
|
1439
|
+
WHERE trace_id = ? AND span_id = ?
|
|
1440
|
+
`)
|
|
1441
|
+
|
|
1442
|
+
const getSpanContext = (traceId: string, spanId: string) => {
|
|
1443
|
+
const key = keyOf(traceId, spanId)
|
|
1444
|
+
const cached = spanContextById.get(key)
|
|
1445
|
+
if (cached !== undefined) return cached
|
|
1446
|
+
const row = spanContextLookup.get(traceId, spanId) as { parent_span_id: string | null; operation_name: string } | null
|
|
1447
|
+
if (!row) return null
|
|
1448
|
+
const value = {
|
|
1449
|
+
parentSpanId: row.parent_span_id,
|
|
1450
|
+
operationName: row.operation_name,
|
|
1354
1451
|
}
|
|
1452
|
+
spanContextById.set(key, value)
|
|
1453
|
+
return value
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
const depthById = new Map<string, number>()
|
|
1457
|
+
const getDepth = (traceId: string, spanId: string, visiting = new Set<string>()): number => {
|
|
1458
|
+
const key = keyOf(traceId, spanId)
|
|
1459
|
+
const cached = depthById.get(key)
|
|
1460
|
+
if (cached !== undefined) return cached
|
|
1461
|
+
if (visiting.has(key)) return 0
|
|
1462
|
+
visiting.add(key)
|
|
1463
|
+
const context = getSpanContext(traceId, spanId)
|
|
1464
|
+
const depth = context?.parentSpanId ? getDepth(traceId, context.parentSpanId, visiting) + 1 : 0
|
|
1465
|
+
depthById.set(key, depth)
|
|
1466
|
+
return depth
|
|
1355
1467
|
}
|
|
1356
1468
|
|
|
1357
1469
|
return rows
|
|
1358
|
-
.map((row) =>
|
|
1359
|
-
|
|
1470
|
+
.map((row) => {
|
|
1471
|
+
const parentContext = row.parent_span_id ? getSpanContext(row.trace_id, row.parent_span_id) : null
|
|
1472
|
+
const parsedSpan = parseSpanRow(row)
|
|
1473
|
+
const span = {
|
|
1474
|
+
...parsedSpan,
|
|
1475
|
+
depth: getDepth(row.trace_id, row.span_id),
|
|
1476
|
+
warnings: row.parent_span_id && !parentContext
|
|
1477
|
+
? [`missing span ${row.parent_span_id} (1 child)`]
|
|
1478
|
+
: parsedSpan.warnings,
|
|
1479
|
+
}
|
|
1480
|
+
return {
|
|
1481
|
+
traceId: row.trace_id,
|
|
1482
|
+
rootOperationName: rootOperationByTraceId.get(row.trace_id) ?? span.operationName,
|
|
1483
|
+
parentOperationName: parentContext?.operationName ?? null,
|
|
1484
|
+
span,
|
|
1485
|
+
} satisfies SpanItem
|
|
1486
|
+
})
|
|
1360
1487
|
.filter((item) => {
|
|
1361
1488
|
if (input.parentOperation) {
|
|
1362
1489
|
const needle = input.parentOperation.toLowerCase()
|
|
@@ -1666,7 +1793,6 @@ export const makeTelemetryStoreLayer = (opts: TelemetryStoreOptions) => Layer.ef
|
|
|
1666
1793
|
})
|
|
1667
1794
|
|
|
1668
1795
|
const listFacets = Effect.fn("motel/TelemetryStore.listFacets")(function* (input: FacetSearch) {
|
|
1669
|
-
|
|
1670
1796
|
const cutoff = (yield* Clock.currentTimeMillis) - (input.lookbackMinutes ?? config.otel.traceLookbackMinutes) * 60 * 1000
|
|
1671
1797
|
const limit = input.limit ?? 20
|
|
1672
1798
|
|
|
@@ -1756,21 +1882,26 @@ export const makeTelemetryStoreLayer = (opts: TelemetryStoreOptions) => Layer.ef
|
|
|
1756
1882
|
// FACET_VALUE_MAX_LEN. For opencode this hides `ai.prompt`,
|
|
1757
1883
|
// `ai.prompt.messages`, and `ai.prompt.tools` — which are 1-6MB text
|
|
1758
1884
|
// blobs that you'd never want to filter by exact match anyway. The
|
|
1759
|
-
// WHERE clause lets SQLite skip reading those pages from disk
|
|
1760
|
-
//
|
|
1885
|
+
// WHERE clause lets SQLite skip reading those pages from disk. We also
|
|
1886
|
+
// dedupe to one (trace, key, value) row before grouping so repeated
|
|
1887
|
+
// span-level duplicates don't blow up the temp B-trees used for the
|
|
1888
|
+
// picker ranking query.
|
|
1761
1889
|
const params: Array<string | number> = [FACET_VALUE_MAX_LEN, cutoff]
|
|
1762
1890
|
if (input.serviceName) params.push(input.serviceName)
|
|
1763
1891
|
params.push(limit)
|
|
1764
1892
|
const rows = db.query(`
|
|
1765
|
-
SELECT
|
|
1766
|
-
COUNT(DISTINCT
|
|
1767
|
-
COUNT(DISTINCT
|
|
1768
|
-
FROM
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1893
|
+
SELECT scoped.key AS value,
|
|
1894
|
+
COUNT(DISTINCT scoped.trace_id) AS count,
|
|
1895
|
+
COUNT(DISTINCT scoped.value) AS distinct_values
|
|
1896
|
+
FROM (
|
|
1897
|
+
SELECT DISTINCT sa.trace_id, sa.key, sa.value
|
|
1898
|
+
FROM span_attributes sa
|
|
1899
|
+
JOIN trace_summaries ts ON ts.trace_id = sa.trace_id
|
|
1900
|
+
WHERE LENGTH(sa.value) < ?
|
|
1901
|
+
AND ts.started_at_ms >= ?
|
|
1902
|
+
${input.serviceName ? "AND ts.service_name = ?" : ""}
|
|
1903
|
+
) AS scoped
|
|
1904
|
+
GROUP BY scoped.key
|
|
1774
1905
|
ORDER BY (CASE WHEN distinct_values = 1 THEN 1 ELSE 0 END) ASC,
|
|
1775
1906
|
distinct_values DESC,
|
|
1776
1907
|
count DESC,
|
|
@@ -6,9 +6,9 @@ export class TraceQueryService extends Context.Service<
|
|
|
6
6
|
TraceQueryService,
|
|
7
7
|
{
|
|
8
8
|
readonly listServices: Effect.Effect<readonly string[], Error>
|
|
9
|
-
readonly listRecentTraces: (serviceName: string, options?: { readonly lookbackMinutes?: number; readonly limit?: number }) => Effect.Effect<readonly TraceItem[], Error>
|
|
10
|
-
readonly listTraceSummaries: (serviceName: string, options?: { readonly lookbackMinutes?: number; readonly limit?: number }) => Effect.Effect<readonly TraceSummaryItem[], Error>
|
|
11
|
-
readonly searchTraceSummaries: (input: { readonly serviceName?: string | null; readonly operation?: string | null; readonly status?: "ok" | "error" | null; readonly minDurationMs?: number | null; readonly lookbackMinutes?: number; readonly limit?: number; readonly attributeFilters?: Readonly<Record<string, string>>; readonly aiText?: string | null }) => Effect.Effect<readonly TraceSummaryItem[], Error>
|
|
9
|
+
readonly listRecentTraces: (serviceName: string, options?: { readonly lookbackMinutes?: number; readonly limit?: number; readonly cursorStartedAtMs?: number; readonly cursorTraceId?: string }) => Effect.Effect<readonly TraceItem[], Error>
|
|
10
|
+
readonly listTraceSummaries: (serviceName: string | null, options?: { readonly lookbackMinutes?: number; readonly limit?: number; readonly cursorStartedAtMs?: number; readonly cursorTraceId?: string }) => Effect.Effect<readonly TraceSummaryItem[], Error>
|
|
11
|
+
readonly searchTraceSummaries: (input: { readonly serviceName?: string | null; readonly operation?: string | null; readonly status?: "ok" | "error" | null; readonly minDurationMs?: number | null; readonly lookbackMinutes?: number; readonly limit?: number; readonly attributeFilters?: Readonly<Record<string, string>>; readonly aiText?: string | null; readonly cursorStartedAtMs?: number; readonly cursorTraceId?: string }) => Effect.Effect<readonly TraceSummaryItem[], Error>
|
|
12
12
|
readonly listFacets: (input: { readonly type: "traces" | "logs"; readonly field: string; readonly serviceName?: string | null; readonly key?: string | null; readonly lookbackMinutes?: number; readonly limit?: number }) => Effect.Effect<readonly { readonly value: string; readonly count: number }[], Error>
|
|
13
13
|
readonly searchTraces: (input: { readonly serviceName?: string | null; readonly operation?: string | null; readonly status?: "ok" | "error" | null; readonly minDurationMs?: number | null; readonly lookbackMinutes?: number; readonly limit?: number; readonly attributeFilters?: Readonly<Record<string, string>> }) => Effect.Effect<readonly TraceItem[], Error>
|
|
14
14
|
readonly traceStats: (input: { readonly groupBy: string; readonly agg: "count" | "avg_duration" | "p95_duration" | "error_rate"; readonly serviceName?: string | null; readonly operation?: string | null; readonly status?: "ok" | "error" | null; readonly minDurationMs?: number | null; readonly lookbackMinutes?: number; readonly limit?: number; readonly attributeFilters?: Readonly<Record<string, string>> }) => Effect.Effect<readonly { readonly group: string; readonly value: number; readonly count: number }[], Error>
|
|
@@ -16,7 +16,7 @@ export class TraceQueryService extends Context.Service<
|
|
|
16
16
|
readonly getSpan: (spanId: string) => Effect.Effect<SpanItem | null, Error>
|
|
17
17
|
readonly getAiCall: (spanId: string) => Effect.Effect<AiCallDetail | null, Error>
|
|
18
18
|
readonly listTraceSpans: (traceId: string) => Effect.Effect<readonly SpanItem[], Error>
|
|
19
|
-
readonly searchSpans: (input: { readonly serviceName?: string | null; readonly operation?: string | null; readonly parentOperation?: string | null; readonly status?: "ok" | "error" | null; readonly lookbackMinutes?: number; readonly limit?: number; readonly attributeFilters?: Readonly<Record<string, string>> }) => Effect.Effect<readonly SpanItem[], Error>
|
|
19
|
+
readonly searchSpans: (input: { readonly serviceName?: string | null; readonly traceId?: string | null; readonly operation?: string | null; readonly parentOperation?: string | null; readonly status?: "ok" | "error" | null; readonly lookbackMinutes?: number; readonly limit?: number; readonly attributeFilters?: Readonly<Record<string, string>>; readonly attributeContainsFilters?: Readonly<Record<string, string>> }) => Effect.Effect<readonly SpanItem[], Error>
|
|
20
20
|
}
|
|
21
21
|
>()("motel/TraceQueryService") {}
|
|
22
22
|
|
|
@@ -31,7 +31,7 @@ export const TraceQueryServiceLive = Layer.effect(
|
|
|
31
31
|
return services
|
|
32
32
|
})()
|
|
33
33
|
|
|
34
|
-
const listRecentTraces = Effect.fn("motel/TraceQueryService.listRecentTraces")(function* (serviceName: string, options?: { readonly lookbackMinutes?: number; readonly limit?: number }) {
|
|
34
|
+
const listRecentTraces = Effect.fn("motel/TraceQueryService.listRecentTraces")(function* (serviceName: string, options?: { readonly lookbackMinutes?: number; readonly limit?: number; readonly cursorStartedAtMs?: number; readonly cursorTraceId?: string }) {
|
|
35
35
|
yield* Effect.annotateCurrentSpan({
|
|
36
36
|
"trace.service_name": serviceName,
|
|
37
37
|
})
|
|
@@ -40,7 +40,7 @@ export const TraceQueryServiceLive = Layer.effect(
|
|
|
40
40
|
return traces
|
|
41
41
|
})
|
|
42
42
|
|
|
43
|
-
const listTraceSummaries = Effect.fn("motel/TraceQueryService.listTraceSummaries")(function* (serviceName: string, options?: { readonly lookbackMinutes?: number; readonly limit?: number }) {
|
|
43
|
+
const listTraceSummaries = Effect.fn("motel/TraceQueryService.listTraceSummaries")(function* (serviceName: string | null, options?: { readonly lookbackMinutes?: number; readonly limit?: number; readonly cursorStartedAtMs?: number; readonly cursorTraceId?: string }) {
|
|
44
44
|
yield* Effect.annotateCurrentSpan({
|
|
45
45
|
"trace.service_name": serviceName,
|
|
46
46
|
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const EPOCH_KEY = "__motelStartupEpoch"
|
|
2
|
+
|
|
3
|
+
type GlobalWithStartupEpoch = typeof globalThis & {
|
|
4
|
+
[EPOCH_KEY]?: number
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const globalWithEpoch = globalThis as GlobalWithStartupEpoch
|
|
8
|
+
|
|
9
|
+
if (globalWithEpoch[EPOCH_KEY] === undefined) {
|
|
10
|
+
globalWithEpoch[EPOCH_KEY] = performance.now()
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const startupBenchEnabled = process.env.MOTEL_BENCH_STARTUP_PHASES === "1"
|
|
14
|
+
|
|
15
|
+
export const startupBenchMark = (phase: string) => {
|
|
16
|
+
if (!startupBenchEnabled) return
|
|
17
|
+
const elapsedMs = performance.now() - (globalWithEpoch[EPOCH_KEY] ?? performance.now())
|
|
18
|
+
process.stderr.write(`[motel-startup] ${phase} ${elapsedMs.toFixed(3)}ms\n`)
|
|
19
|
+
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
detailViewAtom,
|
|
14
14
|
ensureAiCallDetail,
|
|
15
15
|
ensureTraceAttributeKeys,
|
|
16
|
+
ensureTraceAttributeValues,
|
|
16
17
|
filterModeAtom,
|
|
17
18
|
filterTextAtom,
|
|
18
19
|
getCachedAiCallDetail,
|
|
@@ -21,7 +22,6 @@ import {
|
|
|
21
22
|
initialServiceLogState,
|
|
22
23
|
initialTraceDetailState,
|
|
23
24
|
invalidateAiCallDetailCache,
|
|
24
|
-
invalidateFacetCaches,
|
|
25
25
|
loadFilteredTraceSummaries,
|
|
26
26
|
loadRecentTraceSummaries,
|
|
27
27
|
loadServiceLogs,
|
|
@@ -166,17 +166,25 @@ export const useTraceScreenData = () => {
|
|
|
166
166
|
serviceLogCacheRef.current.clear()
|
|
167
167
|
traceDetailInflightRef.current.clear()
|
|
168
168
|
traceLogInflightRef.current.clear()
|
|
169
|
-
invalidateFacetCaches()
|
|
170
169
|
invalidateAiCallDetailCache()
|
|
171
170
|
}, [refreshNonce])
|
|
172
171
|
|
|
173
172
|
// Pre-warm the attribute picker facet keys for the currently-selected
|
|
174
|
-
// service so pressing `f` feels instant.
|
|
175
|
-
//
|
|
173
|
+
// service so pressing `f` feels instant. Once keys are known, also
|
|
174
|
+
// prefetch the value lists for the first few visible keys so the common
|
|
175
|
+
// path of opening `f`, picking a top key, and reopening again stays
|
|
176
|
+
// near-instant. Fire-and-forget; errors are surfaced when the user
|
|
177
|
+
// actually opens the picker.
|
|
176
178
|
useEffect(() => {
|
|
177
179
|
if (!selectedTraceService) return
|
|
178
|
-
void ensureTraceAttributeKeys(selectedTraceService)
|
|
179
|
-
|
|
180
|
+
void ensureTraceAttributeKeys(selectedTraceService)
|
|
181
|
+
.then((entry) => Promise.allSettled(
|
|
182
|
+
entry.data
|
|
183
|
+
.slice(0, 6)
|
|
184
|
+
.map((row) => ensureTraceAttributeValues(selectedTraceService, row.value)),
|
|
185
|
+
))
|
|
186
|
+
.catch(() => {})
|
|
187
|
+
}, [selectedTraceService])
|
|
180
188
|
|
|
181
189
|
useEffect(() => {
|
|
182
190
|
let cancelled = false
|
package/src/ui/atoms.ts
CHANGED
|
@@ -87,7 +87,7 @@ export const selectedSpanIndexAtom = Atom.make<number | null>(null).pipe(Atom.ke
|
|
|
87
87
|
export const selectedAttrIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
|
|
88
88
|
export const detailViewAtom = Atom.make<DetailView>("waterfall").pipe(Atom.keepAlive)
|
|
89
89
|
export const showHelpAtom = Atom.make(false).pipe(Atom.keepAlive)
|
|
90
|
-
export const autoRefreshAtom = Atom.make(
|
|
90
|
+
export const autoRefreshAtom = Atom.make(true).pipe(Atom.keepAlive)
|
|
91
91
|
export const filterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
|
|
92
92
|
export const filterTextAtom = Atom.make("").pipe(Atom.keepAlive)
|
|
93
93
|
|
package/src/ui/persistence.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs"
|
|
2
2
|
import { dirname } from "node:path"
|
|
3
3
|
import { config } from "../config.ts"
|
|
4
|
-
import type
|
|
4
|
+
import { defaultThemeName, type ThemeName } from "./theme.ts"
|
|
5
5
|
|
|
6
6
|
const lastServicePath = `${dirname(config.otel.databasePath)}/last-service.txt`
|
|
7
7
|
|
|
@@ -26,9 +26,9 @@ const lastThemePath = `${dirname(config.otel.databasePath)}/last-theme.txt`
|
|
|
26
26
|
export const readLastTheme = (): ThemeName => {
|
|
27
27
|
try {
|
|
28
28
|
const raw = readFileSync(lastThemePath, "utf-8").trim()
|
|
29
|
-
return raw === "tokyo-night" || raw === "catppuccin" || raw === "motel-default" ? raw :
|
|
29
|
+
return raw === "tokyo-night" || raw === "catppuccin" || raw === "motel-default" ? raw : defaultThemeName
|
|
30
30
|
} catch {
|
|
31
|
-
return
|
|
31
|
+
return defaultThemeName
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
package/src/ui/theme.ts
CHANGED
|
@@ -135,13 +135,15 @@ export const themes = {
|
|
|
135
135
|
|
|
136
136
|
export type ThemeName = keyof typeof themes
|
|
137
137
|
|
|
138
|
-
export const
|
|
138
|
+
export const defaultThemeName: ThemeName = "tokyo-night"
|
|
139
139
|
|
|
140
|
-
export const
|
|
141
|
-
|
|
140
|
+
export const themeOrder: readonly ThemeName[] = ["tokyo-night", "catppuccin", "motel-default"]
|
|
141
|
+
|
|
142
|
+
export const colors: ThemeColors = { ...themes[defaultThemeName].colors }
|
|
143
|
+
export const waterfallColors: ThemeWaterfallColors = { ...themes[defaultThemeName].waterfall }
|
|
142
144
|
|
|
143
145
|
export const applyTheme = (name: ThemeName) => {
|
|
144
|
-
const theme = themes[name] ??
|
|
146
|
+
const theme = themes[name] ?? themes[defaultThemeName]
|
|
145
147
|
Object.assign(colors, theme.colors)
|
|
146
148
|
Object.assign(waterfallColors, theme.waterfall)
|
|
147
149
|
return theme
|
|
@@ -152,7 +154,7 @@ export const cycleThemeName = (current: ThemeName) => {
|
|
|
152
154
|
return themeOrder[nextIndex] ?? themeOrder[0]
|
|
153
155
|
}
|
|
154
156
|
|
|
155
|
-
export const themeLabel = (name: ThemeName) => themes[name]?.label ??
|
|
157
|
+
export const themeLabel = (name: ThemeName) => themes[name]?.label ?? themes[defaultThemeName].label
|
|
156
158
|
|
|
157
159
|
export const SEPARATOR = " \u00b7 "
|
|
158
160
|
export const G_PREFIX_TIMEOUT_MS = 500
|
package/src/ui/useKeyboardNav.ts
CHANGED
|
@@ -19,6 +19,9 @@ import {
|
|
|
19
19
|
detailViewAtom,
|
|
20
20
|
filterModeAtom,
|
|
21
21
|
filterTextAtom,
|
|
22
|
+
getCachedFacetKeys,
|
|
23
|
+
getCachedFacetValues,
|
|
24
|
+
initialAttrFacetState,
|
|
22
25
|
refreshNonceAtom,
|
|
23
26
|
selectedAttrIndexAtom,
|
|
24
27
|
selectedChatChunkIdAtom,
|
|
@@ -125,7 +128,7 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
125
128
|
const [pickerMode, setPickerMode] = useAtom(attrPickerModeAtom)
|
|
126
129
|
const [pickerInput, setPickerInput] = useAtom(attrPickerInputAtom)
|
|
127
130
|
const [pickerIndex, setPickerIndex] = useAtom(attrPickerIndexAtom)
|
|
128
|
-
const [attrFacets] = useAtom(attrFacetStateAtom)
|
|
131
|
+
const [attrFacets, setAttrFacets] = useAtom(attrFacetStateAtom)
|
|
129
132
|
const [activeAttrKey, setActiveAttrKey] = useAtom(activeAttrKeyAtom)
|
|
130
133
|
const [activeAttrValue, setActiveAttrValue] = useAtom(activeAttrValueAtom)
|
|
131
134
|
const [waterfallFilterMode, setWaterfallFilterMode] = useAtom(waterfallFilterModeAtom)
|
|
@@ -259,6 +262,26 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
259
262
|
setPickerIndex(0)
|
|
260
263
|
}
|
|
261
264
|
|
|
265
|
+
const hydrateCachedPickerKeys = (service: string | null) => {
|
|
266
|
+
if (!service) {
|
|
267
|
+
setAttrFacets(initialAttrFacetState)
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
const cached = getCachedFacetKeys(service)
|
|
271
|
+
if (!cached) return
|
|
272
|
+
setAttrFacets({ status: "ready", key: null, data: cached.data, error: null })
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const hydrateCachedPickerValues = (service: string | null, key: string | null) => {
|
|
276
|
+
if (!service || !key) {
|
|
277
|
+
setAttrFacets(initialAttrFacetState)
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
const cached = getCachedFacetValues(service, key)
|
|
281
|
+
if (!cached) return
|
|
282
|
+
setAttrFacets({ status: "ready", key, data: cached.data, error: null })
|
|
283
|
+
}
|
|
284
|
+
|
|
262
285
|
const closePicker = () => {
|
|
263
286
|
setPickerMode("off")
|
|
264
287
|
resetPicker()
|
|
@@ -587,6 +610,7 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
587
610
|
const row = rows[clampedIndex]
|
|
588
611
|
if (!row) return true
|
|
589
612
|
if (s.pickerMode === "keys") {
|
|
613
|
+
hydrateCachedPickerValues(s.selectedTraceService, row.value)
|
|
590
614
|
setActiveAttrKey(row.value)
|
|
591
615
|
setPickerMode("values")
|
|
592
616
|
resetPicker()
|
|
@@ -604,6 +628,7 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
604
628
|
return true
|
|
605
629
|
}
|
|
606
630
|
if (s.pickerMode === "values") {
|
|
631
|
+
hydrateCachedPickerKeys(s.selectedTraceService)
|
|
607
632
|
setPickerMode("keys")
|
|
608
633
|
setActiveAttrKey(null)
|
|
609
634
|
setPickerIndex(0)
|
|
@@ -857,6 +882,7 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
857
882
|
return true
|
|
858
883
|
}
|
|
859
884
|
if ((key.name === "f" || key.name === "F") && !key.ctrl && !key.meta) {
|
|
885
|
+
hydrateCachedPickerKeys(s.selectedTraceService)
|
|
860
886
|
setPickerMode("keys")
|
|
861
887
|
resetPicker()
|
|
862
888
|
setActiveAttrKey(null)
|