@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.
- package/AGENTS.md +180 -0
- package/LICENSE +21 -0
- package/README.md +137 -0
- package/package.json +119 -0
- package/skills/motel-debug/SKILL.md +203 -0
- package/skills/motel-debug/references/effect.md +38 -0
- package/src/App.tsx +414 -0
- package/src/StartupGate.test.ts +80 -0
- package/src/StartupGate.tsx +263 -0
- package/src/cli.ts +205 -0
- package/src/config.ts +45 -0
- package/src/daemon.test.ts +547 -0
- package/src/daemon.ts +486 -0
- package/src/domain.test.ts +62 -0
- package/src/domain.ts +291 -0
- package/src/httpApi.ts +391 -0
- package/src/httpListPolicy.test.ts +76 -0
- package/src/httpListPolicy.ts +129 -0
- package/src/index.tsx +25 -0
- package/src/instructions.ts +72 -0
- package/src/launchAgent.test.ts +430 -0
- package/src/launchAgent.ts +462 -0
- package/src/localServer.ts +568 -0
- package/src/locator.ts +138 -0
- package/src/mcp.ts +433 -0
- package/src/motel.test.ts +176 -0
- package/src/motel.ts +88 -0
- package/src/motelClient.ts +353 -0
- package/src/opentui-jsx.d.ts +11 -0
- package/src/otlp.test.ts +65 -0
- package/src/otlp.ts +162 -0
- package/src/otlpProtobuf.ts +35 -0
- package/src/queryFilters.ts +39 -0
- package/src/registry.ts +115 -0
- package/src/runtime.ts +40 -0
- package/src/server.ts +11 -0
- package/src/services/AsyncIngest.ts +82 -0
- package/src/services/LogQueryService.ts +29 -0
- package/src/services/TelemetryQuery.ts +62 -0
- package/src/services/TelemetryStore.ts +2554 -0
- package/src/services/TraceQueryService.ts +43 -0
- package/src/services/ingestRpc.ts +39 -0
- package/src/services/queryRpc.ts +15 -0
- package/src/services/telemetryQueryWorker.ts +32 -0
- package/src/services/telemetryWorker.ts +59 -0
- package/src/startupBench.ts +19 -0
- package/src/startupLifecycle.ts +57 -0
- package/src/storybook/aiChatStory.tsx +244 -0
- package/src/storybook/fixtures/errorState.ts +44 -0
- package/src/storybook/fixtures/imagePaste.ts +34 -0
- package/src/storybook/fixtures/index.ts +62 -0
- package/src/storybook/fixtures/kitchenSink.ts +148 -0
- package/src/storybook/fixtures/rawPrompt.ts +15 -0
- package/src/storybook/fixtures/short.ts +27 -0
- package/src/storybook/fixtures/toolHeavy.ts +65 -0
- package/src/telemetry.test.ts +1053 -0
- package/src/ui/AiChatView.tsx +308 -0
- package/src/ui/AttrFilterModal.tsx +120 -0
- package/src/ui/ServiceLogs.tsx +115 -0
- package/src/ui/SpanContentView.tsx +184 -0
- package/src/ui/SpanDetail.tsx +215 -0
- package/src/ui/SpanDetailFull.tsx +224 -0
- package/src/ui/SpanDetailPane.tsx +90 -0
- package/src/ui/TraceDetailsPane.tsx +175 -0
- package/src/ui/TraceList.tsx +254 -0
- package/src/ui/Waterfall.tsx +389 -0
- package/src/ui/aiChatModel.test.ts +391 -0
- package/src/ui/aiChatModel.ts +773 -0
- package/src/ui/aiState.ts +59 -0
- package/src/ui/app/TraceListPane.tsx +39 -0
- package/src/ui/app/TraceWorkspace.tsx +452 -0
- package/src/ui/app/useAppLayout.ts +101 -0
- package/src/ui/app/useTraceScreenData.ts +590 -0
- package/src/ui/atoms.ts +131 -0
- package/src/ui/cachedLoader.test.ts +23 -0
- package/src/ui/cachedLoader.ts +60 -0
- package/src/ui/filterParser.test.ts +56 -0
- package/src/ui/filterParser.ts +45 -0
- package/src/ui/format.ts +128 -0
- package/src/ui/loaders.ts +101 -0
- package/src/ui/persistence.ts +41 -0
- package/src/ui/primitives.tsx +198 -0
- package/src/ui/state.ts +6 -0
- package/src/ui/theme.ts +160 -0
- package/src/ui/traceDetailsWidth.repro.test.ts +126 -0
- package/src/ui/traceSortNav.repro.seed.ts +62 -0
- package/src/ui/traceSortNav.repro.test.ts +231 -0
- package/src/ui/useAttrFilterPicker.ts +89 -0
- package/src/ui/useKeyboardNav.ts +1023 -0
- package/src/ui/waterfallFilter.test.ts +84 -0
- package/src/ui/waterfallFilter.ts +59 -0
- package/src/ui/waterfallModel.ts +130 -0
- package/src/ui/waterfallNav.repro.seed.ts +86 -0
- package/src/ui/waterfallNav.repro.test.ts +271 -0
- package/src/ui/waterfallNav.test.ts +453 -0
- package/src/ui/waterfallNav.ts +75 -0
- package/web/dist/assets/index-B01z9BaO.css +2 -0
- package/web/dist/assets/index-CYlBaY9O.js +22 -0
- 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("&", "&")
|
|
112
|
+
.replaceAll("<", "<")
|
|
113
|
+
.replaceAll(">", ">")
|
|
114
|
+
.replaceAll('"', """)
|
|
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
|
+
)
|