@aryasaatvik/motel 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/AGENTS.md +180 -0
  2. package/LICENSE +21 -0
  3. package/README.md +137 -0
  4. package/package.json +119 -0
  5. package/skills/motel-debug/SKILL.md +203 -0
  6. package/skills/motel-debug/references/effect.md +38 -0
  7. package/src/App.tsx +414 -0
  8. package/src/StartupGate.test.ts +80 -0
  9. package/src/StartupGate.tsx +263 -0
  10. package/src/cli.ts +205 -0
  11. package/src/config.ts +45 -0
  12. package/src/daemon.test.ts +547 -0
  13. package/src/daemon.ts +486 -0
  14. package/src/domain.test.ts +62 -0
  15. package/src/domain.ts +291 -0
  16. package/src/httpApi.ts +391 -0
  17. package/src/httpListPolicy.test.ts +76 -0
  18. package/src/httpListPolicy.ts +129 -0
  19. package/src/index.tsx +25 -0
  20. package/src/instructions.ts +72 -0
  21. package/src/launchAgent.test.ts +430 -0
  22. package/src/launchAgent.ts +462 -0
  23. package/src/localServer.ts +568 -0
  24. package/src/locator.ts +138 -0
  25. package/src/mcp.ts +433 -0
  26. package/src/motel.test.ts +176 -0
  27. package/src/motel.ts +88 -0
  28. package/src/motelClient.ts +353 -0
  29. package/src/opentui-jsx.d.ts +11 -0
  30. package/src/otlp.test.ts +65 -0
  31. package/src/otlp.ts +162 -0
  32. package/src/otlpProtobuf.ts +35 -0
  33. package/src/queryFilters.ts +39 -0
  34. package/src/registry.ts +115 -0
  35. package/src/runtime.ts +40 -0
  36. package/src/server.ts +11 -0
  37. package/src/services/AsyncIngest.ts +82 -0
  38. package/src/services/LogQueryService.ts +29 -0
  39. package/src/services/TelemetryQuery.ts +62 -0
  40. package/src/services/TelemetryStore.ts +2554 -0
  41. package/src/services/TraceQueryService.ts +43 -0
  42. package/src/services/ingestRpc.ts +39 -0
  43. package/src/services/queryRpc.ts +15 -0
  44. package/src/services/telemetryQueryWorker.ts +32 -0
  45. package/src/services/telemetryWorker.ts +59 -0
  46. package/src/startupBench.ts +19 -0
  47. package/src/startupLifecycle.ts +57 -0
  48. package/src/storybook/aiChatStory.tsx +244 -0
  49. package/src/storybook/fixtures/errorState.ts +44 -0
  50. package/src/storybook/fixtures/imagePaste.ts +34 -0
  51. package/src/storybook/fixtures/index.ts +62 -0
  52. package/src/storybook/fixtures/kitchenSink.ts +148 -0
  53. package/src/storybook/fixtures/rawPrompt.ts +15 -0
  54. package/src/storybook/fixtures/short.ts +27 -0
  55. package/src/storybook/fixtures/toolHeavy.ts +65 -0
  56. package/src/telemetry.test.ts +1053 -0
  57. package/src/ui/AiChatView.tsx +308 -0
  58. package/src/ui/AttrFilterModal.tsx +120 -0
  59. package/src/ui/ServiceLogs.tsx +115 -0
  60. package/src/ui/SpanContentView.tsx +184 -0
  61. package/src/ui/SpanDetail.tsx +215 -0
  62. package/src/ui/SpanDetailFull.tsx +224 -0
  63. package/src/ui/SpanDetailPane.tsx +90 -0
  64. package/src/ui/TraceDetailsPane.tsx +175 -0
  65. package/src/ui/TraceList.tsx +254 -0
  66. package/src/ui/Waterfall.tsx +389 -0
  67. package/src/ui/aiChatModel.test.ts +391 -0
  68. package/src/ui/aiChatModel.ts +773 -0
  69. package/src/ui/aiState.ts +59 -0
  70. package/src/ui/app/TraceListPane.tsx +39 -0
  71. package/src/ui/app/TraceWorkspace.tsx +452 -0
  72. package/src/ui/app/useAppLayout.ts +101 -0
  73. package/src/ui/app/useTraceScreenData.ts +590 -0
  74. package/src/ui/atoms.ts +131 -0
  75. package/src/ui/cachedLoader.test.ts +23 -0
  76. package/src/ui/cachedLoader.ts +60 -0
  77. package/src/ui/filterParser.test.ts +56 -0
  78. package/src/ui/filterParser.ts +45 -0
  79. package/src/ui/format.ts +128 -0
  80. package/src/ui/loaders.ts +101 -0
  81. package/src/ui/persistence.ts +41 -0
  82. package/src/ui/primitives.tsx +198 -0
  83. package/src/ui/state.ts +6 -0
  84. package/src/ui/theme.ts +160 -0
  85. package/src/ui/traceDetailsWidth.repro.test.ts +126 -0
  86. package/src/ui/traceSortNav.repro.seed.ts +62 -0
  87. package/src/ui/traceSortNav.repro.test.ts +231 -0
  88. package/src/ui/useAttrFilterPicker.ts +89 -0
  89. package/src/ui/useKeyboardNav.ts +1023 -0
  90. package/src/ui/waterfallFilter.test.ts +84 -0
  91. package/src/ui/waterfallFilter.ts +59 -0
  92. package/src/ui/waterfallModel.ts +130 -0
  93. package/src/ui/waterfallNav.repro.seed.ts +86 -0
  94. package/src/ui/waterfallNav.repro.test.ts +271 -0
  95. package/src/ui/waterfallNav.test.ts +453 -0
  96. package/src/ui/waterfallNav.ts +75 -0
  97. package/web/dist/assets/index-B01z9BaO.css +2 -0
  98. package/web/dist/assets/index-CYlBaY9O.js +22 -0
  99. package/web/dist/index.html +13 -0
@@ -0,0 +1,568 @@
1
+ import path from "node:path"
2
+ import { Effect, FileSystem, Layer } from "effect"
3
+ import { config } from "./config.js"
4
+ import { HttpApiBuilder, HttpApiScalar } from "effect/unstable/httpapi"
5
+ import * as HttpMiddleware from "effect/unstable/http/HttpMiddleware"
6
+ import * as HttpRouter from "effect/unstable/http/HttpRouter"
7
+ import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"
8
+ import * as HttpStaticServer from "effect/unstable/http/HttpStaticServer"
9
+ import * as BunHttpServer from "@effect/platform-bun/BunHttpServer"
10
+ import { MotelHttpApi } from "./httpApi.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"
13
+ import { AsyncIngest, AsyncIngestLive } from "./services/AsyncIngest.js"
14
+ import { TelemetryStoreReadonly } from "./services/TelemetryStore.js"
15
+ import { TelemetryQueryLive } from "./services/TelemetryQuery.js"
16
+ import type { LogItem, TraceItem } from "./domain.js"
17
+ import { lifecycleLabel } from "./ui/format.js"
18
+ import { decodeProtobufLogs, decodeProtobufTraces } from "./otlpProtobuf.js"
19
+ import type { OtlpLogExportRequest, OtlpTraceExportRequest } from "./otlp.js"
20
+
21
+ // Set by the RegistryLayer acquisition once the Bun socket has bound.
22
+ // Both /api/health and the registry entry read from here so they agree
23
+ // on a single server-start timestamp, and the value reflects actual
24
+ // listen time rather than module-evaluation time.
25
+ let serverStartedAt: string = new Date().toISOString()
26
+
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)
29
+
30
+ const jsonResponse = (value: unknown, status = 200) => HttpServerResponse.jsonUnsafe(value, { status })
31
+ const textResponse = (value: string) => HttpServerResponse.text(value)
32
+ const htmlResponse = (value: string) => HttpServerResponse.html(value)
33
+ const notFoundResponse = (message = "Not found") => jsonResponse({ error: message }, 404)
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)
48
+ // Response-building helpers are generic in R so a handler can depend
49
+ // on AsyncIngest (worker-RPC path) or TelemetryStoreReadonly (query
50
+ // path) without forcing every handler onto the same service surface.
51
+ const respondJson = <A, R>(effect: Effect.Effect<A, unknown, R>) =>
52
+ Effect.match(effect, {
53
+ onFailure: (error) => jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 500),
54
+ onSuccess: (value) => jsonResponse(value),
55
+ })
56
+ const respondRaw = <R>(effect: Effect.Effect<ReturnType<typeof jsonResponse>, unknown, R>) =>
57
+ Effect.match(effect, {
58
+ onFailure: (error) => jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 500),
59
+ onSuccess: (value) => value,
60
+ })
61
+
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)))
73
+ }
74
+ return Effect.map(request.json, (payload) => payload as T)
75
+ }
76
+
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),
95
+ )
96
+
97
+ const handleLogSearch = (request: { readonly url: string }) =>
98
+ respondRaw(Effect.gen(function*() {
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"),
106
+ }))
107
+ }))
108
+
109
+ const escapeHtml = (value: string) =>
110
+ value
111
+ .replaceAll("&", "&amp;")
112
+ .replaceAll("<", "&lt;")
113
+ .replaceAll(">", "&gt;")
114
+ .replaceAll('"', "&quot;")
115
+
116
+ const renderTracePage = (trace: TraceItem, logs: readonly LogItem[]) => {
117
+ const logCountsBySpan = new Map<string, number>()
118
+ for (const log of logs) {
119
+ if (!log.spanId) continue
120
+ logCountsBySpan.set(log.spanId, (logCountsBySpan.get(log.spanId) ?? 0) + 1)
121
+ }
122
+
123
+ const spansHtml = trace.spans
124
+ .map((span) => {
125
+ const indent = Math.min(span.depth * 20, 120)
126
+ const count = logCountsBySpan.get(span.spanId) ?? 0
127
+ return `<tr>
128
+ <td style="padding-left:${indent}px">${escapeHtml(span.operationName)}</td>
129
+ <td>${escapeHtml(span.serviceName)}</td>
130
+ <td>${lifecycleLabel(span)}</td>
131
+ <td>${escapeHtml(span.status)}</td>
132
+ <td>${span.durationMs.toFixed(2)}ms</td>
133
+ <td>${count}</td>
134
+ </tr>`
135
+ })
136
+ .join("\n")
137
+
138
+ const logsHtml = logs
139
+ .slice(0, 80)
140
+ .map(
141
+ (log) => `<tr>
142
+ <td>${escapeHtml(log.timestamp.toISOString())}</td>
143
+ <td>${escapeHtml(log.severityText)}</td>
144
+ <td>${escapeHtml(log.scopeName ?? log.serviceName)}</td>
145
+ <td><pre>${escapeHtml(log.body)}</pre></td>
146
+ </tr>`,
147
+ )
148
+ .join("\n")
149
+
150
+ return `<!doctype html>
151
+ <html lang="en">
152
+ <head>
153
+ <meta charset="utf-8" />
154
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
155
+ <title>${escapeHtml(trace.rootOperationName)}</title>
156
+ <style>
157
+ body { background:#0b0b0b; color:#ede7da; font-family: ui-monospace, SFMono-Regular, monospace; margin:24px; }
158
+ h1,h2 { color:#f4a51c; }
159
+ .muted { color:#9f9788; }
160
+ table { width:100%; border-collapse: collapse; margin-top:16px; }
161
+ th, td { border-bottom:1px solid #2a2520; padding:8px; text-align:left; vertical-align:top; }
162
+ pre { white-space:pre-wrap; margin:0; color:#ede7da; }
163
+ </style>
164
+ </head>
165
+ <body>
166
+ <h1>${escapeHtml(trace.rootOperationName)}</h1>
167
+ <p class="muted">${escapeHtml(trace.serviceName)} · ${lifecycleLabel(trace)} · ${trace.durationMs.toFixed(2)}ms · ${trace.spanCount} spans · ${logs.length} logs</p>
168
+ <p class="muted">${escapeHtml(trace.traceId)}</p>
169
+ <h2>Spans</h2>
170
+ <table>
171
+ <thead><tr><th>Operation</th><th>Service</th><th>State</th><th>Status</th><th>Duration</th><th>Logs</th></tr></thead>
172
+ <tbody>${spansHtml}</tbody>
173
+ </table>
174
+ <h2>Logs</h2>
175
+ <table>
176
+ <thead><tr><th>Time</th><th>Level</th><th>Scope</th><th>Body</th></tr></thead>
177
+ <tbody>${logsHtml}</tbody>
178
+ </table>
179
+ </body>
180
+ </html>`
181
+ }
182
+
183
+ const TelemetryGroupLive = HttpApiBuilder.group(
184
+ MotelHttpApi,
185
+ "telemetry",
186
+ (handlers) =>
187
+ handlers
188
+ .handleRaw("root", () =>
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")),
190
+ )
191
+ .handle("health", () =>
192
+ HttpMiddleware.withLoggerDisabled(Effect.succeed(healthPayload())),
193
+ )
194
+ // OTLP ingest is routed to the worker thread via AsyncIngest
195
+ // so the main event loop stays free during heavy SQLite writes.
196
+ // Read queries use a separate query worker so synchronous SQLite
197
+ // work cannot block the HTTP event loop.
198
+ .handleRaw("ingestTraces", ({ request }) =>
199
+ HttpMiddleware.withLoggerDisabled(respondRaw(
200
+ Effect.flatMap(
201
+ readOtlpBody<OtlpTraceExportRequest>(request, decodeProtobufTraces),
202
+ (payload) => Effect.map(
203
+ Effect.flatMap(AsyncIngest, (ingest) => ingest.ingestTraces({ payload })),
204
+ (result) => jsonResponse(result),
205
+ ),
206
+ ),
207
+ )),
208
+ )
209
+ .handleRaw("ingestLogs", ({ request }) =>
210
+ HttpMiddleware.withLoggerDisabled(respondRaw(
211
+ Effect.flatMap(
212
+ readOtlpBody<OtlpLogExportRequest>(request, decodeProtobufLogs),
213
+ (payload) => Effect.map(
214
+ Effect.flatMap(AsyncIngest, (ingest) => ingest.ingestLogs({ payload })),
215
+ (result) => jsonResponse(result),
216
+ ),
217
+ ),
218
+ )),
219
+ )
220
+ .handleRaw("services", () => respondJson(Effect.map(withRead((store) => store.listServices), (data) => ({ data }))))
221
+ .handleRaw("traces", ({ request }) =>
222
+ respondRaw(Effect.gen(function*() {
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),
228
+ }))
229
+ return jsonResponse(paginateSummaries(data, params))
230
+ })),
231
+ )
232
+ .handleRaw("searchTraces", ({ request }) =>
233
+ respondRaw(Effect.gen(function*() {
234
+ const params = parseListParams(request, TRACE_LIST)
235
+ const data = yield* withRead((store) =>
236
+ store.searchTraceSummaries({
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),
246
+ }),
247
+ )
248
+ return jsonResponse(paginateSummaries(data, params))
249
+ })),
250
+ )
251
+ .handleRaw("traceStats", ({ request }) =>
252
+ respondRaw(Effect.gen(function*() {
253
+ const params = parseListParams(request, TRACE_STATS)
254
+ const groupBy = params.url.searchParams.get("groupBy")
255
+ const agg = params.url.searchParams.get("agg")
256
+ if (!groupBy || (agg !== "count" && agg !== "avg_duration" && agg !== "p95_duration" && agg !== "error_rate")) {
257
+ return jsonResponse({ error: "Expected groupBy and agg=count|avg_duration|p95_duration|error_rate" }, 400)
258
+ }
259
+ const data = yield* withRead((store) =>
260
+ store.traceStats({
261
+ groupBy,
262
+ agg,
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,
270
+ }),
271
+ )
272
+ return jsonResponse({ data })
273
+ })),
274
+ )
275
+ .handleRaw("searchSpans", ({ request }) =>
276
+ respondRaw(Effect.gen(function*() {
277
+ const params = parseListParams(request, SPAN_LIST)
278
+ const data = yield* withRead((store) =>
279
+ store.searchSpans({
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,
289
+ }),
290
+ )
291
+ const truncated = data.length > params.limit
292
+ const page = truncated ? data.slice(0, params.limit) : data
293
+ return jsonResponse({
294
+ data: page,
295
+ meta: listMeta({
296
+ limit: params.limit,
297
+ lookbackMinutes: params.lookbackMinutes,
298
+ returned: page.length,
299
+ truncated,
300
+ nextCursor: null,
301
+ }),
302
+ })
303
+ })),
304
+ )
305
+ .handleRaw("traceLogs", ({ params: route, request }) =>
306
+ respondRaw(Effect.gen(function*() {
307
+ const params = parseListParams(request, LOG_LIST)
308
+ return jsonResponse(yield* loadLogsPage(params, { traceId: route.traceId }))
309
+ })),
310
+ )
311
+ .handleRaw("traceSpans", ({ params }) =>
312
+ respondJson(Effect.map(withRead((store) => store.listTraceSpans(params.traceId)), (data) => ({ data }))),
313
+ )
314
+ .handleRaw("spanLogs", ({ params: route, request }) =>
315
+ respondRaw(Effect.gen(function*() {
316
+ const params = parseListParams(request, LOG_LIST)
317
+ return jsonResponse(yield* loadLogsPage(params, { spanId: route.spanId }))
318
+ })),
319
+ )
320
+ .handleRaw("span", ({ params }) =>
321
+ respondRaw(
322
+ Effect.flatMap(withRead((store) => store.getSpan(params.spanId)), (data) =>
323
+ Effect.succeed(data ? jsonResponse({ data }) : notFoundResponse("Span not found")),
324
+ ),
325
+ ),
326
+ )
327
+ .handleRaw("trace", ({ params }) =>
328
+ respondRaw(
329
+ Effect.flatMap(withRead((store) => store.getTrace(params.traceId)), (data) =>
330
+ Effect.succeed(data ? jsonResponse({ data }) : notFoundResponse("Trace not found")),
331
+ ),
332
+ ),
333
+ )
334
+ .handleRaw("logs", ({ request }) => handleLogSearch(request))
335
+ .handleRaw("searchLogs", ({ request }) => handleLogSearch(request))
336
+ .handleRaw("logStats", ({ request }) =>
337
+ respondRaw(Effect.gen(function*() {
338
+ const params = parseListParams(request, LOG_STATS)
339
+ const groupBy = params.url.searchParams.get("groupBy")
340
+ const agg = params.url.searchParams.get("agg")
341
+ if (!groupBy || agg !== "count") {
342
+ return jsonResponse({ error: "Expected groupBy and agg=count" }, 400)
343
+ }
344
+ const data = yield* withRead((store) =>
345
+ store.logStats({
346
+ groupBy,
347
+ agg: "count",
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,
355
+ }),
356
+ )
357
+ return jsonResponse({ data })
358
+ })),
359
+ )
360
+ .handle("docs", () =>
361
+ Effect.succeed({
362
+ docs: [
363
+ { name: "debug", title: "Motel Debug Workflow", path: "/api/docs/debug" },
364
+ { name: "effect", title: "Effect Instrumentation Guide", path: "/api/docs/effect" },
365
+ ],
366
+ }),
367
+ )
368
+ .handleRaw("doc", ({ params }) =>
369
+ respondRaw(Effect.gen(function*() {
370
+ const fileSystem = yield* FileSystem.FileSystem
371
+ const docFiles: Record<string, string> = {
372
+ debug: path.resolve(import.meta.dir, "../skills/motel-debug/SKILL.md"),
373
+ effect: path.resolve(import.meta.dir, "../skills/motel-debug/references/effect.md"),
374
+ }
375
+ const filePath = docFiles[params.name]
376
+ if (!filePath) return notFoundResponse(`Unknown doc: ${params.name}. Available: ${Object.keys(docFiles).join(", ")}`)
377
+ return yield* fileSystem.readFileString(filePath).pipe(
378
+ Effect.map(textResponse),
379
+ Effect.catch(() => Effect.succeed(notFoundResponse(`Doc file not found: ${params.name}`))),
380
+ )
381
+ })),
382
+ )
383
+ .handleRaw("facets", ({ request }) =>
384
+ respondRaw(Effect.gen(function*() {
385
+ const url = requestUrl(request)
386
+ const type = url.searchParams.get("type")
387
+ const field = url.searchParams.get("field")
388
+ if ((type !== "traces" && type !== "logs") || !field) {
389
+ return jsonResponse({ error: "Expected type=traces|logs and field=<name>" }, 400)
390
+ }
391
+ const data = yield* withRead((store) =>
392
+ store.listFacets({
393
+ type,
394
+ field,
395
+ serviceName: url.searchParams.get("service"),
396
+ key: url.searchParams.get("key"),
397
+ lookbackMinutes: parseLookbackMinutes(url.searchParams.get("lookback"), config.otel.traceLookbackMinutes),
398
+ limit: parseLimit(url.searchParams.get("limit"), 20),
399
+ }),
400
+ )
401
+ return jsonResponse({ data })
402
+ })),
403
+ )
404
+ .handleRaw("aiCalls", ({ request }) =>
405
+ respondRaw(Effect.gen(function*() {
406
+ const params = parseListParams(request, AI_LIST)
407
+ const data = yield* withRead((store) =>
408
+ store.searchAiCalls({
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,
421
+ }),
422
+ )
423
+ return jsonResponse({
424
+ data,
425
+ meta: listMeta({ limit: params.limit, lookbackMinutes: params.lookbackMinutes, returned: data.length, truncated: false, nextCursor: null }),
426
+ })
427
+ })),
428
+ )
429
+ .handleRaw("aiCall", ({ params }) =>
430
+ respondRaw(Effect.gen(function*() {
431
+ const data = yield* withRead((store) => store.getAiCall(params.spanId))
432
+ if (!data) return notFoundResponse("AI call not found")
433
+ return jsonResponse({ data })
434
+ })),
435
+ )
436
+ .handleRaw("aiStats", ({ request }) =>
437
+ respondRaw(Effect.gen(function*() {
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
441
+ if (!groupBy || !agg) {
442
+ return jsonResponse({ error: "Expected groupBy and agg parameters" }, 400)
443
+ }
444
+ const data = yield* withRead((store) =>
445
+ store.aiCallStats({
446
+ groupBy,
447
+ agg,
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,
459
+ }),
460
+ )
461
+ return jsonResponse({ data })
462
+ })),
463
+ )
464
+ .handleRaw("tracePage", ({ params }) =>
465
+ respondRaw(
466
+ Effect.flatMap(withRead((store) => store.getTrace(params.traceId)), (trace) =>
467
+ trace
468
+ ? Effect.map(withRead((store) => store.listTraceLogs(params.traceId)), (logs) => htmlResponse(renderTracePage(trace, logs)))
469
+ : Effect.succeed(notFoundResponse("Trace not found")),
470
+ ),
471
+ ),
472
+ ),
473
+ )
474
+
475
+ // ---------------------------------------------------------------------------
476
+ // App layer: HTTP router + static SPA + telemetry store
477
+ // ---------------------------------------------------------------------------
478
+
479
+ // API routes come from the Effect HttpApi definition. Everything under
480
+ // /api/*, /v1/*, /openapi.json, /docs is handled here.
481
+ const ApiLayer = HttpApiBuilder.layer(MotelHttpApi, { openapiPath: "/openapi.json" }).pipe(
482
+ Layer.provide(TelemetryGroupLive),
483
+ Layer.provide(HttpApiScalar.layer(MotelHttpApi, { scalar: { forceDarkModeState: "dark", showOperationId: true } })),
484
+ )
485
+
486
+ // Web UI: Vite-built SPA served from web/dist. HttpStaticServer.layer
487
+ // handles GET /*, filesystem lookup under `root`, and SPA fallback to
488
+ // index.html for unknown paths — replacing the hand-rolled serveWebUi
489
+ // wrapper that previously lived inline with Bun.serve. The API routes
490
+ // above take precedence because HttpApi registers specific paths that
491
+ // the router matches before falling through to the /* catch-all.
492
+ const WEB_DIST_DIR = path.resolve(import.meta.dir, "../web/dist")
493
+ const StaticLayer = HttpStaticServer.layer({
494
+ root: WEB_DIST_DIR,
495
+ spa: true,
496
+ })
497
+
498
+ // Registry-entry writer as a scoped acquisition. The entry is published
499
+ // after BunHttpServer.layer binds the socket (scope acquisition order)
500
+ // and removed on scope release, so a bind failure never leaves a zombie
501
+ // entry and a graceful shutdown cleans up alongside the server stop —
502
+ // both in the same finalizer chain managed by Layer.launch.
503
+ const RegistryLayer = Layer.effectDiscard(
504
+ Effect.acquireRelease(
505
+ Effect.sync(() => {
506
+ serverStartedAt = new Date().toISOString()
507
+ try {
508
+ writeRegistryEntry({
509
+ pid: process.pid,
510
+ url: config.otel.baseUrl,
511
+ workdir: process.cwd(),
512
+ startedAt: serverStartedAt,
513
+ version: MOTEL_VERSION,
514
+ databasePath: config.otel.databasePath,
515
+ instanceId: process.env.MOTEL_DAEMON_INSTANCE_ID?.trim(),
516
+ processIdentity: processIdentity(process.pid) ?? undefined,
517
+ })
518
+ } catch (err) {
519
+ console.warn(`motel: failed to write registry entry: ${(err as Error).message}`)
520
+ }
521
+ }),
522
+ () => Effect.sync(() => removeRegistryEntry(process.pid)),
523
+ ),
524
+ )
525
+
526
+ // ---------------------------------------------------------------------------
527
+ // Server lifecycle
528
+ // ---------------------------------------------------------------------------
529
+
530
+ /**
531
+ * Launchable server layer. Composes the API + static UI + store + registry,
532
+ * wraps the whole stack in HttpMiddleware.tracer (per-request OTel spans
533
+ * with http.method / url / status / user-agent attributes), and binds the
534
+ * socket via @effect/platform-bun's BunHttpServer. Use from server.ts:
535
+ *
536
+ * await Effect.runPromise(Layer.launch(ServerLive))
537
+ *
538
+ * Socket lifecycle, graceful shutdown, and error propagation are managed
539
+ * by the BunHttpServer layer's Scope — no hand-rolled start/stop plumbing.
540
+ * `reusePort: true` is retained as defense-in-depth against TIME_WAIT
541
+ * rebind conflicts (the registry-based adoption path in daemon.ts is the
542
+ * primary protection, but this covers a raw `bun src/server.ts` restart).
543
+ */
544
+ export const ServerLive = HttpRouter.serve(
545
+ Layer.mergeAll(ApiLayer, StaticLayer, RegistryLayer),
546
+ { middleware: HttpMiddleware.tracer },
547
+ ).pipe(
548
+ // OTLP ingest paths are NOT traced by the middleware, otherwise
549
+ // MOTEL_OTEL_ENABLED creates a feedback loop: every outbound span
550
+ // POSTs to /v1/traces, the tracer emits a span for that POST, which
551
+ // POSTs again on the next flush. This also shaves ~1 KB of header
552
+ // attributes off every ingest request that would have been written
553
+ // to the spans table as noise.
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.
558
+ Layer.provideMerge(AsyncIngestLive),
559
+ Layer.provideMerge(TelemetryQueryLive),
560
+ Layer.provideMerge(BunHttpServer.layer({
561
+ port: config.otel.port,
562
+ hostname: config.otel.host,
563
+ reusePort: true,
564
+ routes: {
565
+ "/api/health": () => Response.json(healthPayload()),
566
+ },
567
+ })),
568
+ )