@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.
- package/AGENTS.md +142 -0
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/package.json +92 -0
- package/src/App.tsx +217 -0
- package/src/cli.ts +258 -0
- package/src/config.ts +39 -0
- package/src/daemon.test.ts +59 -0
- package/src/daemon.ts +398 -0
- package/src/domain.ts +233 -0
- package/src/httpApi.ts +384 -0
- package/src/index.tsx +18 -0
- package/src/instructions.ts +72 -0
- package/src/localServer.ts +699 -0
- package/src/locator.ts +138 -0
- package/src/mcp.ts +260 -0
- package/src/motel.ts +86 -0
- package/src/motelClient.ts +201 -0
- package/src/otlp.ts +142 -0
- package/src/queryFilters.ts +39 -0
- package/src/registry.ts +86 -0
- package/src/runtime.ts +38 -0
- package/src/server.ts +10 -0
- package/src/services/LogQueryService.ts +43 -0
- package/src/services/TelemetryStore.ts +1821 -0
- package/src/services/TraceQueryService.ts +71 -0
- package/src/telemetry.test.ts +726 -0
- package/src/ui/ServiceLogs.tsx +112 -0
- package/src/ui/SpanDetail.tsx +134 -0
- package/src/ui/SpanDetailFull.tsx +224 -0
- package/src/ui/SpanDetailPane.tsx +91 -0
- package/src/ui/TraceDetailsPane.tsx +169 -0
- package/src/ui/TraceList.tsx +128 -0
- package/src/ui/Waterfall.tsx +412 -0
- package/src/ui/app/TraceListPane.tsx +34 -0
- package/src/ui/app/TraceWorkspace.tsx +254 -0
- package/src/ui/app/useAppLayout.ts +79 -0
- package/src/ui/app/useTraceScreenData.ts +411 -0
- package/src/ui/format.ts +119 -0
- package/src/ui/primitives.tsx +170 -0
- package/src/ui/state.ts +137 -0
- package/src/ui/theme.ts +153 -0
- package/src/ui/traceDetailsWidth.repro.test.ts +115 -0
- package/src/ui/traceSortNav.repro.seed.ts +62 -0
- package/src/ui/traceSortNav.repro.test.ts +220 -0
- package/src/ui/useKeyboardNav.ts +532 -0
- package/src/ui/waterfallNav.repro.seed.ts +86 -0
- package/src/ui/waterfallNav.repro.test.ts +263 -0
- package/src/ui/waterfallNav.test.ts +422 -0
- package/src/ui/waterfallNav.ts +75 -0
- package/web/dist/assets/index-BEKIiisE.js +27 -0
- package/web/dist/assets/index-DzuHNBGV.css +2 -0
- 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("&", "&")
|
|
202
|
+
.replaceAll("<", "<")
|
|
203
|
+
.replaceAll(">", ">")
|
|
204
|
+
.replaceAll('"', """)
|
|
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
|
+
}
|