@kitlangton/motel 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/AGENTS.md +23 -8
  2. package/README.md +13 -2
  3. package/package.json +35 -19
  4. package/skills/motel-debug/SKILL.md +203 -0
  5. package/skills/motel-debug/references/effect.md +38 -0
  6. package/src/App.tsx +12 -5
  7. package/src/StartupGate.tsx +289 -0
  8. package/src/cli.ts +15 -16
  9. package/src/config.ts +7 -1
  10. package/src/daemon.test.ts +332 -51
  11. package/src/daemon.ts +105 -153
  12. package/src/httpApi.ts +1 -0
  13. package/src/httpListPolicy.test.ts +76 -0
  14. package/src/httpListPolicy.ts +129 -0
  15. package/src/index.tsx +9 -2
  16. package/src/localServer.ts +194 -313
  17. package/src/mcp.ts +2 -1
  18. package/src/motel.ts +0 -2
  19. package/src/opentui-jsx.d.ts +11 -0
  20. package/src/otlp.test.ts +65 -0
  21. package/src/otlp.ts +20 -0
  22. package/src/otlpProtobuf.ts +35 -0
  23. package/src/registry.ts +37 -11
  24. package/src/runtime.ts +2 -6
  25. package/src/services/AsyncIngest.ts +22 -8
  26. package/src/services/LogQueryService.ts +13 -27
  27. package/src/services/TelemetryQuery.ts +62 -0
  28. package/src/services/TelemetryStore.ts +546 -231
  29. package/src/services/TraceQueryService.ts +22 -56
  30. package/src/services/ingestRpc.ts +2 -4
  31. package/src/services/queryRpc.ts +15 -0
  32. package/src/services/telemetryQueryWorker.ts +32 -0
  33. package/src/services/telemetryWorker.ts +5 -8
  34. package/src/startupBench.ts +19 -0
  35. package/src/storybook/aiChatStory.tsx +1 -1
  36. package/src/telemetry.test.ts +307 -41
  37. package/src/ui/AiChatView.tsx +1 -1
  38. package/src/ui/AttrFilterModal.tsx +1 -1
  39. package/src/ui/ServiceLogs.tsx +10 -7
  40. package/src/ui/SpanContentView.tsx +24 -21
  41. package/src/ui/TraceDetailsPane.tsx +1 -1
  42. package/src/ui/TraceList.tsx +1 -1
  43. package/src/ui/aiState.ts +10 -22
  44. package/src/ui/app/TraceWorkspace.tsx +2 -1
  45. package/src/ui/app/useAppLayout.ts +1 -1
  46. package/src/ui/app/useTraceScreenData.ts +35 -23
  47. package/src/ui/atoms.ts +1 -1
  48. package/src/ui/cachedLoader.test.ts +23 -0
  49. package/src/ui/cachedLoader.ts +60 -0
  50. package/src/ui/loaders.ts +34 -53
  51. package/src/ui/persistence.ts +3 -3
  52. package/src/ui/primitives.tsx +1 -1
  53. package/src/ui/state.ts +2 -0
  54. package/src/ui/theme.ts +7 -5
  55. package/src/ui/traceDetailsWidth.repro.test.ts +12 -1
  56. package/src/ui/traceSortNav.repro.seed.ts +1 -1
  57. package/src/ui/traceSortNav.repro.test.ts +12 -2
  58. package/src/ui/useAttrFilterPicker.ts +10 -8
  59. package/src/ui/useKeyboardNav.ts +28 -5
  60. package/src/ui/waterfallNav.repro.seed.ts +1 -1
  61. package/src/ui/waterfallNav.repro.test.ts +16 -8
  62. package/web/dist/assets/index-B01z9BaO.css +2 -0
  63. package/web/dist/assets/index-M86tcih5.js +22 -0
  64. package/web/dist/index.html +2 -2
  65. package/web/dist/assets/index-DnyVo03x.js +0 -27
  66. package/web/dist/assets/index-DzuHNBGV.css +0 -2
@@ -1,7 +1,6 @@
1
- import { promises as fs } from "node:fs"
2
1
  import path from "node:path"
3
- import { Effect, Layer } from "effect"
4
- import { config, parsePositiveInt } from "./config.js"
2
+ import { Effect, FileSystem, Layer } from "effect"
3
+ import { config } from "./config.js"
5
4
  import { HttpApiBuilder, HttpApiScalar } from "effect/unstable/httpapi"
6
5
  import * as HttpMiddleware from "effect/unstable/http/HttpMiddleware"
7
6
  import * as HttpRouter from "effect/unstable/http/HttpRouter"
@@ -9,39 +8,46 @@ import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"
9
8
  import * as HttpStaticServer from "effect/unstable/http/HttpStaticServer"
10
9
  import * as BunHttpServer from "@effect/platform-bun/BunHttpServer"
11
10
  import { MotelHttpApi } from "./httpApi.js"
12
- import { attributeFiltersFromEntries, attributeContainsFiltersFromEntries } from "./queryFilters.js"
13
- import { MOTEL_SERVICE_ID, MOTEL_VERSION, removeRegistryEntry, writeRegistryEntry } from "./registry.js"
11
+ import { AI_LIST, LOG_LIST, LOG_STATS, SPAN_LIST, TRACE_LIST, TRACE_STATS, listMeta, logCursorArgs, paginateLogs, paginateSummaries, parseLimit, parseListParams as decodeListParams, parseLookbackMinutes, requestUrl as decodeRequestUrl, traceCursorArgs, type ListBounds, type ListParams } from "./httpListPolicy.js"
12
+ import { MOTEL_SERVICE_ID, MOTEL_VERSION, processIdentity, removeRegistryEntry, writeRegistryEntry } from "./registry.js"
14
13
  import { AsyncIngest, AsyncIngestLive } from "./services/AsyncIngest.js"
15
- import { TelemetryStore, TelemetryStoreLive } from "./services/TelemetryStore.js"
16
- import type { LogItem, TraceItem, TraceSummaryItem } from "./domain.js"
14
+ import { TelemetryStoreReadonly } from "./services/TelemetryStore.js"
15
+ import { TelemetryQueryLive } from "./services/TelemetryQuery.js"
16
+ import type { LogItem, TraceItem } from "./domain.js"
17
17
  import { lifecycleLabel } from "./ui/format.js"
18
+ import { decodeProtobufLogs, decodeProtobufTraces } from "./otlpProtobuf.js"
19
+ import type { OtlpLogExportRequest, OtlpTraceExportRequest } from "./otlp.js"
18
20
 
19
21
  // Set by the RegistryLayer acquisition once the Bun socket has bound.
20
22
  // Both /api/health and the registry entry read from here so they agree
21
23
  // on a single server-start timestamp, and the value reflects actual
22
24
  // listen time rather than module-evaluation time.
23
- let serverStartedAt: string = new Date(0).toISOString()
25
+ let serverStartedAt: string = new Date().toISOString()
24
26
 
25
- const TRACE_DEFAULT_LIMIT = 20
26
- const TRACE_MAX_LIMIT = 100
27
- const TRACE_DEFAULT_LOOKBACK = 60
28
- const TRACE_MAX_LOOKBACK = 24 * 60
29
- const SPAN_DEFAULT_LIMIT = 100
30
- const SPAN_MAX_LIMIT = 500
31
- const LOG_DEFAULT_LIMIT = 100
32
- const LOG_MAX_LIMIT = 500
33
- const LOG_DEFAULT_LOOKBACK = 60
34
- const LOG_MAX_LOOKBACK = 24 * 60
27
+ const requestUrl = (request: { readonly url: string }) => decodeRequestUrl(request, config.otel.baseUrl)
28
+ const parseListParams = (request: { readonly url: string }, bounds: ListBounds) => decodeListParams(request, bounds, config.otel.baseUrl)
35
29
 
36
30
  const jsonResponse = (value: unknown, status = 200) => HttpServerResponse.jsonUnsafe(value, { status })
37
31
  const textResponse = (value: string) => HttpServerResponse.text(value)
38
32
  const htmlResponse = (value: string) => HttpServerResponse.html(value)
39
33
  const notFoundResponse = (message = "Not found") => jsonResponse({ error: message }, 404)
40
- const requestUrl = (request: { readonly url: string }) => new URL(request.url, config.otel.baseUrl)
41
- const withStore = <A>(f: (store: TelemetryStore["Service"]) => Effect.Effect<A, Error>) => Effect.flatMap(TelemetryStore.asEffect(), f)
34
+ const healthPayload = () => ({
35
+ ok: true,
36
+ service: MOTEL_SERVICE_ID,
37
+ databasePath: config.otel.databasePath,
38
+ pid: process.pid,
39
+ url: config.otel.baseUrl,
40
+ workdir: process.cwd(),
41
+ startedAt: serverStartedAt,
42
+ version: MOTEL_VERSION,
43
+ instanceId: process.env.MOTEL_DAEMON_INSTANCE_ID?.trim(),
44
+ })
45
+ // Query handlers resolve against the readonly store identifier so they
46
+ // don't contend with the writer connection that owns ingest/retention.
47
+ const withRead = <A>(f: (store: TelemetryStoreReadonly["Service"]) => Effect.Effect<A, Error>) => Effect.flatMap(TelemetryStoreReadonly, f)
42
48
  // Response-building helpers are generic in R so a handler can depend
43
- // on TelemetryStore (query path) or AsyncIngest (worker-RPC path)
44
- // without forcing every handler onto the same service surface.
49
+ // on AsyncIngest (worker-RPC path) or TelemetryStoreReadonly (query
50
+ // path) without forcing every handler onto the same service surface.
45
51
  const respondJson = <A, R>(effect: Effect.Effect<A, unknown, R>) =>
46
52
  Effect.match(effect, {
47
53
  onFailure: (error) => jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 500),
@@ -53,144 +59,50 @@ const respondRaw = <R>(effect: Effect.Effect<ReturnType<typeof jsonResponse>, un
53
59
  onSuccess: (value) => value,
54
60
  })
55
61
 
56
- const parseLimit = (value: string | null, fallback: number) => parsePositiveInt(value ?? undefined, fallback)
57
- const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(value, max))
58
- const parseBoundedLimit = (value: string | null, fallback: number, max: number) => clamp(parseLimit(value, fallback), 1, max)
59
-
60
- const parseLookbackMinutes = (value: string | null, fallback: number) => {
61
- if (!value) return fallback
62
- const match = value.trim().match(/^(\d+)([mhd])$/i)
63
- if (!match) return fallback
64
- const amount = Number.parseInt(match[1] ?? "", 10)
65
- if (!Number.isFinite(amount) || amount <= 0) return fallback
66
- const unit = (match[2] ?? "m").toLowerCase()
67
- if (unit === "d") return amount * 1440
68
- if (unit === "h") return amount * 60
69
- return amount
70
- }
71
-
72
- const parseBoundedLookbackMinutes = (value: string | null, fallback: number, max: number) => clamp(parseLookbackMinutes(value, fallback), 1, max)
73
-
74
- const attributeFiltersFromQuery = (url: URL) =>
75
- attributeFiltersFromEntries(url.searchParams.entries())
76
-
77
- const attributeContainsFiltersFromQuery = (url: URL) =>
78
- attributeContainsFiltersFromEntries(url.searchParams.entries())
79
-
80
- type CursorShape =
81
- | { readonly kind: "trace"; readonly startedAt: number; readonly id: string }
82
- | { readonly kind: "log"; readonly timestamp: number; readonly id: string }
83
-
84
- const encodeCursor = (cursor: CursorShape) => Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url")
85
-
86
- const decodeCursor = (value: string | null): CursorShape | null => {
87
- if (!value) return null
88
- try {
89
- return JSON.parse(Buffer.from(value, "base64url").toString("utf8")) as CursorShape
90
- } catch {
91
- return null
62
+ const readOtlpBody = <T>(
63
+ request: {
64
+ readonly json: Effect.Effect<unknown, unknown>
65
+ readonly arrayBuffer: Effect.Effect<ArrayBuffer, unknown>
66
+ readonly headers: Readonly<Record<string, string | undefined>>
67
+ },
68
+ decodeProtobuf: (bytes: Uint8Array) => T,
69
+ ): Effect.Effect<T, unknown> => {
70
+ const contentType = (request.headers["content-type"] ?? "").toLowerCase()
71
+ if (contentType.includes("application/x-protobuf") || contentType.includes("application/protobuf")) {
72
+ return Effect.map(request.arrayBuffer, (buffer) => decodeProtobuf(new Uint8Array(buffer)))
92
73
  }
74
+ return Effect.map(request.json, (payload) => payload as T)
93
75
  }
94
76
 
95
- const formatLookback = (minutes: number) => {
96
- if (minutes % 1440 === 0) return `${minutes / 1440}d`
97
- if (minutes % 60 === 0) return `${minutes / 60}h`
98
- return `${minutes}m`
99
- }
100
-
101
- const listMeta = (input: { readonly limit: number; readonly lookbackMinutes: number; readonly returned: number; readonly truncated: boolean; readonly nextCursor: string | null }) => ({
102
- limit: input.limit,
103
- lookback: formatLookback(input.lookbackMinutes),
104
- returned: input.returned,
105
- truncated: input.truncated,
106
- nextCursor: input.nextCursor,
107
- })
108
-
109
- const paginateSummaries = (summaries: readonly TraceSummaryItem[], options: { readonly limit: number; readonly lookbackMinutes: number; readonly cursor: CursorShape | null }) => {
110
- const page = summaries.slice(0, options.limit)
111
- const last = page.at(-1)
112
- return {
113
- data: page,
114
- meta: listMeta({
115
- limit: options.limit,
116
- lookbackMinutes: options.lookbackMinutes,
117
- returned: page.length,
118
- truncated: summaries.length > page.length,
119
- nextCursor: last ? encodeCursor({ kind: "trace", startedAt: last.startedAt.getTime(), id: last.traceId }) : null,
120
- }),
121
- }
122
- }
123
-
124
- const paginateLogs = (logs: readonly LogItem[], options: { readonly limit: number; readonly lookbackMinutes: number; readonly cursor: CursorShape | null }) => {
125
- const page = logs.slice(0, options.limit)
126
- const last = page.at(-1)
127
-
128
- return {
129
- data: page,
130
- meta: listMeta({
131
- limit: options.limit,
132
- lookbackMinutes: options.lookbackMinutes,
133
- returned: page.length,
134
- truncated: logs.length > page.length,
135
- nextCursor: last ? encodeCursor({ kind: "log", timestamp: last.timestamp.getTime(), id: last.id }) : null,
136
- }),
137
- }
138
- }
139
-
140
- const loadLogsPage = (input: {
141
- readonly serviceName?: string | null
142
- readonly severity?: string | null
143
- readonly traceId?: string | null
144
- readonly spanId?: string | null
145
- readonly body?: string | null
146
- readonly attributeFilters?: Readonly<Record<string, string>>
147
- readonly attributeContainsFilters?: Readonly<Record<string, string>>
148
- readonly limit: number
149
- readonly lookbackMinutes: number
150
- readonly cursor: CursorShape | null
151
- }) =>
152
- Effect.flatMap(TelemetryStore.asEffect(), (store) =>
153
- Effect.map(
154
- store.searchLogs({
155
- serviceName: input.serviceName,
156
- severity: input.severity,
157
- traceId: input.traceId,
158
- spanId: input.spanId,
159
- body: input.body,
160
- lookbackMinutes: input.lookbackMinutes,
161
- limit: input.limit + 1,
162
- cursorTimestampMs: input.cursor?.kind === "log" ? input.cursor.timestamp : undefined,
163
- cursorId: input.cursor?.kind === "log" ? input.cursor.id : undefined,
164
- attributeFilters: input.attributeFilters,
165
- attributeContainsFilters: input.attributeContainsFilters,
166
- }),
167
- (logs) => paginateLogs(logs, {
168
- limit: input.limit,
169
- lookbackMinutes: input.lookbackMinutes,
170
- cursor: input.cursor,
171
- }),
172
- ),
77
+ // Log page loader: takes the parsed list params + any resource-specific
78
+ // filter values (service, severity, traceId, spanId, body), runs the
79
+ // store query with limit+1 to detect a next page, and shapes the
80
+ // paginated response.
81
+ const loadLogsPage = (
82
+ params: ListParams,
83
+ filters: { readonly serviceName?: string | null; readonly severity?: string | null; readonly traceId?: string | null; readonly spanId?: string | null; readonly body?: string | null },
84
+ ) =>
85
+ Effect.map(
86
+ withRead((store) => store.searchLogs({
87
+ ...filters,
88
+ ...logCursorArgs(params.cursor),
89
+ attributeFilters: params.attributeFilters,
90
+ attributeContainsFilters: params.attributeContainsFilters,
91
+ lookbackMinutes: params.lookbackMinutes,
92
+ limit: params.limit + 1,
93
+ })),
94
+ (logs) => paginateLogs(logs, params),
173
95
  )
174
96
 
175
97
  const handleLogSearch = (request: { readonly url: string }) =>
176
98
  respondRaw(Effect.gen(function*() {
177
- const url = requestUrl(request)
178
- const attributeFilters = attributeFiltersFromQuery(url)
179
- const attributeContainsFilters = attributeContainsFiltersFromQuery(url)
180
- const limit = parseBoundedLimit(url.searchParams.get("limit"), LOG_DEFAULT_LIMIT, LOG_MAX_LIMIT)
181
- const lookbackMinutes = parseBoundedLookbackMinutes(url.searchParams.get("lookback"), LOG_DEFAULT_LOOKBACK, LOG_MAX_LOOKBACK)
182
- const cursor = decodeCursor(url.searchParams.get("cursor"))
183
- return jsonResponse(yield* loadLogsPage({
184
- serviceName: url.searchParams.get("service"),
185
- severity: url.searchParams.get("severity"),
186
- traceId: url.searchParams.get("traceId"),
187
- spanId: url.searchParams.get("spanId"),
188
- body: url.searchParams.get("body"),
189
- attributeFilters,
190
- attributeContainsFilters,
191
- limit,
192
- lookbackMinutes,
193
- cursor,
99
+ const params = parseListParams(request, LOG_LIST)
100
+ return jsonResponse(yield* loadLogsPage(params, {
101
+ serviceName: params.url.searchParams.get("service"),
102
+ severity: params.url.searchParams.get("severity"),
103
+ traceId: params.url.searchParams.get("traceId"),
104
+ spanId: params.url.searchParams.get("spanId"),
105
+ body: params.url.searchParams.get("body"),
194
106
  }))
195
107
  }))
196
108
 
@@ -277,103 +189,84 @@ const TelemetryGroupLive = HttpApiBuilder.group(
277
189
  Effect.succeed(textResponse("motel local telemetry server\n\nPOST /v1/traces\nPOST /v1/logs\nGET /api/services\nGET /api/traces\nGET /api/traces/search\nGET /api/traces/stats\nGET /api/traces/<trace-id>\nGET /api/traces/<trace-id>/spans\nGET /api/traces/<trace-id>/logs\nGET /api/spans/search\nGET /api/spans/<span-id>\nGET /api/spans/<span-id>/logs\nGET /api/logs\nGET /api/logs/search\nGET /api/logs/stats\nGET /api/ai/calls\nGET /api/ai/calls/<span-id>\nGET /api/ai/stats\nGET /api/facets?type=logs&field=severity\nGET /api/docs\nGET /api/docs/<name>\nGET /openapi.json\nGET /docs\nGET /trace/<trace-id>\n")),
278
190
  )
279
191
  .handle("health", () =>
280
- Effect.succeed({
281
- ok: true,
282
- service: MOTEL_SERVICE_ID,
283
- databasePath: config.otel.databasePath,
284
- pid: process.pid,
285
- url: config.otel.baseUrl,
286
- workdir: process.cwd(),
287
- startedAt: serverStartedAt,
288
- version: MOTEL_VERSION,
289
- }),
192
+ HttpMiddleware.withLoggerDisabled(Effect.succeed(healthPayload())),
290
193
  )
291
194
  // OTLP ingest is routed to the worker thread via AsyncIngest
292
195
  // so the main event loop stays free during heavy SQLite writes.
293
- // Everything else still uses the direct TelemetryStore reads
294
- // are fast enough that IPC overhead isn't worth paying.
196
+ // Read queries use a separate query worker so synchronous SQLite
197
+ // work cannot block the HTTP event loop.
295
198
  .handleRaw("ingestTraces", ({ request }) =>
296
- respondRaw(
297
- Effect.flatMap(request.json, (payload) =>
298
- Effect.map(
299
- Effect.flatMap(AsyncIngest.asEffect(), (ingest) => ingest.ingestTraces({ payload })),
199
+ HttpMiddleware.withLoggerDisabled(respondRaw(
200
+ Effect.flatMap(
201
+ readOtlpBody<OtlpTraceExportRequest>(request, decodeProtobufTraces),
202
+ (payload) => Effect.map(
203
+ Effect.flatMap(AsyncIngest, (ingest) => ingest.ingestTraces({ payload })),
300
204
  (result) => jsonResponse(result),
301
205
  ),
302
206
  ),
303
- ),
207
+ )),
304
208
  )
305
209
  .handleRaw("ingestLogs", ({ request }) =>
306
- respondRaw(
307
- Effect.flatMap(request.json, (payload) =>
308
- Effect.map(
309
- Effect.flatMap(AsyncIngest.asEffect(), (ingest) => ingest.ingestLogs({ payload })),
210
+ HttpMiddleware.withLoggerDisabled(respondRaw(
211
+ Effect.flatMap(
212
+ readOtlpBody<OtlpLogExportRequest>(request, decodeProtobufLogs),
213
+ (payload) => Effect.map(
214
+ Effect.flatMap(AsyncIngest, (ingest) => ingest.ingestLogs({ payload })),
310
215
  (result) => jsonResponse(result),
311
216
  ),
312
217
  ),
313
- ),
218
+ )),
314
219
  )
315
- .handleRaw("services", () => respondJson(Effect.map(withStore((store) => store.listServices), (data) => ({ data }))))
220
+ .handleRaw("services", () => respondJson(Effect.map(withRead((store) => store.listServices), (data) => ({ data }))))
316
221
  .handleRaw("traces", ({ request }) =>
317
222
  respondRaw(Effect.gen(function*() {
318
- const url = requestUrl(request)
319
- const service = url.searchParams.get("service")
320
- const limit = parseBoundedLimit(url.searchParams.get("limit"), TRACE_DEFAULT_LIMIT, TRACE_MAX_LIMIT)
321
- const lookbackMinutes = parseBoundedLookbackMinutes(url.searchParams.get("lookback"), TRACE_DEFAULT_LOOKBACK, TRACE_MAX_LOOKBACK)
322
- const cursor = decodeCursor(url.searchParams.get("cursor"))
323
- const data = yield* withStore((store) => store.listTraceSummaries(service, {
324
- limit: limit + 1,
325
- lookbackMinutes,
326
- cursorStartedAtMs: cursor?.kind === "trace" ? cursor.startedAt : undefined,
327
- cursorTraceId: cursor?.kind === "trace" ? cursor.id : undefined,
223
+ const params = parseListParams(request, TRACE_LIST)
224
+ const data = yield* withRead((store) => store.listTraceSummaries(params.url.searchParams.get("service"), {
225
+ limit: params.limit + 1,
226
+ lookbackMinutes: params.lookbackMinutes,
227
+ ...traceCursorArgs(params.cursor),
328
228
  }))
329
- return jsonResponse(paginateSummaries(data, { limit, lookbackMinutes, cursor }))
229
+ return jsonResponse(paginateSummaries(data, params))
330
230
  })),
331
231
  )
332
232
  .handleRaw("searchTraces", ({ request }) =>
333
233
  respondRaw(Effect.gen(function*() {
334
- const url = requestUrl(request)
335
- const attributeFilters = attributeFiltersFromQuery(url)
336
- const limit = parseBoundedLimit(url.searchParams.get("limit"), TRACE_DEFAULT_LIMIT, TRACE_MAX_LIMIT)
337
- const lookbackMinutes = parseBoundedLookbackMinutes(url.searchParams.get("lookback"), TRACE_DEFAULT_LOOKBACK, TRACE_MAX_LOOKBACK)
338
- const cursor = decodeCursor(url.searchParams.get("cursor"))
339
- const data = yield* withStore((store) =>
234
+ const params = parseListParams(request, TRACE_LIST)
235
+ const data = yield* withRead((store) =>
340
236
  store.searchTraceSummaries({
341
- serviceName: url.searchParams.get("service"),
342
- operation: url.searchParams.get("operation"),
343
- status: (url.searchParams.get("status") as "ok" | "error" | null) ?? null,
344
- minDurationMs: url.searchParams.get("minDurationMs") ? Number.parseFloat(url.searchParams.get("minDurationMs") ?? "") : null,
345
- attributeFilters,
346
- aiText: url.searchParams.get("aiText"),
347
- limit: limit + 1,
348
- lookbackMinutes,
349
- cursorStartedAtMs: cursor?.kind === "trace" ? cursor.startedAt : undefined,
350
- cursorTraceId: cursor?.kind === "trace" ? cursor.id : undefined,
237
+ serviceName: params.url.searchParams.get("service"),
238
+ operation: params.url.searchParams.get("operation"),
239
+ status: (params.url.searchParams.get("status") as "ok" | "error" | null) ?? null,
240
+ minDurationMs: params.url.searchParams.get("minDurationMs") ? Number.parseFloat(params.url.searchParams.get("minDurationMs") ?? "") : null,
241
+ attributeFilters: params.attributeFilters,
242
+ aiText: params.url.searchParams.get("aiText"),
243
+ limit: params.limit + 1,
244
+ lookbackMinutes: params.lookbackMinutes,
245
+ ...traceCursorArgs(params.cursor),
351
246
  }),
352
247
  )
353
- return jsonResponse(paginateSummaries(data, { limit, lookbackMinutes, cursor }))
248
+ return jsonResponse(paginateSummaries(data, params))
354
249
  })),
355
250
  )
356
251
  .handleRaw("traceStats", ({ request }) =>
357
252
  respondRaw(Effect.gen(function*() {
358
- const url = requestUrl(request)
359
- const attributeFilters = attributeFiltersFromQuery(url)
360
- const groupBy = url.searchParams.get("groupBy")
361
- const agg = url.searchParams.get("agg")
362
- const lookbackMinutes = parseBoundedLookbackMinutes(url.searchParams.get("lookback"), TRACE_DEFAULT_LOOKBACK, TRACE_MAX_LOOKBACK)
253
+ const params = parseListParams(request, TRACE_STATS)
254
+ const groupBy = params.url.searchParams.get("groupBy")
255
+ const agg = params.url.searchParams.get("agg")
363
256
  if (!groupBy || (agg !== "count" && agg !== "avg_duration" && agg !== "p95_duration" && agg !== "error_rate")) {
364
257
  return jsonResponse({ error: "Expected groupBy and agg=count|avg_duration|p95_duration|error_rate" }, 400)
365
258
  }
366
- const data = yield* withStore((store) =>
259
+ const data = yield* withRead((store) =>
367
260
  store.traceStats({
368
261
  groupBy,
369
262
  agg,
370
- serviceName: url.searchParams.get("service"),
371
- operation: url.searchParams.get("operation"),
372
- status: (url.searchParams.get("status") as "ok" | "error" | null) ?? null,
373
- minDurationMs: url.searchParams.get("minDurationMs") ? Number.parseFloat(url.searchParams.get("minDurationMs") ?? "") : null,
374
- attributeFilters,
375
- limit: parseBoundedLimit(url.searchParams.get("limit"), 20, TRACE_MAX_LIMIT),
376
- lookbackMinutes,
263
+ serviceName: params.url.searchParams.get("service"),
264
+ operation: params.url.searchParams.get("operation"),
265
+ status: (params.url.searchParams.get("status") as "ok" | "error" | null) ?? null,
266
+ minDurationMs: params.url.searchParams.get("minDurationMs") ? Number.parseFloat(params.url.searchParams.get("minDurationMs") ?? "") : null,
267
+ attributeFilters: params.attributeFilters,
268
+ limit: params.limit,
269
+ lookbackMinutes: params.lookbackMinutes,
377
270
  }),
378
271
  )
379
272
  return jsonResponse({ data })
@@ -381,31 +274,27 @@ const TelemetryGroupLive = HttpApiBuilder.group(
381
274
  )
382
275
  .handleRaw("searchSpans", ({ request }) =>
383
276
  respondRaw(Effect.gen(function*() {
384
- const url = requestUrl(request)
385
- const attributeFilters = attributeFiltersFromQuery(url)
386
- const attributeContainsFilters = attributeContainsFiltersFromQuery(url)
387
- const limit = parseBoundedLimit(url.searchParams.get("limit"), SPAN_DEFAULT_LIMIT, SPAN_MAX_LIMIT)
388
- const lookbackMinutes = parseBoundedLookbackMinutes(url.searchParams.get("lookback"), TRACE_DEFAULT_LOOKBACK, TRACE_MAX_LOOKBACK)
389
- const data = yield* withStore((store) =>
277
+ const params = parseListParams(request, SPAN_LIST)
278
+ const data = yield* withRead((store) =>
390
279
  store.searchSpans({
391
- serviceName: url.searchParams.get("service"),
392
- traceId: url.searchParams.get("traceId"),
393
- operation: url.searchParams.get("operation"),
394
- parentOperation: url.searchParams.get("parentOperation"),
395
- status: (url.searchParams.get("status") as "ok" | "error" | null) ?? null,
396
- attributeFilters,
397
- attributeContainsFilters,
398
- limit: limit + 1,
399
- lookbackMinutes,
280
+ serviceName: params.url.searchParams.get("service"),
281
+ traceId: params.url.searchParams.get("traceId"),
282
+ operation: params.url.searchParams.get("operation"),
283
+ parentOperation: params.url.searchParams.get("parentOperation"),
284
+ status: (params.url.searchParams.get("status") as "ok" | "error" | null) ?? null,
285
+ attributeFilters: params.attributeFilters,
286
+ attributeContainsFilters: params.attributeContainsFilters,
287
+ limit: params.limit + 1,
288
+ lookbackMinutes: params.lookbackMinutes,
400
289
  }),
401
290
  )
402
- const truncated = data.length > limit
403
- const page = truncated ? data.slice(0, limit) : data
291
+ const truncated = data.length > params.limit
292
+ const page = truncated ? data.slice(0, params.limit) : data
404
293
  return jsonResponse({
405
294
  data: page,
406
295
  meta: listMeta({
407
- limit,
408
- lookbackMinutes,
296
+ limit: params.limit,
297
+ lookbackMinutes: params.lookbackMinutes,
409
298
  returned: page.length,
410
299
  truncated,
411
300
  nextCursor: null,
@@ -413,37 +302,31 @@ const TelemetryGroupLive = HttpApiBuilder.group(
413
302
  })
414
303
  })),
415
304
  )
416
- .handleRaw("traceLogs", ({ params, request }) =>
305
+ .handleRaw("traceLogs", ({ params: route, request }) =>
417
306
  respondRaw(Effect.gen(function*() {
418
- const url = requestUrl(request)
419
- const lookbackMinutes = parseBoundedLookbackMinutes(url.searchParams.get("lookback"), LOG_DEFAULT_LOOKBACK, LOG_MAX_LOOKBACK)
420
- const limit = parseBoundedLimit(url.searchParams.get("limit"), LOG_DEFAULT_LIMIT, LOG_MAX_LIMIT)
421
- const cursor = decodeCursor(url.searchParams.get("cursor"))
422
- return jsonResponse(yield* loadLogsPage({ traceId: params.traceId, limit, lookbackMinutes, cursor }))
307
+ const params = parseListParams(request, LOG_LIST)
308
+ return jsonResponse(yield* loadLogsPage(params, { traceId: route.traceId }))
423
309
  })),
424
310
  )
425
311
  .handleRaw("traceSpans", ({ params }) =>
426
- respondJson(Effect.map(withStore((store) => store.listTraceSpans(params.traceId)), (data) => ({ data }))),
312
+ respondJson(Effect.map(withRead((store) => store.listTraceSpans(params.traceId)), (data) => ({ data }))),
427
313
  )
428
- .handleRaw("spanLogs", ({ params, request }) =>
314
+ .handleRaw("spanLogs", ({ params: route, request }) =>
429
315
  respondRaw(Effect.gen(function*() {
430
- const url = requestUrl(request)
431
- const lookbackMinutes = parseBoundedLookbackMinutes(url.searchParams.get("lookback"), LOG_DEFAULT_LOOKBACK, LOG_MAX_LOOKBACK)
432
- const limit = parseBoundedLimit(url.searchParams.get("limit"), LOG_DEFAULT_LIMIT, LOG_MAX_LIMIT)
433
- const cursor = decodeCursor(url.searchParams.get("cursor"))
434
- return jsonResponse(yield* loadLogsPage({ spanId: params.spanId, limit, lookbackMinutes, cursor }))
316
+ const params = parseListParams(request, LOG_LIST)
317
+ return jsonResponse(yield* loadLogsPage(params, { spanId: route.spanId }))
435
318
  })),
436
319
  )
437
320
  .handleRaw("span", ({ params }) =>
438
321
  respondRaw(
439
- Effect.flatMap(withStore((store) => store.getSpan(params.spanId)), (data) =>
322
+ Effect.flatMap(withRead((store) => store.getSpan(params.spanId)), (data) =>
440
323
  Effect.succeed(data ? jsonResponse({ data }) : notFoundResponse("Span not found")),
441
324
  ),
442
325
  ),
443
326
  )
444
327
  .handleRaw("trace", ({ params }) =>
445
328
  respondRaw(
446
- Effect.flatMap(withStore((store) => store.getTrace(params.traceId)), (data) =>
329
+ Effect.flatMap(withRead((store) => store.getTrace(params.traceId)), (data) =>
447
330
  Effect.succeed(data ? jsonResponse({ data }) : notFoundResponse("Trace not found")),
448
331
  ),
449
332
  ),
@@ -452,25 +335,23 @@ const TelemetryGroupLive = HttpApiBuilder.group(
452
335
  .handleRaw("searchLogs", ({ request }) => handleLogSearch(request))
453
336
  .handleRaw("logStats", ({ request }) =>
454
337
  respondRaw(Effect.gen(function*() {
455
- const url = requestUrl(request)
456
- const attributeFilters = attributeFiltersFromQuery(url)
457
- const groupBy = url.searchParams.get("groupBy")
458
- const agg = url.searchParams.get("agg")
459
- const lookbackMinutes = parseBoundedLookbackMinutes(url.searchParams.get("lookback"), LOG_DEFAULT_LOOKBACK, LOG_MAX_LOOKBACK)
338
+ const params = parseListParams(request, LOG_STATS)
339
+ const groupBy = params.url.searchParams.get("groupBy")
340
+ const agg = params.url.searchParams.get("agg")
460
341
  if (!groupBy || agg !== "count") {
461
342
  return jsonResponse({ error: "Expected groupBy and agg=count" }, 400)
462
343
  }
463
- const data = yield* withStore((store) =>
344
+ const data = yield* withRead((store) =>
464
345
  store.logStats({
465
346
  groupBy,
466
347
  agg: "count",
467
- serviceName: url.searchParams.get("service"),
468
- traceId: url.searchParams.get("traceId"),
469
- spanId: url.searchParams.get("spanId"),
470
- body: url.searchParams.get("body"),
471
- attributeFilters,
472
- limit: parseBoundedLimit(url.searchParams.get("limit"), 20, LOG_MAX_LIMIT),
473
- lookbackMinutes,
348
+ serviceName: params.url.searchParams.get("service"),
349
+ traceId: params.url.searchParams.get("traceId"),
350
+ spanId: params.url.searchParams.get("spanId"),
351
+ body: params.url.searchParams.get("body"),
352
+ attributeFilters: params.attributeFilters,
353
+ limit: params.limit,
354
+ lookbackMinutes: params.lookbackMinutes,
474
355
  }),
475
356
  )
476
357
  return jsonResponse({ data })
@@ -486,18 +367,17 @@ const TelemetryGroupLive = HttpApiBuilder.group(
486
367
  )
487
368
  .handleRaw("doc", ({ params }) =>
488
369
  respondRaw(Effect.gen(function*() {
370
+ const fileSystem = yield* FileSystem.FileSystem
489
371
  const docFiles: Record<string, string> = {
490
372
  debug: path.resolve(import.meta.dir, "../skills/motel-debug/SKILL.md"),
491
373
  effect: path.resolve(import.meta.dir, "../skills/motel-debug/references/effect.md"),
492
374
  }
493
375
  const filePath = docFiles[params.name]
494
376
  if (!filePath) return notFoundResponse(`Unknown doc: ${params.name}. Available: ${Object.keys(docFiles).join(", ")}`)
495
- try {
496
- const content = yield* Effect.promise(() => fs.readFile(filePath, "utf8"))
497
- return textResponse(content)
498
- } catch {
499
- return notFoundResponse(`Doc file not found: ${params.name}`)
500
- }
377
+ return yield* fileSystem.readFileString(filePath).pipe(
378
+ Effect.map(textResponse),
379
+ Effect.catch(() => Effect.succeed(notFoundResponse(`Doc file not found: ${params.name}`))),
380
+ )
501
381
  })),
502
382
  )
503
383
  .handleRaw("facets", ({ request }) =>
@@ -508,7 +388,7 @@ const TelemetryGroupLive = HttpApiBuilder.group(
508
388
  if ((type !== "traces" && type !== "logs") || !field) {
509
389
  return jsonResponse({ error: "Expected type=traces|logs and field=<name>" }, 400)
510
390
  }
511
- const data = yield* withStore((store) =>
391
+ const data = yield* withRead((store) =>
512
392
  store.listFacets({
513
393
  type,
514
394
  field,
@@ -523,62 +403,59 @@ const TelemetryGroupLive = HttpApiBuilder.group(
523
403
  )
524
404
  .handleRaw("aiCalls", ({ request }) =>
525
405
  respondRaw(Effect.gen(function*() {
526
- const url = requestUrl(request)
527
- const limit = parseBoundedLimit(url.searchParams.get("limit"), 20, SPAN_MAX_LIMIT)
528
- const lookbackMinutes = parseBoundedLookbackMinutes(url.searchParams.get("lookback"), TRACE_DEFAULT_LOOKBACK, TRACE_MAX_LOOKBACK)
529
- const data = yield* withStore((store) =>
406
+ const params = parseListParams(request, AI_LIST)
407
+ const data = yield* withRead((store) =>
530
408
  store.searchAiCalls({
531
- service: url.searchParams.get("service"),
532
- traceId: url.searchParams.get("traceId"),
533
- sessionId: url.searchParams.get("sessionId"),
534
- functionId: url.searchParams.get("functionId"),
535
- provider: url.searchParams.get("provider"),
536
- model: url.searchParams.get("model"),
537
- operation: url.searchParams.get("operation"),
538
- status: (url.searchParams.get("status") as "ok" | "error" | null) ?? null,
539
- minDurationMs: url.searchParams.get("minDurationMs") ? Number(url.searchParams.get("minDurationMs")) : null,
540
- text: url.searchParams.get("text"),
541
- lookbackMinutes,
542
- limit,
409
+ service: params.url.searchParams.get("service"),
410
+ traceId: params.url.searchParams.get("traceId"),
411
+ sessionId: params.url.searchParams.get("sessionId"),
412
+ functionId: params.url.searchParams.get("functionId"),
413
+ provider: params.url.searchParams.get("provider"),
414
+ model: params.url.searchParams.get("model"),
415
+ operation: params.url.searchParams.get("operation"),
416
+ status: (params.url.searchParams.get("status") as "ok" | "error" | null) ?? null,
417
+ minDurationMs: params.url.searchParams.get("minDurationMs") ? Number(params.url.searchParams.get("minDurationMs")) : null,
418
+ text: params.url.searchParams.get("text"),
419
+ lookbackMinutes: params.lookbackMinutes,
420
+ limit: params.limit,
543
421
  }),
544
422
  )
545
423
  return jsonResponse({
546
424
  data,
547
- meta: listMeta({ limit, lookbackMinutes, returned: data.length, truncated: false, nextCursor: null }),
425
+ meta: listMeta({ limit: params.limit, lookbackMinutes: params.lookbackMinutes, returned: data.length, truncated: false, nextCursor: null }),
548
426
  })
549
427
  })),
550
428
  )
551
429
  .handleRaw("aiCall", ({ params }) =>
552
430
  respondRaw(Effect.gen(function*() {
553
- const data = yield* withStore((store) => store.getAiCall(params.spanId))
431
+ const data = yield* withRead((store) => store.getAiCall(params.spanId))
554
432
  if (!data) return notFoundResponse("AI call not found")
555
433
  return jsonResponse({ data })
556
434
  })),
557
435
  )
558
436
  .handleRaw("aiStats", ({ request }) =>
559
437
  respondRaw(Effect.gen(function*() {
560
- const url = requestUrl(request)
561
- const groupBy = url.searchParams.get("groupBy") as "provider" | "model" | "functionId" | "sessionId" | "status" | null
562
- const agg = url.searchParams.get("agg") as "count" | "avg_duration" | "p95_duration" | "total_input_tokens" | "total_output_tokens" | null
438
+ const params = parseListParams(request, AI_LIST)
439
+ const groupBy = params.url.searchParams.get("groupBy") as "provider" | "model" | "functionId" | "sessionId" | "status" | null
440
+ const agg = params.url.searchParams.get("agg") as "count" | "avg_duration" | "p95_duration" | "total_input_tokens" | "total_output_tokens" | null
563
441
  if (!groupBy || !agg) {
564
442
  return jsonResponse({ error: "Expected groupBy and agg parameters" }, 400)
565
443
  }
566
- const lookbackMinutes = parseBoundedLookbackMinutes(url.searchParams.get("lookback"), TRACE_DEFAULT_LOOKBACK, TRACE_MAX_LOOKBACK)
567
- const data = yield* withStore((store) =>
444
+ const data = yield* withRead((store) =>
568
445
  store.aiCallStats({
569
446
  groupBy,
570
447
  agg,
571
- service: url.searchParams.get("service"),
572
- traceId: url.searchParams.get("traceId"),
573
- sessionId: url.searchParams.get("sessionId"),
574
- functionId: url.searchParams.get("functionId"),
575
- provider: url.searchParams.get("provider"),
576
- model: url.searchParams.get("model"),
577
- operation: url.searchParams.get("operation"),
578
- status: (url.searchParams.get("status") as "ok" | "error" | null) ?? null,
579
- minDurationMs: url.searchParams.get("minDurationMs") ? Number(url.searchParams.get("minDurationMs")) : null,
580
- lookbackMinutes,
581
- limit: parseBoundedLimit(url.searchParams.get("limit"), 20, SPAN_MAX_LIMIT),
448
+ service: params.url.searchParams.get("service"),
449
+ traceId: params.url.searchParams.get("traceId"),
450
+ sessionId: params.url.searchParams.get("sessionId"),
451
+ functionId: params.url.searchParams.get("functionId"),
452
+ provider: params.url.searchParams.get("provider"),
453
+ model: params.url.searchParams.get("model"),
454
+ operation: params.url.searchParams.get("operation"),
455
+ status: (params.url.searchParams.get("status") as "ok" | "error" | null) ?? null,
456
+ minDurationMs: params.url.searchParams.get("minDurationMs") ? Number(params.url.searchParams.get("minDurationMs")) : null,
457
+ lookbackMinutes: params.lookbackMinutes,
458
+ limit: params.limit,
582
459
  }),
583
460
  )
584
461
  return jsonResponse({ data })
@@ -586,9 +463,9 @@ const TelemetryGroupLive = HttpApiBuilder.group(
586
463
  )
587
464
  .handleRaw("tracePage", ({ params }) =>
588
465
  respondRaw(
589
- Effect.flatMap(withStore((store) => store.getTrace(params.traceId)), (trace) =>
466
+ Effect.flatMap(withRead((store) => store.getTrace(params.traceId)), (trace) =>
590
467
  trace
591
- ? Effect.map(withStore((store) => store.listTraceLogs(params.traceId)), (logs) => htmlResponse(renderTracePage(trace, logs)))
468
+ ? Effect.map(withRead((store) => store.listTraceLogs(params.traceId)), (logs) => htmlResponse(renderTracePage(trace, logs)))
592
469
  : Effect.succeed(notFoundResponse("Trace not found")),
593
470
  ),
594
471
  ),
@@ -635,6 +512,8 @@ const RegistryLayer = Layer.effectDiscard(
635
512
  startedAt: serverStartedAt,
636
513
  version: MOTEL_VERSION,
637
514
  databasePath: config.otel.databasePath,
515
+ instanceId: process.env.MOTEL_DAEMON_INSTANCE_ID?.trim(),
516
+ processIdentity: processIdentity(process.pid) ?? undefined,
638
517
  })
639
518
  } catch (err) {
640
519
  console.warn(`motel: failed to write registry entry: ${(err as Error).message}`)
@@ -672,16 +551,18 @@ export const ServerLive = HttpRouter.serve(
672
551
  // POSTs again on the next flush. This also shaves ~1 KB of header
673
552
  // attributes off every ingest request that would have been written
674
553
  // to the spans table as noise.
675
- Layer.provide(HttpMiddleware.layerTracerDisabledForUrls(["/v1/traces", "/v1/logs"])),
676
- // AsyncIngest spawns the telemetry worker keeps the main-thread
677
- // event loop free during heavy SQLite writes. Provided alongside
678
- // the direct TelemetryStore so query handlers can still resolve
679
- // their dependency directly.
554
+ Layer.provide(HttpMiddleware.layerTracerDisabledForUrls(["/api/health", "/v1/traces", "/v1/logs"])),
555
+ // The telemetry worker owns ingest, migrations, and bounded maintenance.
556
+ // The HTTP thread only opens an existing database read-only (or bootstraps
557
+ // a brand-new empty one), keeping health independent of writer work.
680
558
  Layer.provideMerge(AsyncIngestLive),
681
- Layer.provideMerge(TelemetryStoreLive),
559
+ Layer.provideMerge(TelemetryQueryLive),
682
560
  Layer.provideMerge(BunHttpServer.layer({
683
561
  port: config.otel.port,
684
562
  hostname: config.otel.host,
685
563
  reusePort: true,
564
+ routes: {
565
+ "/api/health": () => Response.json(healthPayload()),
566
+ },
686
567
  })),
687
568
  )