@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
package/src/httpApi.ts ADDED
@@ -0,0 +1,384 @@
1
+ import { Schema } from "effect"
2
+ import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
3
+ import {
4
+ AiCallSummary, AiCallDetail,
5
+ FacetItem, StatsItem, TraceSpanStatus,
6
+ TraceItem, TraceSummaryItem, SpanItem, LogItem,
7
+ } from "./domain.js"
8
+
9
+ const ErrorResponse = Schema.Struct({ error: Schema.String })
10
+ const Meta = Schema.Struct({
11
+ limit: Schema.Number,
12
+ lookback: Schema.String,
13
+ returned: Schema.Number,
14
+ truncated: Schema.Boolean,
15
+ nextCursor: Schema.NullOr(Schema.String),
16
+ }).annotate({ identifier: "ListMeta" })
17
+
18
+ const ServiceList = Schema.Struct({ data: Schema.Array(Schema.String) })
19
+ const Health = Schema.Struct({
20
+ ok: Schema.Boolean,
21
+ service: Schema.String.pipe(Schema.annotateKey({ description: "Stable identity string. Always 'motel-local-server' — used by the MCP shim to detect impostor processes on a stale port." })),
22
+ databasePath: Schema.String,
23
+ pid: Schema.Number.pipe(Schema.annotateKey({ description: "Process ID of this motel instance. Used by the MCP shim to verify a registry entry points at the expected process." })),
24
+ url: Schema.String.pipe(Schema.annotateKey({ description: "Base URL this instance is actually bound to, including the dynamically-chosen port." })),
25
+ workdir: Schema.String.pipe(Schema.annotateKey({ description: "Working directory at the time the server started. Used by MCP discovery to match the current project via longest-prefix." })),
26
+ startedAt: Schema.String.pipe(Schema.annotateKey({ description: "ISO 8601 timestamp of when the server bound its port." })),
27
+ version: Schema.String.pipe(Schema.annotateKey({ description: "Motel version string." })),
28
+ })
29
+ const IngestTraceResponse = Schema.Struct({ insertedSpans: Schema.Number })
30
+ const IngestLogResponse = Schema.Struct({ insertedLogs: Schema.Number })
31
+ const DocIndex = Schema.Struct({
32
+ docs: Schema.Array(Schema.Struct({
33
+ name: Schema.String.pipe(Schema.annotateKey({ description: "Document identifier used in the URL path" })),
34
+ title: Schema.String.pipe(Schema.annotateKey({ description: "Human-readable title" })),
35
+ path: Schema.String.pipe(Schema.annotateKey({ description: "API path to fetch this document" })),
36
+ })),
37
+ }).annotate({ identifier: "DocIndex" })
38
+ const PlainText = Schema.String.pipe(HttpApiSchema.asText())
39
+ const HtmlText = Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/html" }))
40
+ const TraceSummaryList = Schema.Struct({ data: Schema.Array(TraceSummaryItem), meta: Meta })
41
+ const SpanResponse = Schema.Struct({ data: SpanItem })
42
+ const SpanList = Schema.Struct({ data: Schema.Array(SpanItem) })
43
+ const PaginatedSpanList = Schema.Struct({ data: Schema.Array(SpanItem), meta: Meta })
44
+ const TraceResponse = Schema.Struct({ data: TraceItem })
45
+ const LogList = Schema.Struct({ data: Schema.Array(LogItem), meta: Meta })
46
+ const FacetList = Schema.Struct({ data: Schema.Array(FacetItem) })
47
+ const StatList = Schema.Struct({ data: Schema.Array(StatsItem) })
48
+
49
+ const AiCallList = Schema.Struct({ data: Schema.Array(AiCallSummary), meta: Meta })
50
+ const AiCallDetailResponse = Schema.Struct({ data: AiCallDetail })
51
+
52
+ // Shared query parameter schemas
53
+ const LookbackParam = Schema.optionalKey(Schema.String).pipe(
54
+ Schema.annotateKey({ description: "Time window to look back. Examples: 30m, 1h, 6h, 1d. Default: 90m" }),
55
+ )
56
+ const LimitParam = Schema.optionalKey(Schema.Number).pipe(
57
+ Schema.annotateKey({ description: "Maximum number of results to return" }),
58
+ )
59
+ const CursorParam = Schema.optionalKey(Schema.String).pipe(
60
+ Schema.annotateKey({ description: "Opaque pagination cursor from a previous response" }),
61
+ )
62
+ const ServiceParam = Schema.optionalKey(Schema.String).pipe(
63
+ Schema.annotateKey({ description: "Filter by service name" }),
64
+ )
65
+
66
+ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
67
+ .annotate(OpenApi.Title, "Motel Telemetry API")
68
+ .annotate(OpenApi.Version, "1.0.0")
69
+ .annotate(OpenApi.Description, "Local OpenTelemetry ingest, query, and debugging API. Accepts OTLP HTTP traces and logs, stores them in SQLite, and exposes query endpoints for TUI, CLI, and agent consumption.")
70
+ .add(
71
+ HttpApiGroup.make("telemetry")
72
+ .annotate(OpenApi.Description, "Query traces, spans, logs, and service metadata from the local telemetry store")
73
+ .add(
74
+ HttpApiEndpoint.get("root", "/", { success: PlainText })
75
+ .annotate(OpenApi.Summary, "Root endpoint")
76
+ .annotate(OpenApi.Description, "Human-readable overview of the local telemetry server routes."),
77
+
78
+ HttpApiEndpoint.get("health", "/api/health", { success: Health })
79
+ .annotate(OpenApi.Summary, "Health check and identity handshake")
80
+ .annotate(OpenApi.Description, "Returns liveness plus identity fields (pid, url, workdir, startedAt, version). Doubles as the MCP discovery handshake: clients compare the returned pid against a registry entry to detect stale registrations that now point at an impostor process on the same port."),
81
+
82
+ HttpApiEndpoint.post("ingestTraces", "/v1/traces", {
83
+ payload: Schema.Unknown,
84
+ success: IngestTraceResponse,
85
+ })
86
+ .annotate(OpenApi.Summary, "Ingest OTLP traces")
87
+ .annotate(OpenApi.Description, "Accepts OTLP HTTP trace export requests and stores them in the local SQLite telemetry store."),
88
+
89
+ HttpApiEndpoint.post("ingestLogs", "/v1/logs", {
90
+ payload: Schema.Unknown,
91
+ success: IngestLogResponse,
92
+ })
93
+ .annotate(OpenApi.Summary, "Ingest OTLP logs")
94
+ .annotate(OpenApi.Description, "Accepts OTLP HTTP log export requests and stores them in the local SQLite telemetry store."),
95
+
96
+ HttpApiEndpoint.get("services", "/api/services", { success: ServiceList })
97
+ .annotate(OpenApi.Summary, "List active services")
98
+ .annotate(OpenApi.Description, "Returns service names that have emitted spans or logs within the default lookback window. Use this to discover what services are reporting, then query their traces or logs."),
99
+
100
+ HttpApiEndpoint.get("traces", "/api/traces", {
101
+ query: {
102
+ service: ServiceParam,
103
+ limit: LimitParam,
104
+ lookback: LookbackParam,
105
+ cursor: CursorParam,
106
+ },
107
+ success: TraceSummaryList,
108
+ })
109
+ .annotate(OpenApi.Summary, "List recent traces")
110
+ .annotate(OpenApi.Description, "Returns compact trace summaries ordered by start time descending. Use /api/traces/{traceId} for the full span tree. Supports cursor pagination and applies default/max limit and lookback bounds."),
111
+
112
+ HttpApiEndpoint.get("searchTraces", "/api/traces/search", {
113
+ query: {
114
+ service: ServiceParam,
115
+ operation: Schema.optionalKey(Schema.String).pipe(
116
+ Schema.annotateKey({ description: "Substring match against span operation names (case-insensitive)" }),
117
+ ),
118
+ status: Schema.optionalKey(TraceSpanStatus).pipe(
119
+ Schema.annotateKey({ description: "Filter by trace health: 'error' = at least one span errored, 'ok' = no errors" }),
120
+ ),
121
+ minDurationMs: Schema.optionalKey(Schema.Number).pipe(
122
+ Schema.annotateKey({ description: "Only return traces slower than this threshold (milliseconds)" }),
123
+ ),
124
+ lookback: LookbackParam,
125
+ limit: LimitParam,
126
+ cursor: CursorParam,
127
+ },
128
+ success: TraceSummaryList,
129
+ })
130
+ .annotate(OpenApi.Summary, "Search traces with filters")
131
+ .annotate(OpenApi.Description, "Search compact trace summaries with filters. Use /api/traces/{traceId} for full details. Supports cursor pagination and attr.<key> filters in the query string."),
132
+
133
+ HttpApiEndpoint.get("traceStats", "/api/traces/stats", {
134
+ query: {
135
+ groupBy: Schema.String.pipe(Schema.annotateKey({ description: "Grouping field: service, operation, status, or attr.<key>" })),
136
+ agg: Schema.Literals(["count", "avg_duration", "p95_duration", "error_rate"]),
137
+ service: ServiceParam,
138
+ operation: Schema.optionalKey(Schema.String),
139
+ status: Schema.optionalKey(TraceSpanStatus),
140
+ minDurationMs: Schema.optionalKey(Schema.Number),
141
+ lookback: LookbackParam,
142
+ limit: LimitParam,
143
+ },
144
+ success: StatList,
145
+ error: ErrorResponse,
146
+ })
147
+ .annotate(OpenApi.Summary, "Aggregate trace statistics")
148
+ .annotate(OpenApi.Description, "Returns grouped trace aggregates such as count, average duration, p95 duration, or error rate. Supports the same core filters as trace search plus groupBy dimensions like service, operation, status, and attr.<key>."),
149
+
150
+ HttpApiEndpoint.get("trace", "/api/traces/:traceId", {
151
+ params: {
152
+ traceId: Schema.String.pipe(Schema.annotateKey({ description: "Full 32-character hex trace ID" })),
153
+ },
154
+ success: TraceResponse,
155
+ error: ErrorResponse,
156
+ })
157
+ .annotate(OpenApi.Summary, "Get a single trace")
158
+ .annotate(OpenApi.Description, "Returns the full trace with all spans ordered by parent-child hierarchy. Returns 404 if the trace ID is not found or has expired."),
159
+
160
+ HttpApiEndpoint.get("tracePage", "/trace/:traceId", {
161
+ params: {
162
+ traceId: Schema.String.pipe(Schema.annotateKey({ description: "Full 32-character hex trace ID" })),
163
+ },
164
+ success: HtmlText,
165
+ error: ErrorResponse,
166
+ })
167
+ .annotate(OpenApi.Summary, "Render a browser trace page")
168
+ .annotate(OpenApi.Description, "Renders a simple HTML waterfall/log view for one trace, suitable for opening from the TUI or browser."),
169
+
170
+ HttpApiEndpoint.get("traceLogs", "/api/traces/:traceId/logs", {
171
+ params: {
172
+ traceId: Schema.String.pipe(Schema.annotateKey({ description: "Full 32-character hex trace ID" })),
173
+ },
174
+ query: {
175
+ lookback: LookbackParam,
176
+ limit: LimitParam,
177
+ cursor: CursorParam,
178
+ },
179
+ success: LogList,
180
+ })
181
+ .annotate(OpenApi.Summary, "Get logs for a trace")
182
+ .annotate(OpenApi.Description, "Returns log records correlated with the given trace, across all spans. Ordered by timestamp descending. Supports cursor pagination and bounded lookback/limit defaults."),
183
+
184
+ HttpApiEndpoint.get("traceSpans", "/api/traces/:traceId/spans", {
185
+ params: {
186
+ traceId: Schema.String.pipe(Schema.annotateKey({ description: "Full 32-character hex trace ID" })),
187
+ },
188
+ success: SpanList,
189
+ })
190
+ .annotate(OpenApi.Summary, "List spans for a trace")
191
+ .annotate(OpenApi.Description, "Returns the flat list of spans for one trace, preserving trace context on each row. Useful for span-level filtering and sorting without traversing the full tree shape."),
192
+
193
+ HttpApiEndpoint.get("span", "/api/spans/:spanId", {
194
+ params: {
195
+ spanId: Schema.String.pipe(Schema.annotateKey({ description: "Full 16-character hex span ID" })),
196
+ },
197
+ success: SpanResponse,
198
+ error: ErrorResponse,
199
+ })
200
+ .annotate(OpenApi.Summary, "Get a single span")
201
+ .annotate(OpenApi.Description, "Returns a span by its ID, including the parent trace ID and root operation name for context. Returns 404 if the span is not found."),
202
+
203
+ HttpApiEndpoint.get("spanLogs", "/api/spans/:spanId/logs", {
204
+ params: {
205
+ spanId: Schema.String.pipe(Schema.annotateKey({ description: "Full 16-character hex span ID" })),
206
+ },
207
+ query: {
208
+ lookback: LookbackParam,
209
+ limit: LimitParam,
210
+ cursor: CursorParam,
211
+ },
212
+ success: LogList,
213
+ })
214
+ .annotate(OpenApi.Summary, "Get logs for a span")
215
+ .annotate(OpenApi.Description, "Returns log records correlated with the given span. Ordered by timestamp descending. Supports cursor pagination and bounded lookback/limit defaults."),
216
+
217
+ HttpApiEndpoint.get("searchSpans", "/api/spans/search", {
218
+ query: {
219
+ service: ServiceParam,
220
+ traceId: Schema.optionalKey(Schema.String).pipe(
221
+ Schema.annotateKey({ description: "Scope search to a single trace" }),
222
+ ),
223
+ operation: Schema.optionalKey(Schema.String),
224
+ parentOperation: Schema.optionalKey(Schema.String),
225
+ status: Schema.optionalKey(TraceSpanStatus),
226
+ lookback: LookbackParam,
227
+ limit: LimitParam,
228
+ },
229
+ success: PaginatedSpanList,
230
+ })
231
+ .annotate(OpenApi.Summary, "Search spans directly")
232
+ .annotate(OpenApi.Description, "Search spans directly instead of root traces. Supports service, traceId, operation, parentOperation, status, lookback, limit, attr.<key>=<value> (exact match), and attrContains.<key>=<substring> (case-insensitive substring search inside attribute values) in the query string."),
233
+
234
+ HttpApiEndpoint.get("logs", "/api/logs", {
235
+ query: {
236
+ service: ServiceParam,
237
+ severity: Schema.optionalKey(Schema.String).pipe(
238
+ Schema.annotateKey({ description: "Filter by log severity: TRACE, DEBUG, INFO, WARN, ERROR, FATAL (case-insensitive)" }),
239
+ ),
240
+ traceId: Schema.optionalKey(Schema.String).pipe(
241
+ Schema.annotateKey({ description: "Filter logs by trace ID" }),
242
+ ),
243
+ spanId: Schema.optionalKey(Schema.String).pipe(
244
+ Schema.annotateKey({ description: "Filter logs by span ID" }),
245
+ ),
246
+ body: Schema.optionalKey(Schema.String).pipe(
247
+ Schema.annotateKey({ description: "Substring match against log body (case-insensitive)" }),
248
+ ),
249
+ lookback: LookbackParam,
250
+ limit: LimitParam,
251
+ cursor: CursorParam,
252
+ },
253
+ success: LogList,
254
+ })
255
+ .annotate(OpenApi.Summary, "Search logs")
256
+ .annotate(OpenApi.Description, "Search log records by service, severity, trace/span correlation, or body text (case-insensitive). Supports attr.<key>=<value> (exact match) and attrContains.<key>=<substring> (case-insensitive substring) in the query string. Cursor pagination and bounded lookback/limit defaults. Ordered by timestamp descending."),
257
+
258
+ HttpApiEndpoint.get("searchLogs", "/api/logs/search", {
259
+ query: {
260
+ service: ServiceParam,
261
+ severity: Schema.optionalKey(Schema.String).pipe(
262
+ Schema.annotateKey({ description: "Filter by log severity: TRACE, DEBUG, INFO, WARN, ERROR, FATAL (case-insensitive)" }),
263
+ ),
264
+ traceId: Schema.optionalKey(Schema.String),
265
+ spanId: Schema.optionalKey(Schema.String),
266
+ body: Schema.optionalKey(Schema.String),
267
+ lookback: LookbackParam,
268
+ limit: LimitParam,
269
+ cursor: CursorParam,
270
+ },
271
+ success: LogList,
272
+ })
273
+ .annotate(OpenApi.Summary, "Alias for log search")
274
+ .annotate(OpenApi.Description, "Same behavior as GET /api/logs. Exists as an explicit search endpoint for agents and scripts that distinguish list vs search routes."),
275
+
276
+ HttpApiEndpoint.get("logStats", "/api/logs/stats", {
277
+ query: {
278
+ groupBy: Schema.String.pipe(Schema.annotateKey({ description: "Grouping field: service, severity, scope, or attr.<key>" })),
279
+ agg: Schema.Literals(["count"]),
280
+ service: ServiceParam,
281
+ traceId: Schema.optionalKey(Schema.String),
282
+ spanId: Schema.optionalKey(Schema.String),
283
+ body: Schema.optionalKey(Schema.String),
284
+ lookback: LookbackParam,
285
+ limit: LimitParam,
286
+ },
287
+ success: StatList,
288
+ error: ErrorResponse,
289
+ })
290
+ .annotate(OpenApi.Summary, "Aggregate log statistics")
291
+ .annotate(OpenApi.Description, "Returns grouped log counts by fields like severity, service, scope, or attr.<key>. Useful for quickly understanding log distribution before drilling into raw entries."),
292
+
293
+ HttpApiEndpoint.get("docs", "/api/docs", { success: DocIndex })
294
+ .annotate(OpenApi.Summary, "List available documentation")
295
+ .annotate(OpenApi.Description, "Returns an index of available documentation pages. Use GET /api/docs/{name} to fetch the full content of a specific document."),
296
+
297
+ HttpApiEndpoint.get("doc", "/api/docs/:name", {
298
+ params: {
299
+ name: Schema.String.pipe(Schema.annotateKey({ description: "Document name: 'debug' for the debug workflow skill, 'effect' for Effect-specific instrumentation guidance" })),
300
+ },
301
+ success: PlainText,
302
+ error: ErrorResponse,
303
+ })
304
+ .annotate(OpenApi.Summary, "Get a documentation page")
305
+ .annotate(OpenApi.Description, "Returns the full markdown content of a documentation page. Available documents: 'debug' (hypothesis-driven debugging workflow using motel), 'effect' (Effect-specific instrumentation and runtime guidance)."),
306
+
307
+ HttpApiEndpoint.get("facets", "/api/facets", {
308
+ query: {
309
+ type: Schema.Literals(["traces", "logs"]).pipe(
310
+ Schema.annotateKey({ description: "Data source to facet: 'traces' facets span columns, 'logs' facets log columns" }),
311
+ ),
312
+ field: Schema.String.pipe(
313
+ Schema.annotateKey({ description: "Column to facet. Traces: service, operation, status. Logs: service, severity, scope" }),
314
+ ),
315
+ service: ServiceParam,
316
+ lookback: LookbackParam,
317
+ limit: LimitParam,
318
+ },
319
+ success: FacetList,
320
+ error: ErrorResponse,
321
+ })
322
+ .annotate(OpenApi.Summary, "Get facet value counts")
323
+ .annotate(OpenApi.Description, "Returns distinct values and their counts for a given field, useful for discovering what data exists before querying. For example: ?type=logs&field=severity returns the distribution of log levels."),
324
+
325
+ // AI Call endpoints
326
+ HttpApiEndpoint.get("aiCalls", "/api/ai/calls", {
327
+ query: {
328
+ service: ServiceParam,
329
+ traceId: Schema.optionalKey(Schema.String),
330
+ sessionId: Schema.optionalKey(Schema.String).pipe(Schema.annotateKey({ description: "Filter by ai.telemetry.metadata.sessionId" })),
331
+ functionId: Schema.optionalKey(Schema.String).pipe(Schema.annotateKey({ description: "Filter by ai.telemetry.functionId" })),
332
+ provider: Schema.optionalKey(Schema.String).pipe(Schema.annotateKey({ description: "Filter by ai.model.provider (e.g. openai.responses)" })),
333
+ model: Schema.optionalKey(Schema.String).pipe(Schema.annotateKey({ description: "Filter by ai.model.id (e.g. gpt-5.4)" })),
334
+ operation: Schema.optionalKey(Schema.String).pipe(Schema.annotateKey({ description: "Filter by AI operation: streamText, generateText, streamObject, etc." })),
335
+ status: Schema.optionalKey(TraceSpanStatus),
336
+ minDurationMs: Schema.optionalKey(Schema.Number),
337
+ text: Schema.optionalKey(Schema.String).pipe(Schema.annotateKey({ description: "Case-insensitive substring search across prompt, response, and tool content" })),
338
+ lookback: LookbackParam,
339
+ limit: LimitParam,
340
+ },
341
+ success: AiCallList,
342
+ })
343
+ .annotate(OpenApi.Summary, "Search AI calls")
344
+ .annotate(OpenApi.Description, "Search AI SDK calls (streamText, generateText, etc.) with normalized fields. Returns compact summaries with previews — use /api/ai/calls/{spanId} for full prompt/response payloads. Supports filtering by service, session, model, provider, function, operation, status, duration, and free-text search across prompt/response content."),
345
+
346
+ HttpApiEndpoint.get("aiCall", "/api/ai/calls/:spanId", {
347
+ params: {
348
+ spanId: Schema.String.pipe(Schema.annotateKey({ description: "The span ID of the AI call" })),
349
+ },
350
+ success: AiCallDetailResponse,
351
+ error: ErrorResponse,
352
+ })
353
+ .annotate(OpenApi.Summary, "Get AI call detail")
354
+ .annotate(OpenApi.Description, "Returns the full detail of a single AI call including complete prompt messages, response text, tool calls with args, token usage, timing, provider metadata, and correlated logs."),
355
+
356
+ HttpApiEndpoint.get("aiStats", "/api/ai/stats", {
357
+ query: {
358
+ groupBy: Schema.Literals(["provider", "model", "functionId", "sessionId", "status"]).pipe(
359
+ Schema.annotateKey({ description: "Group results by this field" }),
360
+ ),
361
+ agg: Schema.Literals(["count", "avg_duration", "p95_duration", "total_input_tokens", "total_output_tokens"]).pipe(
362
+ Schema.annotateKey({ description: "Aggregation function" }),
363
+ ),
364
+ service: ServiceParam,
365
+ traceId: Schema.optionalKey(Schema.String),
366
+ sessionId: Schema.optionalKey(Schema.String),
367
+ functionId: Schema.optionalKey(Schema.String),
368
+ provider: Schema.optionalKey(Schema.String),
369
+ model: Schema.optionalKey(Schema.String),
370
+ operation: Schema.optionalKey(Schema.String),
371
+ status: Schema.optionalKey(TraceSpanStatus),
372
+ minDurationMs: Schema.optionalKey(Schema.Number),
373
+ lookback: LookbackParam,
374
+ limit: LimitParam,
375
+ },
376
+ success: StatList,
377
+ error: ErrorResponse,
378
+ })
379
+ .annotate(OpenApi.Summary, "Aggregate AI call statistics")
380
+ .annotate(OpenApi.Description, "Returns grouped statistics for AI calls. Supports grouping by provider, model, functionId, sessionId, or status with aggregations: count, avg_duration, p95_duration, total_input_tokens, total_output_tokens."),
381
+ )
382
+ )
383
+
384
+ export const motelOpenApiSpec = OpenApi.fromApi(MotelHttpApi)
package/src/index.tsx ADDED
@@ -0,0 +1,18 @@
1
+ import { RegistryProvider } from "@effect/atom-react"
2
+ import { createCliRenderer } from "@opentui/core"
3
+ import { createRoot } from "@opentui/react"
4
+ import { App } from "./App.js"
5
+
6
+ const renderer = await createCliRenderer({
7
+ exitOnCtrlC: false,
8
+ screenMode: "alternate-screen",
9
+ onDestroy: () => {
10
+ process.exit(0)
11
+ },
12
+ })
13
+
14
+ createRoot(renderer).render(
15
+ <RegistryProvider>
16
+ <App />
17
+ </RegistryProvider>,
18
+ )
@@ -0,0 +1,72 @@
1
+ import { config } from "./config.js"
2
+
3
+ export const otelServerInstructions = () => `Motel is a local OpenTelemetry server for traces and logs. Use it for OTLP/HTTP ingestion and as a runtime evidence loop for debugging.
4
+
5
+ Base URL: ${config.otel.queryUrl}
6
+
7
+ OTLP ingest:
8
+ - Traces: POST ${config.otel.exporterUrl}
9
+ - Logs: POST ${config.otel.logsExporterUrl}
10
+ - Content-Type: application/json
11
+ - No auth required
12
+
13
+ Query endpoints:
14
+ - Health: GET ${config.otel.queryUrl}/api/health
15
+ - Services: GET ${config.otel.queryUrl}/api/services
16
+ - Traces: GET ${config.otel.queryUrl}/api/traces
17
+ - Trace search: GET ${config.otel.queryUrl}/api/traces/search
18
+ - Trace stats: GET ${config.otel.queryUrl}/api/traces/stats
19
+ - Trace spans: GET ${config.otel.queryUrl}/api/traces/<trace-id>/spans
20
+ - Trace logs: GET ${config.otel.queryUrl}/api/traces/<trace-id>/logs
21
+ - Span search: GET ${config.otel.queryUrl}/api/spans/search
22
+ - Span detail: GET ${config.otel.queryUrl}/api/spans/<span-id>
23
+ - Span logs: GET ${config.otel.queryUrl}/api/spans/<span-id>/logs
24
+ - Logs: GET ${config.otel.queryUrl}/api/logs
25
+ - Log search: GET ${config.otel.queryUrl}/api/logs/search
26
+ - Log stats: GET ${config.otel.queryUrl}/api/logs/stats
27
+ - AI calls: GET ${config.otel.queryUrl}/api/ai/calls
28
+ - AI call detail: GET ${config.otel.queryUrl}/api/ai/calls/<span-id>
29
+ - AI stats: GET ${config.otel.queryUrl}/api/ai/stats
30
+ - Facets: GET ${config.otel.queryUrl}/api/facets?type=logs&field=severity
31
+ - OpenAPI: GET ${config.otel.queryUrl}/openapi.json
32
+ - Docs: GET ${config.otel.queryUrl}/api/docs
33
+
34
+ Documentation:
35
+ - Debug workflow: GET ${config.otel.queryUrl}/api/docs/debug
36
+ - Effect guide: GET ${config.otel.queryUrl}/api/docs/effect
37
+
38
+ For full API details, query the OpenAPI spec at ${config.otel.queryUrl}/openapi.json.
39
+ For setup guidance with Effect or other frameworks, query ${config.otel.queryUrl}/api/docs/effect.
40
+
41
+ Debug workflow (hypothesis-driven):
42
+
43
+ 1. Verify motel is running: curl ${config.otel.queryUrl}/api/health
44
+ 2. Generate 3-5 hypotheses about why the bug occurs before touching code.
45
+ 3. Add temporary instrumentation to confirm or reject all hypotheses.
46
+ - Use whatever tracing/logging the codebase already has (spans, structured logs, annotations).
47
+ - Tag every debug point with structured attributes: debug.session, debug.hypothesis, debug.step, debug.label.
48
+ - Wrap every temporary block in markers for cleanup:
49
+ // #region motel debug
50
+ // ... instrumentation ...
51
+ // #endregion motel debug
52
+ 4. Reproduce the issue.
53
+ 5. Query motel for evidence:
54
+ - curl "${config.otel.queryUrl}/api/spans/search?service=<svc>&attr.debug.hypothesis=<id>"
55
+ - curl "${config.otel.queryUrl}/api/spans/search?service=<svc>&traceId=<trace-id>&attrContains.ai.prompt=<phrase>"
56
+ - curl "${config.otel.queryUrl}/api/logs/search?service=<svc>&severity=ERROR&attr.debug.session=<session>"
57
+ - curl "${config.otel.queryUrl}/api/logs/search?service=<svc>&attrContains.debug.label=<substring>"
58
+ 6. Evaluate each hypothesis (CONFIRMED / REJECTED / INCONCLUSIVE) with cited evidence.
59
+ 7. Fix only with runtime evidence. Keep instrumentation during verification.
60
+ 8. Reproduce again to verify the fix with before/after evidence.
61
+ 9. If the fix failed, revert speculative changes, generate new hypotheses, and iterate.
62
+ 10. After verified success, remove all #region motel debug blocks and confirm with git diff.
63
+
64
+ Rules:
65
+ - Never fix without runtime evidence.
66
+ - Do not remove instrumentation before verification succeeds.
67
+ - Do not log secrets, tokens, passwords, or PII.
68
+ - Revert code from rejected hypotheses — do not let unproven changes accumulate.
69
+ - Use attr.<key>=<value> for exact attribute match, attrContains.<key>=<substring> for case-insensitive substring search inside attribute values.
70
+ - /api/logs supports severity=ERROR|WARN|INFO|DEBUG|TRACE and case-insensitive body search.
71
+ - /api/spans/search supports traceId to scope to one trace.
72
+ - List and search responses include meta.nextCursor when more data is available.`