@kitlangton/motel 0.1.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 (53) hide show
  1. package/AGENTS.md +142 -0
  2. package/LICENSE +21 -0
  3. package/README.md +199 -0
  4. package/package.json +92 -0
  5. package/src/App.tsx +217 -0
  6. package/src/cli.ts +258 -0
  7. package/src/config.ts +39 -0
  8. package/src/daemon.test.ts +59 -0
  9. package/src/daemon.ts +398 -0
  10. package/src/domain.ts +233 -0
  11. package/src/httpApi.ts +384 -0
  12. package/src/index.tsx +18 -0
  13. package/src/instructions.ts +72 -0
  14. package/src/localServer.ts +699 -0
  15. package/src/locator.ts +138 -0
  16. package/src/mcp.ts +260 -0
  17. package/src/motel.ts +86 -0
  18. package/src/motelClient.ts +201 -0
  19. package/src/otlp.ts +142 -0
  20. package/src/queryFilters.ts +39 -0
  21. package/src/registry.ts +86 -0
  22. package/src/runtime.ts +38 -0
  23. package/src/server.ts +10 -0
  24. package/src/services/LogQueryService.ts +43 -0
  25. package/src/services/TelemetryStore.ts +1821 -0
  26. package/src/services/TraceQueryService.ts +71 -0
  27. package/src/telemetry.test.ts +726 -0
  28. package/src/ui/ServiceLogs.tsx +112 -0
  29. package/src/ui/SpanDetail.tsx +134 -0
  30. package/src/ui/SpanDetailFull.tsx +224 -0
  31. package/src/ui/SpanDetailPane.tsx +91 -0
  32. package/src/ui/TraceDetailsPane.tsx +169 -0
  33. package/src/ui/TraceList.tsx +128 -0
  34. package/src/ui/Waterfall.tsx +412 -0
  35. package/src/ui/app/TraceListPane.tsx +34 -0
  36. package/src/ui/app/TraceWorkspace.tsx +254 -0
  37. package/src/ui/app/useAppLayout.ts +79 -0
  38. package/src/ui/app/useTraceScreenData.ts +411 -0
  39. package/src/ui/format.ts +119 -0
  40. package/src/ui/primitives.tsx +170 -0
  41. package/src/ui/state.ts +137 -0
  42. package/src/ui/theme.ts +153 -0
  43. package/src/ui/traceDetailsWidth.repro.test.ts +115 -0
  44. package/src/ui/traceSortNav.repro.seed.ts +62 -0
  45. package/src/ui/traceSortNav.repro.test.ts +220 -0
  46. package/src/ui/useKeyboardNav.ts +532 -0
  47. package/src/ui/waterfallNav.repro.seed.ts +86 -0
  48. package/src/ui/waterfallNav.repro.test.ts +263 -0
  49. package/src/ui/waterfallNav.test.ts +422 -0
  50. package/src/ui/waterfallNav.ts +75 -0
  51. package/web/dist/assets/index-BEKIiisE.js +27 -0
  52. package/web/dist/assets/index-DzuHNBGV.css +2 -0
  53. package/web/dist/index.html +13 -0
@@ -0,0 +1,699 @@
1
+ import { promises as fs } from "node:fs"
2
+ import path from "node:path"
3
+ import { Effect, Layer, Context } from "effect"
4
+ import { config, parsePositiveInt, resolveOtelUrl } from "./config.js"
5
+ import { HttpApiBuilder, HttpApiScalar } from "effect/unstable/httpapi"
6
+ import * as HttpRouter from "effect/unstable/http/HttpRouter"
7
+ import * as HttpServer from "effect/unstable/http/HttpServer"
8
+ import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"
9
+ import { MotelHttpApi } from "./httpApi.js"
10
+ import { attributeFiltersFromEntries, attributeContainsFiltersFromEntries, ATTRIBUTE_FILTER_PREFIX, ATTRIBUTE_CONTAINS_PREFIX } from "./queryFilters.js"
11
+ import { MOTEL_SERVICE_ID, MOTEL_VERSION, writeRegistryEntry } from "./registry.js"
12
+ import { TelemetryStore, TelemetryStoreLive } from "./services/TelemetryStore.js"
13
+ import type { LogItem, TraceItem, TraceSummaryItem } from "./domain.js"
14
+ import { lifecycleLabel } from "./ui/format.js"
15
+
16
+ const TRACE_DEFAULT_LIMIT = 20
17
+ const TRACE_MAX_LIMIT = 100
18
+ const TRACE_DEFAULT_LOOKBACK = 60
19
+ const TRACE_MAX_LOOKBACK = 24 * 60
20
+ const SPAN_DEFAULT_LIMIT = 100
21
+ const SPAN_MAX_LIMIT = 500
22
+ const LOG_DEFAULT_LIMIT = 100
23
+ const LOG_MAX_LIMIT = 500
24
+ const LOG_DEFAULT_LOOKBACK = 60
25
+ const LOG_MAX_LOOKBACK = 24 * 60
26
+
27
+ let server: ReturnType<typeof Bun.serve> | null = null
28
+ let disposeWebHandler: (() => Promise<void>) | null = null
29
+ let startedAt: string | null = null
30
+
31
+ const resolveBoundUrl = () => {
32
+ if (!server) return config.otel.queryUrl
33
+ const host = server.hostname === "0.0.0.0" || server.hostname === "::" ? "127.0.0.1" : server.hostname
34
+ return `http://${host}:${server.port}`
35
+ }
36
+
37
+ const jsonResponse = (value: unknown, status = 200) => HttpServerResponse.jsonUnsafe(value, { status })
38
+ const textResponse = (value: string) => HttpServerResponse.text(value)
39
+ const htmlResponse = (value: string) => HttpServerResponse.html(value)
40
+ const notFoundResponse = (message = "Not found") => jsonResponse({ error: message }, 404)
41
+ const requestUrl = (request: { readonly url: string }) => new URL(request.url, config.otel.baseUrl)
42
+ const withStore = <A>(f: (store: TelemetryStore["Service"]) => Effect.Effect<A, Error>) => Effect.flatMap(TelemetryStore.asEffect(), f)
43
+ const respondJson = <A>(effect: Effect.Effect<A, unknown, TelemetryStore>) =>
44
+ Effect.match(effect, {
45
+ onFailure: (error) => jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 500),
46
+ onSuccess: (value) => jsonResponse(value),
47
+ })
48
+ const respondRaw = (effect: Effect.Effect<ReturnType<typeof jsonResponse>, unknown, TelemetryStore>) =>
49
+ Effect.match(effect, {
50
+ onFailure: (error) => jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 500),
51
+ onSuccess: (value) => value,
52
+ })
53
+
54
+ const parseLimit = (value: string | null, fallback: number) => parsePositiveInt(value ?? undefined, fallback)
55
+ const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(value, max))
56
+ const parseBoundedLimit = (value: string | null, fallback: number, max: number) => clamp(parseLimit(value, fallback), 1, max)
57
+
58
+ const parseLookbackMinutes = (value: string | null, fallback: number) => {
59
+ if (!value) return fallback
60
+ const match = value.trim().match(/^(\d+)([mhd])$/i)
61
+ if (!match) return fallback
62
+ const amount = Number.parseInt(match[1] ?? "", 10)
63
+ if (!Number.isFinite(amount) || amount <= 0) return fallback
64
+ const unit = (match[2] ?? "m").toLowerCase()
65
+ if (unit === "d") return amount * 1440
66
+ if (unit === "h") return amount * 60
67
+ return amount
68
+ }
69
+
70
+ const parseBoundedLookbackMinutes = (value: string | null, fallback: number, max: number) => clamp(parseLookbackMinutes(value, fallback), 1, max)
71
+
72
+ const attributeFiltersFromQuery = (url: URL) =>
73
+ attributeFiltersFromEntries(
74
+ [...url.searchParams.entries()].filter(([key]) => key.startsWith(ATTRIBUTE_FILTER_PREFIX) && !key.startsWith(ATTRIBUTE_CONTAINS_PREFIX)),
75
+ )
76
+
77
+ const attributeContainsFiltersFromQuery = (url: URL) =>
78
+ attributeContainsFiltersFromEntries(
79
+ [...url.searchParams.entries()].filter(([key]) => key.startsWith(ATTRIBUTE_CONTAINS_PREFIX)),
80
+ )
81
+
82
+ type CursorShape =
83
+ | { readonly kind: "trace"; readonly startedAt: number; readonly id: string }
84
+ | { readonly kind: "log"; readonly timestamp: number; readonly id: string }
85
+
86
+ const encodeCursor = (cursor: CursorShape) => Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url")
87
+
88
+ const decodeCursor = (value: string | null): CursorShape | null => {
89
+ if (!value) return null
90
+ try {
91
+ return JSON.parse(Buffer.from(value, "base64url").toString("utf8")) as CursorShape
92
+ } catch {
93
+ return null
94
+ }
95
+ }
96
+
97
+ const formatLookback = (minutes: number) => {
98
+ if (minutes % 1440 === 0) return `${minutes / 1440}d`
99
+ if (minutes % 60 === 0) return `${minutes / 60}h`
100
+ return `${minutes}m`
101
+ }
102
+
103
+ const listMeta = (input: { readonly limit: number; readonly lookbackMinutes: number; readonly returned: number; readonly truncated: boolean; readonly nextCursor: string | null }) => ({
104
+ limit: input.limit,
105
+ lookback: formatLookback(input.lookbackMinutes),
106
+ returned: input.returned,
107
+ truncated: input.truncated,
108
+ nextCursor: input.nextCursor,
109
+ })
110
+
111
+ const paginateSummaries = (summaries: readonly TraceSummaryItem[], options: { readonly limit: number; readonly lookbackMinutes: number; readonly cursor: CursorShape | null }) => {
112
+ const page = summaries.slice(0, options.limit)
113
+ const last = page.at(-1)
114
+ return {
115
+ data: page,
116
+ meta: listMeta({
117
+ limit: options.limit,
118
+ lookbackMinutes: options.lookbackMinutes,
119
+ returned: page.length,
120
+ truncated: summaries.length > page.length,
121
+ nextCursor: last ? encodeCursor({ kind: "trace", startedAt: last.startedAt.getTime(), id: last.traceId }) : null,
122
+ }),
123
+ }
124
+ }
125
+
126
+ const paginateLogs = (logs: readonly LogItem[], options: { readonly limit: number; readonly lookbackMinutes: number; readonly cursor: CursorShape | null }) => {
127
+ const page = logs.slice(0, options.limit)
128
+ const last = page.at(-1)
129
+
130
+ return {
131
+ data: page,
132
+ meta: listMeta({
133
+ limit: options.limit,
134
+ lookbackMinutes: options.lookbackMinutes,
135
+ returned: page.length,
136
+ truncated: logs.length > page.length,
137
+ nextCursor: last ? encodeCursor({ kind: "log", timestamp: last.timestamp.getTime(), id: last.id }) : null,
138
+ }),
139
+ }
140
+ }
141
+
142
+ const loadLogsPage = (input: {
143
+ readonly serviceName?: string | null
144
+ readonly severity?: string | null
145
+ readonly traceId?: string | null
146
+ readonly spanId?: string | null
147
+ readonly body?: string | null
148
+ readonly attributeFilters?: Readonly<Record<string, string>>
149
+ readonly attributeContainsFilters?: Readonly<Record<string, string>>
150
+ readonly limit: number
151
+ readonly lookbackMinutes: number
152
+ readonly cursor: CursorShape | null
153
+ }) =>
154
+ Effect.flatMap(TelemetryStore.asEffect(), (store) =>
155
+ Effect.map(
156
+ store.searchLogs({
157
+ serviceName: input.serviceName,
158
+ severity: input.severity,
159
+ traceId: input.traceId,
160
+ spanId: input.spanId,
161
+ body: input.body,
162
+ lookbackMinutes: input.lookbackMinutes,
163
+ limit: input.limit + 1,
164
+ cursorTimestampMs: input.cursor?.kind === "log" ? input.cursor.timestamp : undefined,
165
+ cursorId: input.cursor?.kind === "log" ? input.cursor.id : undefined,
166
+ attributeFilters: input.attributeFilters,
167
+ attributeContainsFilters: input.attributeContainsFilters,
168
+ }),
169
+ (logs) => paginateLogs(logs, {
170
+ limit: input.limit,
171
+ lookbackMinutes: input.lookbackMinutes,
172
+ cursor: input.cursor,
173
+ }),
174
+ ),
175
+ )
176
+
177
+ const handleLogSearch = (request: { readonly url: string }) =>
178
+ respondRaw(Effect.gen(function*() {
179
+ const url = requestUrl(request)
180
+ const attributeFilters = attributeFiltersFromQuery(url)
181
+ const attributeContainsFilters = attributeContainsFiltersFromQuery(url)
182
+ const limit = parseBoundedLimit(url.searchParams.get("limit"), LOG_DEFAULT_LIMIT, LOG_MAX_LIMIT)
183
+ const lookbackMinutes = parseBoundedLookbackMinutes(url.searchParams.get("lookback"), LOG_DEFAULT_LOOKBACK, LOG_MAX_LOOKBACK)
184
+ const cursor = decodeCursor(url.searchParams.get("cursor"))
185
+ return jsonResponse(yield* loadLogsPage({
186
+ serviceName: url.searchParams.get("service"),
187
+ severity: url.searchParams.get("severity"),
188
+ traceId: url.searchParams.get("traceId"),
189
+ spanId: url.searchParams.get("spanId"),
190
+ body: url.searchParams.get("body"),
191
+ attributeFilters,
192
+ attributeContainsFilters,
193
+ limit,
194
+ lookbackMinutes,
195
+ cursor,
196
+ }))
197
+ }))
198
+
199
+ const escapeHtml = (value: string) =>
200
+ value
201
+ .replaceAll("&", "&amp;")
202
+ .replaceAll("<", "&lt;")
203
+ .replaceAll(">", "&gt;")
204
+ .replaceAll('"', "&quot;")
205
+
206
+ const renderTracePage = (trace: TraceItem, logs: readonly LogItem[]) => {
207
+ const logCountsBySpan = new Map<string, number>()
208
+ for (const log of logs) {
209
+ if (!log.spanId) continue
210
+ logCountsBySpan.set(log.spanId, (logCountsBySpan.get(log.spanId) ?? 0) + 1)
211
+ }
212
+
213
+ const spansHtml = trace.spans
214
+ .map((span) => {
215
+ const indent = Math.min(span.depth * 20, 120)
216
+ const count = logCountsBySpan.get(span.spanId) ?? 0
217
+ return `<tr>
218
+ <td style="padding-left:${indent}px">${escapeHtml(span.operationName)}</td>
219
+ <td>${escapeHtml(span.serviceName)}</td>
220
+ <td>${lifecycleLabel(span)}</td>
221
+ <td>${escapeHtml(span.status)}</td>
222
+ <td>${span.durationMs.toFixed(2)}ms</td>
223
+ <td>${count}</td>
224
+ </tr>`
225
+ })
226
+ .join("\n")
227
+
228
+ const logsHtml = logs
229
+ .slice(0, 80)
230
+ .map(
231
+ (log) => `<tr>
232
+ <td>${escapeHtml(log.timestamp.toISOString())}</td>
233
+ <td>${escapeHtml(log.severityText)}</td>
234
+ <td>${escapeHtml(log.scopeName ?? log.serviceName)}</td>
235
+ <td><pre>${escapeHtml(log.body)}</pre></td>
236
+ </tr>`,
237
+ )
238
+ .join("\n")
239
+
240
+ return `<!doctype html>
241
+ <html lang="en">
242
+ <head>
243
+ <meta charset="utf-8" />
244
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
245
+ <title>${escapeHtml(trace.rootOperationName)}</title>
246
+ <style>
247
+ body { background:#0b0b0b; color:#ede7da; font-family: ui-monospace, SFMono-Regular, monospace; margin:24px; }
248
+ h1,h2 { color:#f4a51c; }
249
+ .muted { color:#9f9788; }
250
+ table { width:100%; border-collapse: collapse; margin-top:16px; }
251
+ th, td { border-bottom:1px solid #2a2520; padding:8px; text-align:left; vertical-align:top; }
252
+ pre { white-space:pre-wrap; margin:0; color:#ede7da; }
253
+ </style>
254
+ </head>
255
+ <body>
256
+ <h1>${escapeHtml(trace.rootOperationName)}</h1>
257
+ <p class="muted">${escapeHtml(trace.serviceName)} · ${lifecycleLabel(trace)} · ${trace.durationMs.toFixed(2)}ms · ${trace.spanCount} spans · ${logs.length} logs</p>
258
+ <p class="muted">${escapeHtml(trace.traceId)}</p>
259
+ <h2>Spans</h2>
260
+ <table>
261
+ <thead><tr><th>Operation</th><th>Service</th><th>State</th><th>Status</th><th>Duration</th><th>Logs</th></tr></thead>
262
+ <tbody>${spansHtml}</tbody>
263
+ </table>
264
+ <h2>Logs</h2>
265
+ <table>
266
+ <thead><tr><th>Time</th><th>Level</th><th>Scope</th><th>Body</th></tr></thead>
267
+ <tbody>${logsHtml}</tbody>
268
+ </table>
269
+ </body>
270
+ </html>`
271
+ }
272
+
273
+ const TelemetryGroupLive = HttpApiBuilder.group(
274
+ MotelHttpApi,
275
+ "telemetry",
276
+ (handlers) =>
277
+ handlers
278
+ .handleRaw("root", () =>
279
+ 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")),
280
+ )
281
+ .handle("health", () =>
282
+ Effect.succeed({
283
+ ok: true,
284
+ service: MOTEL_SERVICE_ID,
285
+ databasePath: config.otel.databasePath,
286
+ pid: process.pid,
287
+ url: resolveBoundUrl(),
288
+ workdir: process.cwd(),
289
+ startedAt: startedAt ?? new Date(0).toISOString(),
290
+ version: MOTEL_VERSION,
291
+ }),
292
+ )
293
+ .handleRaw("ingestTraces", ({ request }) =>
294
+ respondRaw(
295
+ Effect.flatMap(request.json, (payload) =>
296
+ Effect.map(withStore((store) => store.ingestTraces(payload as any)), (result) => jsonResponse(result)),
297
+ ),
298
+ ),
299
+ )
300
+ .handleRaw("ingestLogs", ({ request }) =>
301
+ respondRaw(
302
+ Effect.flatMap(request.json, (payload) =>
303
+ Effect.map(withStore((store) => store.ingestLogs(payload as any)), (result) => jsonResponse(result)),
304
+ ),
305
+ ),
306
+ )
307
+ .handleRaw("services", () => respondJson(Effect.map(withStore((store) => store.listServices), (data) => ({ data }))))
308
+ .handleRaw("traces", ({ request }) =>
309
+ respondRaw(Effect.gen(function*() {
310
+ const url = requestUrl(request)
311
+ const service = url.searchParams.get("service")
312
+ const limit = parseBoundedLimit(url.searchParams.get("limit"), TRACE_DEFAULT_LIMIT, TRACE_MAX_LIMIT)
313
+ const lookbackMinutes = parseBoundedLookbackMinutes(url.searchParams.get("lookback"), TRACE_DEFAULT_LOOKBACK, TRACE_MAX_LOOKBACK)
314
+ const cursor = decodeCursor(url.searchParams.get("cursor"))
315
+ const data = yield* withStore((store) => store.listTraceSummaries(service, {
316
+ limit: limit + 1,
317
+ lookbackMinutes,
318
+ cursorStartedAtMs: cursor?.kind === "trace" ? cursor.startedAt : undefined,
319
+ cursorTraceId: cursor?.kind === "trace" ? cursor.id : undefined,
320
+ }))
321
+ return jsonResponse(paginateSummaries(data, { limit, lookbackMinutes, cursor }))
322
+ })),
323
+ )
324
+ .handleRaw("searchTraces", ({ request }) =>
325
+ respondRaw(Effect.gen(function*() {
326
+ const url = requestUrl(request)
327
+ const attributeFilters = attributeFiltersFromQuery(url)
328
+ const limit = parseBoundedLimit(url.searchParams.get("limit"), TRACE_DEFAULT_LIMIT, TRACE_MAX_LIMIT)
329
+ const lookbackMinutes = parseBoundedLookbackMinutes(url.searchParams.get("lookback"), TRACE_DEFAULT_LOOKBACK, TRACE_MAX_LOOKBACK)
330
+ const cursor = decodeCursor(url.searchParams.get("cursor"))
331
+ const data = yield* withStore((store) =>
332
+ store.searchTraceSummaries({
333
+ serviceName: url.searchParams.get("service"),
334
+ operation: url.searchParams.get("operation"),
335
+ status: (url.searchParams.get("status") as "ok" | "error" | null) ?? null,
336
+ minDurationMs: url.searchParams.get("minDurationMs") ? Number.parseFloat(url.searchParams.get("minDurationMs") ?? "") : null,
337
+ attributeFilters,
338
+ limit: limit + 1,
339
+ lookbackMinutes,
340
+ cursorStartedAtMs: cursor?.kind === "trace" ? cursor.startedAt : undefined,
341
+ cursorTraceId: cursor?.kind === "trace" ? cursor.id : undefined,
342
+ }),
343
+ )
344
+ return jsonResponse(paginateSummaries(data, { limit, lookbackMinutes, cursor }))
345
+ })),
346
+ )
347
+ .handleRaw("traceStats", ({ request }) =>
348
+ respondRaw(Effect.gen(function*() {
349
+ const url = requestUrl(request)
350
+ const attributeFilters = attributeFiltersFromQuery(url)
351
+ const groupBy = url.searchParams.get("groupBy")
352
+ const agg = url.searchParams.get("agg")
353
+ const lookbackMinutes = parseBoundedLookbackMinutes(url.searchParams.get("lookback"), TRACE_DEFAULT_LOOKBACK, TRACE_MAX_LOOKBACK)
354
+ if (!groupBy || (agg !== "count" && agg !== "avg_duration" && agg !== "p95_duration" && agg !== "error_rate")) {
355
+ return jsonResponse({ error: "Expected groupBy and agg=count|avg_duration|p95_duration|error_rate" }, 400)
356
+ }
357
+ const data = yield* withStore((store) =>
358
+ store.traceStats({
359
+ groupBy,
360
+ agg,
361
+ serviceName: url.searchParams.get("service"),
362
+ operation: url.searchParams.get("operation"),
363
+ status: (url.searchParams.get("status") as "ok" | "error" | null) ?? null,
364
+ minDurationMs: url.searchParams.get("minDurationMs") ? Number.parseFloat(url.searchParams.get("minDurationMs") ?? "") : null,
365
+ attributeFilters,
366
+ limit: parseBoundedLimit(url.searchParams.get("limit"), 20, TRACE_MAX_LIMIT),
367
+ lookbackMinutes,
368
+ }),
369
+ )
370
+ return jsonResponse({ data })
371
+ })),
372
+ )
373
+ .handleRaw("searchSpans", ({ request }) =>
374
+ respondRaw(Effect.gen(function*() {
375
+ const url = requestUrl(request)
376
+ const attributeFilters = attributeFiltersFromQuery(url)
377
+ const attributeContainsFilters = attributeContainsFiltersFromQuery(url)
378
+ const limit = parseBoundedLimit(url.searchParams.get("limit"), SPAN_DEFAULT_LIMIT, SPAN_MAX_LIMIT)
379
+ const lookbackMinutes = parseBoundedLookbackMinutes(url.searchParams.get("lookback"), TRACE_DEFAULT_LOOKBACK, TRACE_MAX_LOOKBACK)
380
+ const data = yield* withStore((store) =>
381
+ store.searchSpans({
382
+ serviceName: url.searchParams.get("service"),
383
+ traceId: url.searchParams.get("traceId"),
384
+ operation: url.searchParams.get("operation"),
385
+ parentOperation: url.searchParams.get("parentOperation"),
386
+ status: (url.searchParams.get("status") as "ok" | "error" | null) ?? null,
387
+ attributeFilters,
388
+ attributeContainsFilters,
389
+ limit: limit + 1,
390
+ lookbackMinutes,
391
+ }),
392
+ )
393
+ const truncated = data.length > limit
394
+ const page = truncated ? data.slice(0, limit) : data
395
+ return jsonResponse({
396
+ data: page,
397
+ meta: listMeta({
398
+ limit,
399
+ lookbackMinutes,
400
+ returned: page.length,
401
+ truncated,
402
+ nextCursor: null,
403
+ }),
404
+ })
405
+ })),
406
+ )
407
+ .handleRaw("traceLogs", ({ params, request }) =>
408
+ respondRaw(Effect.gen(function*() {
409
+ const url = requestUrl(request)
410
+ const lookbackMinutes = parseBoundedLookbackMinutes(url.searchParams.get("lookback"), LOG_DEFAULT_LOOKBACK, LOG_MAX_LOOKBACK)
411
+ const limit = parseBoundedLimit(url.searchParams.get("limit"), LOG_DEFAULT_LIMIT, LOG_MAX_LIMIT)
412
+ const cursor = decodeCursor(url.searchParams.get("cursor"))
413
+ return jsonResponse(yield* loadLogsPage({ traceId: params.traceId, limit, lookbackMinutes, cursor }))
414
+ })),
415
+ )
416
+ .handleRaw("traceSpans", ({ params }) =>
417
+ respondJson(Effect.map(withStore((store) => store.listTraceSpans(params.traceId)), (data) => ({ data }))),
418
+ )
419
+ .handleRaw("spanLogs", ({ params, request }) =>
420
+ respondRaw(Effect.gen(function*() {
421
+ const url = requestUrl(request)
422
+ const lookbackMinutes = parseBoundedLookbackMinutes(url.searchParams.get("lookback"), LOG_DEFAULT_LOOKBACK, LOG_MAX_LOOKBACK)
423
+ const limit = parseBoundedLimit(url.searchParams.get("limit"), LOG_DEFAULT_LIMIT, LOG_MAX_LIMIT)
424
+ const cursor = decodeCursor(url.searchParams.get("cursor"))
425
+ return jsonResponse(yield* loadLogsPage({ spanId: params.spanId, limit, lookbackMinutes, cursor }))
426
+ })),
427
+ )
428
+ .handleRaw("span", ({ params }) =>
429
+ respondRaw(
430
+ Effect.flatMap(withStore((store) => store.getSpan(params.spanId)), (data) =>
431
+ Effect.succeed(data ? jsonResponse({ data }) : notFoundResponse("Span not found")),
432
+ ),
433
+ ),
434
+ )
435
+ .handleRaw("trace", ({ params }) =>
436
+ respondRaw(
437
+ Effect.flatMap(withStore((store) => store.getTrace(params.traceId)), (data) =>
438
+ Effect.succeed(data ? jsonResponse({ data }) : notFoundResponse("Trace not found")),
439
+ ),
440
+ ),
441
+ )
442
+ .handleRaw("logs", ({ request }) => handleLogSearch(request))
443
+ .handleRaw("searchLogs", ({ request }) => handleLogSearch(request))
444
+ .handleRaw("logStats", ({ request }) =>
445
+ respondRaw(Effect.gen(function*() {
446
+ const url = requestUrl(request)
447
+ const attributeFilters = attributeFiltersFromQuery(url)
448
+ const groupBy = url.searchParams.get("groupBy")
449
+ const agg = url.searchParams.get("agg")
450
+ const lookbackMinutes = parseBoundedLookbackMinutes(url.searchParams.get("lookback"), LOG_DEFAULT_LOOKBACK, LOG_MAX_LOOKBACK)
451
+ if (!groupBy || agg !== "count") {
452
+ return jsonResponse({ error: "Expected groupBy and agg=count" }, 400)
453
+ }
454
+ const data = yield* withStore((store) =>
455
+ store.logStats({
456
+ groupBy,
457
+ agg: "count",
458
+ serviceName: url.searchParams.get("service"),
459
+ traceId: url.searchParams.get("traceId"),
460
+ spanId: url.searchParams.get("spanId"),
461
+ body: url.searchParams.get("body"),
462
+ attributeFilters,
463
+ limit: parseBoundedLimit(url.searchParams.get("limit"), 20, LOG_MAX_LIMIT),
464
+ lookbackMinutes,
465
+ }),
466
+ )
467
+ return jsonResponse({ data })
468
+ })),
469
+ )
470
+ .handle("docs", () =>
471
+ Effect.succeed({
472
+ docs: [
473
+ { name: "debug", title: "Motel Debug Workflow", path: "/api/docs/debug" },
474
+ { name: "effect", title: "Effect Instrumentation Guide", path: "/api/docs/effect" },
475
+ ],
476
+ }),
477
+ )
478
+ .handleRaw("doc", ({ params }) =>
479
+ respondRaw(Effect.gen(function*() {
480
+ const docFiles: Record<string, string> = {
481
+ debug: path.resolve(import.meta.dir, "../skills/motel-debug/SKILL.md"),
482
+ effect: path.resolve(import.meta.dir, "../skills/motel-debug/references/effect.md"),
483
+ }
484
+ const filePath = docFiles[params.name]
485
+ if (!filePath) return notFoundResponse(`Unknown doc: ${params.name}. Available: ${Object.keys(docFiles).join(", ")}`)
486
+ try {
487
+ const content = yield* Effect.promise(() => fs.readFile(filePath, "utf8"))
488
+ return textResponse(content)
489
+ } catch {
490
+ return notFoundResponse(`Doc file not found: ${params.name}`)
491
+ }
492
+ })),
493
+ )
494
+ .handleRaw("facets", ({ request }) =>
495
+ respondRaw(Effect.gen(function*() {
496
+ const url = requestUrl(request)
497
+ const type = url.searchParams.get("type")
498
+ const field = url.searchParams.get("field")
499
+ if ((type !== "traces" && type !== "logs") || !field) {
500
+ return jsonResponse({ error: "Expected type=traces|logs and field=<name>" }, 400)
501
+ }
502
+ const data = yield* withStore((store) =>
503
+ store.listFacets({
504
+ type,
505
+ field,
506
+ serviceName: url.searchParams.get("service"),
507
+ lookbackMinutes: parseLookbackMinutes(url.searchParams.get("lookback"), config.otel.traceLookbackMinutes),
508
+ limit: parseLimit(url.searchParams.get("limit"), 20),
509
+ }),
510
+ )
511
+ return jsonResponse({ data })
512
+ })),
513
+ )
514
+ .handleRaw("aiCalls", ({ request }) =>
515
+ respondRaw(Effect.gen(function*() {
516
+ const url = requestUrl(request)
517
+ const limit = parseBoundedLimit(url.searchParams.get("limit"), 20, SPAN_MAX_LIMIT)
518
+ const lookbackMinutes = parseBoundedLookbackMinutes(url.searchParams.get("lookback"), TRACE_DEFAULT_LOOKBACK, TRACE_MAX_LOOKBACK)
519
+ const data = yield* withStore((store) =>
520
+ store.searchAiCalls({
521
+ service: url.searchParams.get("service"),
522
+ traceId: url.searchParams.get("traceId"),
523
+ sessionId: url.searchParams.get("sessionId"),
524
+ functionId: url.searchParams.get("functionId"),
525
+ provider: url.searchParams.get("provider"),
526
+ model: url.searchParams.get("model"),
527
+ operation: url.searchParams.get("operation"),
528
+ status: (url.searchParams.get("status") as "ok" | "error" | null) ?? null,
529
+ minDurationMs: url.searchParams.get("minDurationMs") ? Number(url.searchParams.get("minDurationMs")) : null,
530
+ text: url.searchParams.get("text"),
531
+ lookbackMinutes,
532
+ limit,
533
+ }),
534
+ )
535
+ return jsonResponse({
536
+ data,
537
+ meta: listMeta({ limit, lookbackMinutes, returned: data.length, truncated: false, nextCursor: null }),
538
+ })
539
+ })),
540
+ )
541
+ .handleRaw("aiCall", ({ params }) =>
542
+ respondRaw(Effect.gen(function*() {
543
+ const data = yield* withStore((store) => store.getAiCall(params.spanId))
544
+ if (!data) return notFoundResponse("AI call not found")
545
+ return jsonResponse({ data })
546
+ })),
547
+ )
548
+ .handleRaw("aiStats", ({ request }) =>
549
+ respondRaw(Effect.gen(function*() {
550
+ const url = requestUrl(request)
551
+ const groupBy = url.searchParams.get("groupBy") as "provider" | "model" | "functionId" | "sessionId" | "status" | null
552
+ const agg = url.searchParams.get("agg") as "count" | "avg_duration" | "p95_duration" | "total_input_tokens" | "total_output_tokens" | null
553
+ if (!groupBy || !agg) {
554
+ return jsonResponse({ error: "Expected groupBy and agg parameters" }, 400)
555
+ }
556
+ const lookbackMinutes = parseBoundedLookbackMinutes(url.searchParams.get("lookback"), TRACE_DEFAULT_LOOKBACK, TRACE_MAX_LOOKBACK)
557
+ const data = yield* withStore((store) =>
558
+ store.aiCallStats({
559
+ groupBy,
560
+ agg,
561
+ service: url.searchParams.get("service"),
562
+ traceId: url.searchParams.get("traceId"),
563
+ sessionId: url.searchParams.get("sessionId"),
564
+ functionId: url.searchParams.get("functionId"),
565
+ provider: url.searchParams.get("provider"),
566
+ model: url.searchParams.get("model"),
567
+ operation: url.searchParams.get("operation"),
568
+ status: (url.searchParams.get("status") as "ok" | "error" | null) ?? null,
569
+ minDurationMs: url.searchParams.get("minDurationMs") ? Number(url.searchParams.get("minDurationMs")) : null,
570
+ lookbackMinutes,
571
+ limit: parseBoundedLimit(url.searchParams.get("limit"), 20, SPAN_MAX_LIMIT),
572
+ }),
573
+ )
574
+ return jsonResponse({ data })
575
+ })),
576
+ )
577
+ .handleRaw("tracePage", ({ params }) =>
578
+ respondRaw(
579
+ Effect.flatMap(withStore((store) => store.getTrace(params.traceId)), (trace) =>
580
+ trace
581
+ ? Effect.map(withStore((store) => store.listTraceLogs(params.traceId)), (logs) => htmlResponse(renderTracePage(trace, logs)))
582
+ : Effect.succeed(notFoundResponse("Trace not found")),
583
+ ),
584
+ ),
585
+ ),
586
+ )
587
+
588
+ const ApiLive = Layer.provideMerge(
589
+ HttpApiBuilder.layer(MotelHttpApi, { openapiPath: "/openapi.json" }).pipe(
590
+ Layer.provide(TelemetryGroupLive),
591
+ Layer.provide(HttpApiScalar.layer(MotelHttpApi, { scalar: { forceDarkModeState: "dark", showOperationId: true } })),
592
+ Layer.provide(HttpServer.layerServices),
593
+ ),
594
+ TelemetryStoreLive,
595
+ )
596
+
597
+ // ---------------------------------------------------------------------------
598
+ // Static file serving for the web UI
599
+ // ---------------------------------------------------------------------------
600
+
601
+ const WEB_DIST_DIR = path.resolve(import.meta.dir, "../web/dist")
602
+ // Only cache `true` — a `false` result is rechecked so a later `web:build` is picked up
603
+ let webUiAvailable = false
604
+
605
+ const isWebUiAvailable = async (): Promise<boolean> => {
606
+ if (webUiAvailable) return true
607
+ try {
608
+ webUiAvailable = await Bun.file(path.join(WEB_DIST_DIR, "index.html")).exists()
609
+ } catch {
610
+ /* ignore */
611
+ }
612
+ return webUiAvailable
613
+ }
614
+
615
+ /** Routes that must always go through the Effect API handler */
616
+ const isStrictApiRoute = (pathname: string) =>
617
+ pathname.startsWith("/api/") ||
618
+ pathname.startsWith("/v1/") ||
619
+ pathname === "/openapi.json" ||
620
+ pathname === "/docs"
621
+
622
+ const serveWebUi = async (request: Request, apiHandler: (req: Request) => Promise<Response>): Promise<Response> => {
623
+ const url = new URL(request.url)
624
+ const pathname = url.pathname
625
+
626
+ // Strict API routes always go through the Effect handler
627
+ if (isStrictApiRoute(pathname)) return apiHandler(request)
628
+
629
+ // Only serve web UI if built
630
+ if (!(await isWebUiAvailable())) return apiHandler(request)
631
+
632
+ // Try to serve a static file from web/dist/ (hashed assets, favicon, etc.)
633
+ if (pathname.startsWith("/assets/") || (pathname !== "/" && pathname.includes("."))) {
634
+ const resolved = path.resolve(WEB_DIST_DIR, pathname.slice(1))
635
+ if (resolved.startsWith(WEB_DIST_DIR) && await Bun.file(resolved).exists()) {
636
+ return new Response(Bun.file(resolved))
637
+ }
638
+ }
639
+
640
+ // SPA fallback: serve index.html for / and all client routes
641
+ return new Response(Bun.file(path.join(WEB_DIST_DIR, "index.html")), {
642
+ headers: { "content-type": "text/html; charset=utf-8" },
643
+ })
644
+ }
645
+
646
+ // ---------------------------------------------------------------------------
647
+ // Server lifecycle
648
+ // ---------------------------------------------------------------------------
649
+
650
+ export const startLocalServer = async () => {
651
+ if (server) return server
652
+ const { handler, dispose } = HttpRouter.toWebHandler(ApiLive, { disableLogger: true })
653
+ disposeWebHandler = dispose
654
+ server = Bun.serve({
655
+ hostname: config.otel.host,
656
+ port: config.otel.port,
657
+ fetch(request) {
658
+ return serveWebUi(request, handler)
659
+ },
660
+ })
661
+ startedAt = new Date().toISOString()
662
+ try {
663
+ writeRegistryEntry({
664
+ pid: process.pid,
665
+ url: resolveBoundUrl(),
666
+ workdir: process.cwd(),
667
+ startedAt,
668
+ version: MOTEL_VERSION,
669
+ })
670
+ } catch (err) {
671
+ console.warn(`motel: failed to write registry entry: ${(err as Error).message}`)
672
+ }
673
+ return server
674
+ }
675
+
676
+ export const ensureLocalServer = async () => {
677
+ if (server) return server
678
+ try {
679
+ const response = await fetch(resolveOtelUrl("/api/health"), { signal: AbortSignal.timeout(250) })
680
+ if (response.ok) return null
681
+ } catch {
682
+ // Start local server below.
683
+ }
684
+ return await startLocalServer()
685
+ }
686
+
687
+ export const stopLocalServer = () => {
688
+ server?.stop(true)
689
+ server = null
690
+ startedAt = null
691
+
692
+ const dispose = disposeWebHandler
693
+ disposeWebHandler = null
694
+ if (dispose) {
695
+ void dispose().catch((err) => {
696
+ console.warn(`motel: failed to dispose web handler: ${(err as Error).message}`)
697
+ })
698
+ }
699
+ }