@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 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.4",
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(POLL_INTERVAL_MS)
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 { App } from "./App.js"
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
- <App />
21
+ <StartupGate />
17
22
  </RegistryProvider>,
18
23
  )
24
+
25
+ startupBenchMark("root_render_called")
@@ -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 { TelemetryStore, TelemetryStoreLive } from "./services/TelemetryStore.js"
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(TelemetryStore.asEffect(), (store) =>
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(withStore((store) => store.listServices), (data) => ({ data }))))
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* withStore((store) => store.listTraceSummaries(service, {
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* withStore((store) =>
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* withStore((store) =>
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* withStore((store) =>
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(withStore((store) => store.listTraceSpans(params.traceId)), (data) => ({ data }))),
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(withStore((store) => store.getSpan(params.spanId)), (data) =>
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(withStore((store) => store.getTrace(params.traceId)), (data) =>
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* withStore((store) =>
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* withStore((store) =>
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(withStore((store) => store.getTrace(params.traceId)), (trace) =>
593
+ Effect.flatMap(withTraceQuery((store) => store.getTrace(params.traceId)), (trace) =>
590
594
  trace
591
- ? Effect.map(withStore((store) => store.listTraceLogs(params.traceId)), (logs) => htmlResponse(renderTracePage(trace, logs)))
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 direct TelemetryStore so query handlers can still resolve
679
- // their dependency directly.
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* RpcClient.make(IngestRpcs).pipe(
59
- Effect.provide(WorkerProtocol),
60
- Effect.cached,
61
- )
62
- const withScope = <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.provideService(effect, Scope.Scope, scope)
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(withScope(getClient), (client) => client.ingestTraces(input, options)),
65
- ingestLogs: (input, options) => Effect.flatMap(withScope(getClient), (client) => client.ingestLogs(input, options)),
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
- for (const [key, value] of Object.entries(mergedAttributes)) {
1000
- insertSpanAttribute.run(span.traceId, span.spanId, key, value)
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
- for (const [key, value] of Object.entries(mergedAttributes)) {
1052
- insertLogAttribute.run(logId, key, value)
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
- clauses.push("EXISTS (SELECT 1 FROM span_operation_fts WHERE span_operation_fts.trace_id = s.trace_id AND span_operation_fts.span_id = s.span_id AND span_operation_fts MATCH ?)")
1300
- params.push(ftsQuery)
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 trace_id, span_id
1325
- FROM spans AS s
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 Array<{ trace_id: string; span_id: string }>
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 spanRows = db.query(`
1336
- SELECT * FROM spans
1337
- WHERE trace_id IN (${placeholders})
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 SpanRow[]
1340
-
1341
- const grouped = new Map<string, SpanRow[]>()
1342
- for (const row of spanRows) {
1343
- const group = grouped.get(row.trace_id) ?? []
1344
- group.push(row)
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 itemById = new Map<string, SpanItem>()
1349
- for (const traceId of traceIds) {
1350
- const traceSpanRows = grouped.get(traceId)
1351
- if (!traceSpanRows) continue
1352
- for (const item of buildSpanItems(traceId, traceSpanRows)) {
1353
- itemById.set(`${item.traceId}:${item.span.spanId}`, item)
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) => itemById.get(`${row.trace_id}:${row.span_id}`))
1359
- .filter((item): item is SpanItem => item !== undefined)
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, taking
1760
- // the picker open time from ~1.2s to ~370ms on a 2GB database.
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 sa.key AS value,
1766
- COUNT(DISTINCT sa.trace_id) AS count,
1767
- COUNT(DISTINCT sa.value) AS distinct_values
1768
- FROM span_attributes sa
1769
- JOIN spans s ON s.trace_id = sa.trace_id AND s.span_id = sa.span_id
1770
- WHERE LENGTH(sa.value) < ?
1771
- AND s.start_time_ms >= ?
1772
- ${input.serviceName ? "AND s.service_name = ?" : ""}
1773
- GROUP BY sa.key
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. Fire-and-forget; errors are
175
- // surfaced when the user actually opens the picker.
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).catch(() => {})
179
- }, [selectedTraceService, refreshNonce])
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(false).pipe(Atom.keepAlive)
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
 
@@ -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 { ThemeName } from "./theme.ts"
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 : "motel-default"
29
+ return raw === "tokyo-night" || raw === "catppuccin" || raw === "motel-default" ? raw : defaultThemeName
30
30
  } catch {
31
- return "motel-default"
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 themeOrder: readonly ThemeName[] = ["motel-default", "tokyo-night", "catppuccin"]
138
+ export const defaultThemeName: ThemeName = "tokyo-night"
139
139
 
140
- export const colors: ThemeColors = { ...motelDefaultTheme.colors }
141
- export const waterfallColors: ThemeWaterfallColors = { ...motelDefaultTheme.waterfall }
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] ?? motelDefaultTheme
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 ?? motelDefaultTheme.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
@@ -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)