@kitlangton/motel 0.2.5 → 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 (60) hide show
  1. package/AGENTS.md +11 -8
  2. package/README.md +13 -2
  3. package/package.json +31 -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 +3 -5
  7. package/src/StartupGate.tsx +8 -10
  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 +103 -152
  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/localServer.ts +194 -323
  16. package/src/mcp.ts +2 -1
  17. package/src/opentui-jsx.d.ts +11 -0
  18. package/src/otlp.test.ts +65 -0
  19. package/src/otlp.ts +20 -0
  20. package/src/otlpProtobuf.ts +35 -0
  21. package/src/registry.ts +37 -11
  22. package/src/runtime.ts +2 -6
  23. package/src/services/AsyncIngest.ts +20 -8
  24. package/src/services/LogQueryService.ts +11 -25
  25. package/src/services/TelemetryQuery.ts +62 -0
  26. package/src/services/TelemetryStore.ts +433 -249
  27. package/src/services/TraceQueryService.ts +18 -52
  28. package/src/services/ingestRpc.ts +2 -4
  29. package/src/services/queryRpc.ts +15 -0
  30. package/src/services/telemetryQueryWorker.ts +32 -0
  31. package/src/services/telemetryWorker.ts +5 -8
  32. package/src/storybook/aiChatStory.tsx +1 -1
  33. package/src/telemetry.test.ts +307 -41
  34. package/src/ui/AiChatView.tsx +1 -1
  35. package/src/ui/AttrFilterModal.tsx +1 -1
  36. package/src/ui/ServiceLogs.tsx +10 -7
  37. package/src/ui/SpanContentView.tsx +24 -21
  38. package/src/ui/TraceDetailsPane.tsx +1 -1
  39. package/src/ui/TraceList.tsx +1 -1
  40. package/src/ui/aiState.ts +10 -22
  41. package/src/ui/app/TraceWorkspace.tsx +2 -1
  42. package/src/ui/app/useAppLayout.ts +1 -1
  43. package/src/ui/app/useTraceScreenData.ts +22 -18
  44. package/src/ui/cachedLoader.test.ts +23 -0
  45. package/src/ui/cachedLoader.ts +60 -0
  46. package/src/ui/loaders.ts +34 -53
  47. package/src/ui/primitives.tsx +1 -1
  48. package/src/ui/state.ts +2 -0
  49. package/src/ui/traceDetailsWidth.repro.test.ts +12 -1
  50. package/src/ui/traceSortNav.repro.seed.ts +1 -1
  51. package/src/ui/traceSortNav.repro.test.ts +12 -2
  52. package/src/ui/useAttrFilterPicker.ts +10 -8
  53. package/src/ui/useKeyboardNav.ts +3 -6
  54. package/src/ui/waterfallNav.repro.seed.ts +1 -1
  55. package/src/ui/waterfallNav.repro.test.ts +16 -8
  56. package/web/dist/assets/index-B01z9BaO.css +2 -0
  57. package/web/dist/assets/index-M86tcih5.js +22 -0
  58. package/web/dist/index.html +2 -2
  59. package/web/dist/assets/index-DnyVo03x.js +0 -27
  60. 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,43 +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 { LogQueryService, LogQueryServiceLive } from "./services/LogQueryService.js"
16
- import { TelemetryStore, TelemetryStoreLive, TelemetryStoreReadonlyLive } from "./services/TelemetryStore.js"
17
- import { TraceQueryService, TraceQueryServiceLive } from "./services/TraceQueryService.js"
18
- 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"
19
17
  import { lifecycleLabel } from "./ui/format.js"
18
+ import { decodeProtobufLogs, decodeProtobufTraces } from "./otlpProtobuf.js"
19
+ import type { OtlpLogExportRequest, OtlpTraceExportRequest } from "./otlp.js"
20
20
 
21
21
  // Set by the RegistryLayer acquisition once the Bun socket has bound.
22
22
  // Both /api/health and the registry entry read from here so they agree
23
23
  // on a single server-start timestamp, and the value reflects actual
24
24
  // listen time rather than module-evaluation time.
25
- let serverStartedAt: string = new Date(0).toISOString()
25
+ let serverStartedAt: string = new Date().toISOString()
26
26
 
27
- const TRACE_DEFAULT_LIMIT = 20
28
- const TRACE_MAX_LIMIT = 100
29
- const TRACE_DEFAULT_LOOKBACK = 60
30
- const TRACE_MAX_LOOKBACK = 24 * 60
31
- const SPAN_DEFAULT_LIMIT = 100
32
- const SPAN_MAX_LIMIT = 500
33
- const LOG_DEFAULT_LIMIT = 100
34
- const LOG_MAX_LIMIT = 500
35
- const LOG_DEFAULT_LOOKBACK = 60
36
- 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)
37
29
 
38
30
  const jsonResponse = (value: unknown, status = 200) => HttpServerResponse.jsonUnsafe(value, { status })
39
31
  const textResponse = (value: string) => HttpServerResponse.text(value)
40
32
  const htmlResponse = (value: string) => HttpServerResponse.html(value)
41
33
  const notFoundResponse = (message = "Not found") => jsonResponse({ error: message }, 404)
42
- const requestUrl = (request: { readonly url: string }) => new URL(request.url, config.otel.baseUrl)
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)
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)
46
48
  // Response-building helpers are generic in R so a handler can depend
47
- // on TelemetryStore (query path) or AsyncIngest (worker-RPC path)
48
- // 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.
49
51
  const respondJson = <A, R>(effect: Effect.Effect<A, unknown, R>) =>
50
52
  Effect.match(effect, {
51
53
  onFailure: (error) => jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 500),
@@ -57,144 +59,50 @@ const respondRaw = <R>(effect: Effect.Effect<ReturnType<typeof jsonResponse>, un
57
59
  onSuccess: (value) => value,
58
60
  })
59
61
 
60
- const parseLimit = (value: string | null, fallback: number) => parsePositiveInt(value ?? undefined, fallback)
61
- const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(value, max))
62
- const parseBoundedLimit = (value: string | null, fallback: number, max: number) => clamp(parseLimit(value, fallback), 1, max)
63
-
64
- const parseLookbackMinutes = (value: string | null, fallback: number) => {
65
- if (!value) return fallback
66
- const match = value.trim().match(/^(\d+)([mhd])$/i)
67
- if (!match) return fallback
68
- const amount = Number.parseInt(match[1] ?? "", 10)
69
- if (!Number.isFinite(amount) || amount <= 0) return fallback
70
- const unit = (match[2] ?? "m").toLowerCase()
71
- if (unit === "d") return amount * 1440
72
- if (unit === "h") return amount * 60
73
- return amount
74
- }
75
-
76
- const parseBoundedLookbackMinutes = (value: string | null, fallback: number, max: number) => clamp(parseLookbackMinutes(value, fallback), 1, max)
77
-
78
- const attributeFiltersFromQuery = (url: URL) =>
79
- attributeFiltersFromEntries(url.searchParams.entries())
80
-
81
- const attributeContainsFiltersFromQuery = (url: URL) =>
82
- attributeContainsFiltersFromEntries(url.searchParams.entries())
83
-
84
- type CursorShape =
85
- | { readonly kind: "trace"; readonly startedAt: number; readonly id: string }
86
- | { readonly kind: "log"; readonly timestamp: number; readonly id: string }
87
-
88
- const encodeCursor = (cursor: CursorShape) => Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url")
89
-
90
- const decodeCursor = (value: string | null): CursorShape | null => {
91
- if (!value) return null
92
- try {
93
- return JSON.parse(Buffer.from(value, "base64url").toString("utf8")) as CursorShape
94
- } catch {
95
- return null
96
- }
97
- }
98
-
99
- const formatLookback = (minutes: number) => {
100
- if (minutes % 1440 === 0) return `${minutes / 1440}d`
101
- if (minutes % 60 === 0) return `${minutes / 60}h`
102
- return `${minutes}m`
103
- }
104
-
105
- const listMeta = (input: { readonly limit: number; readonly lookbackMinutes: number; readonly returned: number; readonly truncated: boolean; readonly nextCursor: string | null }) => ({
106
- limit: input.limit,
107
- lookback: formatLookback(input.lookbackMinutes),
108
- returned: input.returned,
109
- truncated: input.truncated,
110
- nextCursor: input.nextCursor,
111
- })
112
-
113
- const paginateSummaries = (summaries: readonly TraceSummaryItem[], options: { readonly limit: number; readonly lookbackMinutes: number; readonly cursor: CursorShape | null }) => {
114
- const page = summaries.slice(0, options.limit)
115
- const last = page.at(-1)
116
- return {
117
- data: page,
118
- meta: listMeta({
119
- limit: options.limit,
120
- lookbackMinutes: options.lookbackMinutes,
121
- returned: page.length,
122
- truncated: summaries.length > page.length,
123
- nextCursor: last ? encodeCursor({ kind: "trace", startedAt: last.startedAt.getTime(), id: last.traceId }) : null,
124
- }),
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)))
125
73
  }
74
+ return Effect.map(request.json, (payload) => payload as T)
126
75
  }
127
76
 
128
- const paginateLogs = (logs: readonly LogItem[], options: { readonly limit: number; readonly lookbackMinutes: number; readonly cursor: CursorShape | null }) => {
129
- const page = logs.slice(0, options.limit)
130
- const last = page.at(-1)
131
-
132
- return {
133
- data: page,
134
- meta: listMeta({
135
- limit: options.limit,
136
- lookbackMinutes: options.lookbackMinutes,
137
- returned: page.length,
138
- truncated: logs.length > page.length,
139
- nextCursor: last ? encodeCursor({ kind: "log", timestamp: last.timestamp.getTime(), id: last.id }) : null,
140
- }),
141
- }
142
- }
143
-
144
- const loadLogsPage = (input: {
145
- readonly serviceName?: string | null
146
- readonly severity?: string | null
147
- readonly traceId?: string | null
148
- readonly spanId?: string | null
149
- readonly body?: string | null
150
- readonly attributeFilters?: Readonly<Record<string, string>>
151
- readonly attributeContainsFilters?: Readonly<Record<string, string>>
152
- readonly limit: number
153
- readonly lookbackMinutes: number
154
- readonly cursor: CursorShape | null
155
- }) =>
156
- Effect.flatMap(LogQueryService.asEffect(), (store) =>
157
- Effect.map(
158
- store.searchLogs({
159
- serviceName: input.serviceName,
160
- severity: input.severity,
161
- traceId: input.traceId,
162
- spanId: input.spanId,
163
- body: input.body,
164
- lookbackMinutes: input.lookbackMinutes,
165
- limit: input.limit + 1,
166
- cursorTimestampMs: input.cursor?.kind === "log" ? input.cursor.timestamp : undefined,
167
- cursorId: input.cursor?.kind === "log" ? input.cursor.id : undefined,
168
- attributeFilters: input.attributeFilters,
169
- attributeContainsFilters: input.attributeContainsFilters,
170
- }),
171
- (logs) => paginateLogs(logs, {
172
- limit: input.limit,
173
- lookbackMinutes: input.lookbackMinutes,
174
- cursor: input.cursor,
175
- }),
176
- ),
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),
177
95
  )
178
96
 
179
97
  const handleLogSearch = (request: { readonly url: string }) =>
180
98
  respondRaw(Effect.gen(function*() {
181
- const url = requestUrl(request)
182
- const attributeFilters = attributeFiltersFromQuery(url)
183
- const attributeContainsFilters = attributeContainsFiltersFromQuery(url)
184
- const limit = parseBoundedLimit(url.searchParams.get("limit"), LOG_DEFAULT_LIMIT, LOG_MAX_LIMIT)
185
- const lookbackMinutes = parseBoundedLookbackMinutes(url.searchParams.get("lookback"), LOG_DEFAULT_LOOKBACK, LOG_MAX_LOOKBACK)
186
- const cursor = decodeCursor(url.searchParams.get("cursor"))
187
- return jsonResponse(yield* loadLogsPage({
188
- serviceName: url.searchParams.get("service"),
189
- severity: url.searchParams.get("severity"),
190
- traceId: url.searchParams.get("traceId"),
191
- spanId: url.searchParams.get("spanId"),
192
- body: url.searchParams.get("body"),
193
- attributeFilters,
194
- attributeContainsFilters,
195
- limit,
196
- lookbackMinutes,
197
- 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"),
198
106
  }))
199
107
  }))
200
108
 
@@ -281,103 +189,84 @@ const TelemetryGroupLive = HttpApiBuilder.group(
281
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")),
282
190
  )
283
191
  .handle("health", () =>
284
- Effect.succeed({
285
- ok: true,
286
- service: MOTEL_SERVICE_ID,
287
- databasePath: config.otel.databasePath,
288
- pid: process.pid,
289
- url: config.otel.baseUrl,
290
- workdir: process.cwd(),
291
- startedAt: serverStartedAt,
292
- version: MOTEL_VERSION,
293
- }),
192
+ HttpMiddleware.withLoggerDisabled(Effect.succeed(healthPayload())),
294
193
  )
295
194
  // OTLP ingest is routed to the worker thread via AsyncIngest
296
195
  // so the main event loop stays free during heavy SQLite writes.
297
- // Everything else still uses the direct TelemetryStore reads
298
- // 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.
299
198
  .handleRaw("ingestTraces", ({ request }) =>
300
- respondRaw(
301
- Effect.flatMap(request.json, (payload) =>
302
- Effect.map(
303
- 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 })),
304
204
  (result) => jsonResponse(result),
305
205
  ),
306
206
  ),
307
- ),
207
+ )),
308
208
  )
309
209
  .handleRaw("ingestLogs", ({ request }) =>
310
- respondRaw(
311
- Effect.flatMap(request.json, (payload) =>
312
- Effect.map(
313
- 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 })),
314
215
  (result) => jsonResponse(result),
315
216
  ),
316
217
  ),
317
- ),
218
+ )),
318
219
  )
319
- .handleRaw("services", () => respondJson(Effect.map(withTraceQuery((store) => store.listServices), (data) => ({ data }))))
220
+ .handleRaw("services", () => respondJson(Effect.map(withRead((store) => store.listServices), (data) => ({ data }))))
320
221
  .handleRaw("traces", ({ request }) =>
321
222
  respondRaw(Effect.gen(function*() {
322
- const url = requestUrl(request)
323
- const service = url.searchParams.get("service")
324
- const limit = parseBoundedLimit(url.searchParams.get("limit"), TRACE_DEFAULT_LIMIT, TRACE_MAX_LIMIT)
325
- const lookbackMinutes = parseBoundedLookbackMinutes(url.searchParams.get("lookback"), TRACE_DEFAULT_LOOKBACK, TRACE_MAX_LOOKBACK)
326
- const cursor = decodeCursor(url.searchParams.get("cursor"))
327
- const data = yield* withTraceQuery((store) => store.listTraceSummaries(service, {
328
- limit: limit + 1,
329
- lookbackMinutes,
330
- cursorStartedAtMs: cursor?.kind === "trace" ? cursor.startedAt : undefined,
331
- 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),
332
228
  }))
333
- return jsonResponse(paginateSummaries(data, { limit, lookbackMinutes, cursor }))
229
+ return jsonResponse(paginateSummaries(data, params))
334
230
  })),
335
231
  )
336
232
  .handleRaw("searchTraces", ({ request }) =>
337
233
  respondRaw(Effect.gen(function*() {
338
- const url = requestUrl(request)
339
- const attributeFilters = attributeFiltersFromQuery(url)
340
- const limit = parseBoundedLimit(url.searchParams.get("limit"), TRACE_DEFAULT_LIMIT, TRACE_MAX_LIMIT)
341
- const lookbackMinutes = parseBoundedLookbackMinutes(url.searchParams.get("lookback"), TRACE_DEFAULT_LOOKBACK, TRACE_MAX_LOOKBACK)
342
- const cursor = decodeCursor(url.searchParams.get("cursor"))
343
- const data = yield* withTraceQuery((store) =>
234
+ const params = parseListParams(request, TRACE_LIST)
235
+ const data = yield* withRead((store) =>
344
236
  store.searchTraceSummaries({
345
- serviceName: url.searchParams.get("service"),
346
- operation: url.searchParams.get("operation"),
347
- status: (url.searchParams.get("status") as "ok" | "error" | null) ?? null,
348
- minDurationMs: url.searchParams.get("minDurationMs") ? Number.parseFloat(url.searchParams.get("minDurationMs") ?? "") : null,
349
- attributeFilters,
350
- aiText: url.searchParams.get("aiText"),
351
- limit: limit + 1,
352
- lookbackMinutes,
353
- cursorStartedAtMs: cursor?.kind === "trace" ? cursor.startedAt : undefined,
354
- 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),
355
246
  }),
356
247
  )
357
- return jsonResponse(paginateSummaries(data, { limit, lookbackMinutes, cursor }))
248
+ return jsonResponse(paginateSummaries(data, params))
358
249
  })),
359
250
  )
360
251
  .handleRaw("traceStats", ({ request }) =>
361
252
  respondRaw(Effect.gen(function*() {
362
- const url = requestUrl(request)
363
- const attributeFilters = attributeFiltersFromQuery(url)
364
- const groupBy = url.searchParams.get("groupBy")
365
- const agg = url.searchParams.get("agg")
366
- 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")
367
256
  if (!groupBy || (agg !== "count" && agg !== "avg_duration" && agg !== "p95_duration" && agg !== "error_rate")) {
368
257
  return jsonResponse({ error: "Expected groupBy and agg=count|avg_duration|p95_duration|error_rate" }, 400)
369
258
  }
370
- const data = yield* withTraceQuery((store) =>
259
+ const data = yield* withRead((store) =>
371
260
  store.traceStats({
372
261
  groupBy,
373
262
  agg,
374
- serviceName: url.searchParams.get("service"),
375
- operation: url.searchParams.get("operation"),
376
- status: (url.searchParams.get("status") as "ok" | "error" | null) ?? null,
377
- minDurationMs: url.searchParams.get("minDurationMs") ? Number.parseFloat(url.searchParams.get("minDurationMs") ?? "") : null,
378
- attributeFilters,
379
- limit: parseBoundedLimit(url.searchParams.get("limit"), 20, TRACE_MAX_LIMIT),
380
- 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,
381
270
  }),
382
271
  )
383
272
  return jsonResponse({ data })
@@ -385,31 +274,27 @@ const TelemetryGroupLive = HttpApiBuilder.group(
385
274
  )
386
275
  .handleRaw("searchSpans", ({ request }) =>
387
276
  respondRaw(Effect.gen(function*() {
388
- const url = requestUrl(request)
389
- const attributeFilters = attributeFiltersFromQuery(url)
390
- const attributeContainsFilters = attributeContainsFiltersFromQuery(url)
391
- const limit = parseBoundedLimit(url.searchParams.get("limit"), SPAN_DEFAULT_LIMIT, SPAN_MAX_LIMIT)
392
- const lookbackMinutes = parseBoundedLookbackMinutes(url.searchParams.get("lookback"), TRACE_DEFAULT_LOOKBACK, TRACE_MAX_LOOKBACK)
393
- const data = yield* withTraceQuery((store) =>
277
+ const params = parseListParams(request, SPAN_LIST)
278
+ const data = yield* withRead((store) =>
394
279
  store.searchSpans({
395
- serviceName: url.searchParams.get("service"),
396
- traceId: url.searchParams.get("traceId"),
397
- operation: url.searchParams.get("operation"),
398
- parentOperation: url.searchParams.get("parentOperation"),
399
- status: (url.searchParams.get("status") as "ok" | "error" | null) ?? null,
400
- attributeFilters,
401
- attributeContainsFilters,
402
- limit: limit + 1,
403
- 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,
404
289
  }),
405
290
  )
406
- const truncated = data.length > limit
407
- 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
408
293
  return jsonResponse({
409
294
  data: page,
410
295
  meta: listMeta({
411
- limit,
412
- lookbackMinutes,
296
+ limit: params.limit,
297
+ lookbackMinutes: params.lookbackMinutes,
413
298
  returned: page.length,
414
299
  truncated,
415
300
  nextCursor: null,
@@ -417,37 +302,31 @@ const TelemetryGroupLive = HttpApiBuilder.group(
417
302
  })
418
303
  })),
419
304
  )
420
- .handleRaw("traceLogs", ({ params, request }) =>
305
+ .handleRaw("traceLogs", ({ params: route, request }) =>
421
306
  respondRaw(Effect.gen(function*() {
422
- const url = requestUrl(request)
423
- const lookbackMinutes = parseBoundedLookbackMinutes(url.searchParams.get("lookback"), LOG_DEFAULT_LOOKBACK, LOG_MAX_LOOKBACK)
424
- const limit = parseBoundedLimit(url.searchParams.get("limit"), LOG_DEFAULT_LIMIT, LOG_MAX_LIMIT)
425
- const cursor = decodeCursor(url.searchParams.get("cursor"))
426
- 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 }))
427
309
  })),
428
310
  )
429
311
  .handleRaw("traceSpans", ({ params }) =>
430
- respondJson(Effect.map(withTraceQuery((store) => store.listTraceSpans(params.traceId)), (data) => ({ data }))),
312
+ respondJson(Effect.map(withRead((store) => store.listTraceSpans(params.traceId)), (data) => ({ data }))),
431
313
  )
432
- .handleRaw("spanLogs", ({ params, request }) =>
314
+ .handleRaw("spanLogs", ({ params: route, request }) =>
433
315
  respondRaw(Effect.gen(function*() {
434
- const url = requestUrl(request)
435
- const lookbackMinutes = parseBoundedLookbackMinutes(url.searchParams.get("lookback"), LOG_DEFAULT_LOOKBACK, LOG_MAX_LOOKBACK)
436
- const limit = parseBoundedLimit(url.searchParams.get("limit"), LOG_DEFAULT_LIMIT, LOG_MAX_LIMIT)
437
- const cursor = decodeCursor(url.searchParams.get("cursor"))
438
- 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 }))
439
318
  })),
440
319
  )
441
320
  .handleRaw("span", ({ params }) =>
442
321
  respondRaw(
443
- Effect.flatMap(withTraceQuery((store) => store.getSpan(params.spanId)), (data) =>
322
+ Effect.flatMap(withRead((store) => store.getSpan(params.spanId)), (data) =>
444
323
  Effect.succeed(data ? jsonResponse({ data }) : notFoundResponse("Span not found")),
445
324
  ),
446
325
  ),
447
326
  )
448
327
  .handleRaw("trace", ({ params }) =>
449
328
  respondRaw(
450
- Effect.flatMap(withTraceQuery((store) => store.getTrace(params.traceId)), (data) =>
329
+ Effect.flatMap(withRead((store) => store.getTrace(params.traceId)), (data) =>
451
330
  Effect.succeed(data ? jsonResponse({ data }) : notFoundResponse("Trace not found")),
452
331
  ),
453
332
  ),
@@ -456,25 +335,23 @@ const TelemetryGroupLive = HttpApiBuilder.group(
456
335
  .handleRaw("searchLogs", ({ request }) => handleLogSearch(request))
457
336
  .handleRaw("logStats", ({ request }) =>
458
337
  respondRaw(Effect.gen(function*() {
459
- const url = requestUrl(request)
460
- const attributeFilters = attributeFiltersFromQuery(url)
461
- const groupBy = url.searchParams.get("groupBy")
462
- const agg = url.searchParams.get("agg")
463
- 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")
464
341
  if (!groupBy || agg !== "count") {
465
342
  return jsonResponse({ error: "Expected groupBy and agg=count" }, 400)
466
343
  }
467
- const data = yield* withLogQuery((store) =>
344
+ const data = yield* withRead((store) =>
468
345
  store.logStats({
469
346
  groupBy,
470
347
  agg: "count",
471
- serviceName: url.searchParams.get("service"),
472
- traceId: url.searchParams.get("traceId"),
473
- spanId: url.searchParams.get("spanId"),
474
- body: url.searchParams.get("body"),
475
- attributeFilters,
476
- limit: parseBoundedLimit(url.searchParams.get("limit"), 20, LOG_MAX_LIMIT),
477
- 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,
478
355
  }),
479
356
  )
480
357
  return jsonResponse({ data })
@@ -490,18 +367,17 @@ const TelemetryGroupLive = HttpApiBuilder.group(
490
367
  )
491
368
  .handleRaw("doc", ({ params }) =>
492
369
  respondRaw(Effect.gen(function*() {
370
+ const fileSystem = yield* FileSystem.FileSystem
493
371
  const docFiles: Record<string, string> = {
494
372
  debug: path.resolve(import.meta.dir, "../skills/motel-debug/SKILL.md"),
495
373
  effect: path.resolve(import.meta.dir, "../skills/motel-debug/references/effect.md"),
496
374
  }
497
375
  const filePath = docFiles[params.name]
498
376
  if (!filePath) return notFoundResponse(`Unknown doc: ${params.name}. Available: ${Object.keys(docFiles).join(", ")}`)
499
- try {
500
- const content = yield* Effect.promise(() => fs.readFile(filePath, "utf8"))
501
- return textResponse(content)
502
- } catch {
503
- return notFoundResponse(`Doc file not found: ${params.name}`)
504
- }
377
+ return yield* fileSystem.readFileString(filePath).pipe(
378
+ Effect.map(textResponse),
379
+ Effect.catch(() => Effect.succeed(notFoundResponse(`Doc file not found: ${params.name}`))),
380
+ )
505
381
  })),
506
382
  )
507
383
  .handleRaw("facets", ({ request }) =>
@@ -512,7 +388,7 @@ const TelemetryGroupLive = HttpApiBuilder.group(
512
388
  if ((type !== "traces" && type !== "logs") || !field) {
513
389
  return jsonResponse({ error: "Expected type=traces|logs and field=<name>" }, 400)
514
390
  }
515
- const data = yield* withTraceQuery((store) =>
391
+ const data = yield* withRead((store) =>
516
392
  store.listFacets({
517
393
  type,
518
394
  field,
@@ -527,62 +403,59 @@ const TelemetryGroupLive = HttpApiBuilder.group(
527
403
  )
528
404
  .handleRaw("aiCalls", ({ request }) =>
529
405
  respondRaw(Effect.gen(function*() {
530
- const url = requestUrl(request)
531
- const limit = parseBoundedLimit(url.searchParams.get("limit"), 20, SPAN_MAX_LIMIT)
532
- const lookbackMinutes = parseBoundedLookbackMinutes(url.searchParams.get("lookback"), TRACE_DEFAULT_LOOKBACK, TRACE_MAX_LOOKBACK)
533
- const data = yield* withStore((store) =>
406
+ const params = parseListParams(request, AI_LIST)
407
+ const data = yield* withRead((store) =>
534
408
  store.searchAiCalls({
535
- service: url.searchParams.get("service"),
536
- traceId: url.searchParams.get("traceId"),
537
- sessionId: url.searchParams.get("sessionId"),
538
- functionId: url.searchParams.get("functionId"),
539
- provider: url.searchParams.get("provider"),
540
- model: url.searchParams.get("model"),
541
- operation: url.searchParams.get("operation"),
542
- status: (url.searchParams.get("status") as "ok" | "error" | null) ?? null,
543
- minDurationMs: url.searchParams.get("minDurationMs") ? Number(url.searchParams.get("minDurationMs")) : null,
544
- text: url.searchParams.get("text"),
545
- lookbackMinutes,
546
- 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,
547
421
  }),
548
422
  )
549
423
  return jsonResponse({
550
424
  data,
551
- 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 }),
552
426
  })
553
427
  })),
554
428
  )
555
429
  .handleRaw("aiCall", ({ params }) =>
556
430
  respondRaw(Effect.gen(function*() {
557
- const data = yield* withStore((store) => store.getAiCall(params.spanId))
431
+ const data = yield* withRead((store) => store.getAiCall(params.spanId))
558
432
  if (!data) return notFoundResponse("AI call not found")
559
433
  return jsonResponse({ data })
560
434
  })),
561
435
  )
562
436
  .handleRaw("aiStats", ({ request }) =>
563
437
  respondRaw(Effect.gen(function*() {
564
- const url = requestUrl(request)
565
- const groupBy = url.searchParams.get("groupBy") as "provider" | "model" | "functionId" | "sessionId" | "status" | null
566
- 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
567
441
  if (!groupBy || !agg) {
568
442
  return jsonResponse({ error: "Expected groupBy and agg parameters" }, 400)
569
443
  }
570
- const lookbackMinutes = parseBoundedLookbackMinutes(url.searchParams.get("lookback"), TRACE_DEFAULT_LOOKBACK, TRACE_MAX_LOOKBACK)
571
- const data = yield* withStore((store) =>
444
+ const data = yield* withRead((store) =>
572
445
  store.aiCallStats({
573
446
  groupBy,
574
447
  agg,
575
- service: url.searchParams.get("service"),
576
- traceId: url.searchParams.get("traceId"),
577
- sessionId: url.searchParams.get("sessionId"),
578
- functionId: url.searchParams.get("functionId"),
579
- provider: url.searchParams.get("provider"),
580
- model: url.searchParams.get("model"),
581
- operation: url.searchParams.get("operation"),
582
- status: (url.searchParams.get("status") as "ok" | "error" | null) ?? null,
583
- minDurationMs: url.searchParams.get("minDurationMs") ? Number(url.searchParams.get("minDurationMs")) : null,
584
- lookbackMinutes,
585
- 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,
586
459
  }),
587
460
  )
588
461
  return jsonResponse({ data })
@@ -590,9 +463,9 @@ const TelemetryGroupLive = HttpApiBuilder.group(
590
463
  )
591
464
  .handleRaw("tracePage", ({ params }) =>
592
465
  respondRaw(
593
- Effect.flatMap(withTraceQuery((store) => store.getTrace(params.traceId)), (trace) =>
466
+ Effect.flatMap(withRead((store) => store.getTrace(params.traceId)), (trace) =>
594
467
  trace
595
- ? Effect.map(withLogQuery((store) => store.listTraceLogs(params.traceId)), (logs) => htmlResponse(renderTracePage(trace, logs)))
468
+ ? Effect.map(withRead((store) => store.listTraceLogs(params.traceId)), (logs) => htmlResponse(renderTracePage(trace, logs)))
596
469
  : Effect.succeed(notFoundResponse("Trace not found")),
597
470
  ),
598
471
  ),
@@ -610,10 +483,6 @@ const ApiLayer = HttpApiBuilder.layer(MotelHttpApi, { openapiPath: "/openapi.jso
610
483
  Layer.provide(HttpApiScalar.layer(MotelHttpApi, { scalar: { forceDarkModeState: "dark", showOperationId: true } })),
611
484
  )
612
485
 
613
- const QueryServicesLive = Layer.mergeAll(TraceQueryServiceLive, LogQueryServiceLive).pipe(
614
- Layer.provideMerge(TelemetryStoreReadonlyLive),
615
- )
616
-
617
486
  // Web UI: Vite-built SPA served from web/dist. HttpStaticServer.layer
618
487
  // handles GET /*, filesystem lookup under `root`, and SPA fallback to
619
488
  // index.html for unknown paths — replacing the hand-rolled serveWebUi
@@ -643,6 +512,8 @@ const RegistryLayer = Layer.effectDiscard(
643
512
  startedAt: serverStartedAt,
644
513
  version: MOTEL_VERSION,
645
514
  databasePath: config.otel.databasePath,
515
+ instanceId: process.env.MOTEL_DAEMON_INSTANCE_ID?.trim(),
516
+ processIdentity: processIdentity(process.pid) ?? undefined,
646
517
  })
647
518
  } catch (err) {
648
519
  console.warn(`motel: failed to write registry entry: ${(err as Error).message}`)
@@ -680,18 +551,18 @@ export const ServerLive = HttpRouter.serve(
680
551
  // POSTs again on the next flush. This also shaves ~1 KB of header
681
552
  // attributes off every ingest request that would have been written
682
553
  // to the spans table as noise.
683
- Layer.provide(HttpMiddleware.layerTracerDisabledForUrls(["/v1/traces", "/v1/logs"])),
684
- // AsyncIngest spawns the telemetry worker keeps the main-thread
685
- // event loop free during heavy SQLite writes. Provided alongside
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.
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.
689
558
  Layer.provideMerge(AsyncIngestLive),
690
- Layer.provideMerge(QueryServicesLive),
691
- Layer.provideMerge(TelemetryStoreLive),
559
+ Layer.provideMerge(TelemetryQueryLive),
692
560
  Layer.provideMerge(BunHttpServer.layer({
693
561
  port: config.otel.port,
694
562
  hostname: config.otel.host,
695
563
  reusePort: true,
564
+ routes: {
565
+ "/api/health": () => Response.json(healthPayload()),
566
+ },
696
567
  })),
697
568
  )