@aryasaatvik/motel 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/AGENTS.md +180 -0
  2. package/LICENSE +21 -0
  3. package/README.md +137 -0
  4. package/package.json +119 -0
  5. package/skills/motel-debug/SKILL.md +203 -0
  6. package/skills/motel-debug/references/effect.md +38 -0
  7. package/src/App.tsx +414 -0
  8. package/src/StartupGate.test.ts +80 -0
  9. package/src/StartupGate.tsx +263 -0
  10. package/src/cli.ts +205 -0
  11. package/src/config.ts +45 -0
  12. package/src/daemon.test.ts +547 -0
  13. package/src/daemon.ts +486 -0
  14. package/src/domain.test.ts +62 -0
  15. package/src/domain.ts +291 -0
  16. package/src/httpApi.ts +391 -0
  17. package/src/httpListPolicy.test.ts +76 -0
  18. package/src/httpListPolicy.ts +129 -0
  19. package/src/index.tsx +25 -0
  20. package/src/instructions.ts +72 -0
  21. package/src/launchAgent.test.ts +430 -0
  22. package/src/launchAgent.ts +462 -0
  23. package/src/localServer.ts +568 -0
  24. package/src/locator.ts +138 -0
  25. package/src/mcp.ts +433 -0
  26. package/src/motel.test.ts +176 -0
  27. package/src/motel.ts +88 -0
  28. package/src/motelClient.ts +353 -0
  29. package/src/opentui-jsx.d.ts +11 -0
  30. package/src/otlp.test.ts +65 -0
  31. package/src/otlp.ts +162 -0
  32. package/src/otlpProtobuf.ts +35 -0
  33. package/src/queryFilters.ts +39 -0
  34. package/src/registry.ts +115 -0
  35. package/src/runtime.ts +40 -0
  36. package/src/server.ts +11 -0
  37. package/src/services/AsyncIngest.ts +82 -0
  38. package/src/services/LogQueryService.ts +29 -0
  39. package/src/services/TelemetryQuery.ts +62 -0
  40. package/src/services/TelemetryStore.ts +2554 -0
  41. package/src/services/TraceQueryService.ts +43 -0
  42. package/src/services/ingestRpc.ts +39 -0
  43. package/src/services/queryRpc.ts +15 -0
  44. package/src/services/telemetryQueryWorker.ts +32 -0
  45. package/src/services/telemetryWorker.ts +59 -0
  46. package/src/startupBench.ts +19 -0
  47. package/src/startupLifecycle.ts +57 -0
  48. package/src/storybook/aiChatStory.tsx +244 -0
  49. package/src/storybook/fixtures/errorState.ts +44 -0
  50. package/src/storybook/fixtures/imagePaste.ts +34 -0
  51. package/src/storybook/fixtures/index.ts +62 -0
  52. package/src/storybook/fixtures/kitchenSink.ts +148 -0
  53. package/src/storybook/fixtures/rawPrompt.ts +15 -0
  54. package/src/storybook/fixtures/short.ts +27 -0
  55. package/src/storybook/fixtures/toolHeavy.ts +65 -0
  56. package/src/telemetry.test.ts +1053 -0
  57. package/src/ui/AiChatView.tsx +308 -0
  58. package/src/ui/AttrFilterModal.tsx +120 -0
  59. package/src/ui/ServiceLogs.tsx +115 -0
  60. package/src/ui/SpanContentView.tsx +184 -0
  61. package/src/ui/SpanDetail.tsx +215 -0
  62. package/src/ui/SpanDetailFull.tsx +224 -0
  63. package/src/ui/SpanDetailPane.tsx +90 -0
  64. package/src/ui/TraceDetailsPane.tsx +175 -0
  65. package/src/ui/TraceList.tsx +254 -0
  66. package/src/ui/Waterfall.tsx +389 -0
  67. package/src/ui/aiChatModel.test.ts +391 -0
  68. package/src/ui/aiChatModel.ts +773 -0
  69. package/src/ui/aiState.ts +59 -0
  70. package/src/ui/app/TraceListPane.tsx +39 -0
  71. package/src/ui/app/TraceWorkspace.tsx +452 -0
  72. package/src/ui/app/useAppLayout.ts +101 -0
  73. package/src/ui/app/useTraceScreenData.ts +590 -0
  74. package/src/ui/atoms.ts +131 -0
  75. package/src/ui/cachedLoader.test.ts +23 -0
  76. package/src/ui/cachedLoader.ts +60 -0
  77. package/src/ui/filterParser.test.ts +56 -0
  78. package/src/ui/filterParser.ts +45 -0
  79. package/src/ui/format.ts +128 -0
  80. package/src/ui/loaders.ts +101 -0
  81. package/src/ui/persistence.ts +41 -0
  82. package/src/ui/primitives.tsx +198 -0
  83. package/src/ui/state.ts +6 -0
  84. package/src/ui/theme.ts +160 -0
  85. package/src/ui/traceDetailsWidth.repro.test.ts +126 -0
  86. package/src/ui/traceSortNav.repro.seed.ts +62 -0
  87. package/src/ui/traceSortNav.repro.test.ts +231 -0
  88. package/src/ui/useAttrFilterPicker.ts +89 -0
  89. package/src/ui/useKeyboardNav.ts +1023 -0
  90. package/src/ui/waterfallFilter.test.ts +84 -0
  91. package/src/ui/waterfallFilter.ts +59 -0
  92. package/src/ui/waterfallModel.ts +130 -0
  93. package/src/ui/waterfallNav.repro.seed.ts +86 -0
  94. package/src/ui/waterfallNav.repro.test.ts +271 -0
  95. package/src/ui/waterfallNav.test.ts +453 -0
  96. package/src/ui/waterfallNav.ts +75 -0
  97. package/web/dist/assets/index-B01z9BaO.css +2 -0
  98. package/web/dist/assets/index-CYlBaY9O.js +22 -0
  99. package/web/dist/index.html +13 -0
package/src/httpApi.ts ADDED
@@ -0,0 +1,391 @@
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
+ instanceId: Schema.optionalKey(Schema.String).pipe(Schema.annotateKey({ description: "Managed-daemon instance nonce used for readiness and safe shutdown identity checks." })),
29
+ })
30
+ const IngestTraceResponse = Schema.Struct({ insertedSpans: Schema.Number })
31
+ const IngestLogResponse = Schema.Struct({ insertedLogs: Schema.Number })
32
+ const DocIndex = Schema.Struct({
33
+ docs: Schema.Array(Schema.Struct({
34
+ name: Schema.String.pipe(Schema.annotateKey({ description: "Document identifier used in the URL path" })),
35
+ title: Schema.String.pipe(Schema.annotateKey({ description: "Human-readable title" })),
36
+ path: Schema.String.pipe(Schema.annotateKey({ description: "API path to fetch this document" })),
37
+ })),
38
+ }).annotate({ identifier: "DocIndex" })
39
+ const PlainText = Schema.String.pipe(HttpApiSchema.asText())
40
+ const HtmlText = Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/html" }))
41
+ const TraceSummaryList = Schema.Struct({ data: Schema.Array(TraceSummaryItem), meta: Meta })
42
+ const SpanResponse = Schema.Struct({ data: SpanItem })
43
+ const SpanList = Schema.Struct({ data: Schema.Array(SpanItem) })
44
+ const PaginatedSpanList = Schema.Struct({ data: Schema.Array(SpanItem), meta: Meta })
45
+ const TraceResponse = Schema.Struct({ data: TraceItem })
46
+ const LogList = Schema.Struct({ data: Schema.Array(LogItem), meta: Meta })
47
+ const FacetList = Schema.Struct({ data: Schema.Array(FacetItem) })
48
+ const StatList = Schema.Struct({ data: Schema.Array(StatsItem) })
49
+
50
+ const AiCallList = Schema.Struct({ data: Schema.Array(AiCallSummary), meta: Meta })
51
+ const AiCallDetailResponse = Schema.Struct({ data: AiCallDetail })
52
+
53
+ // Shared query parameter schemas
54
+ const LookbackParam = Schema.optionalKey(Schema.String).pipe(
55
+ Schema.annotateKey({ description: "Time window to look back. Examples: 30m, 1h, 6h, 1d. Default: 90m" }),
56
+ )
57
+ const LimitParam = Schema.optionalKey(Schema.Number).pipe(
58
+ Schema.annotateKey({ description: "Maximum number of results to return" }),
59
+ )
60
+ const CursorParam = Schema.optionalKey(Schema.String).pipe(
61
+ Schema.annotateKey({ description: "Opaque pagination cursor from a previous response" }),
62
+ )
63
+ const ServiceParam = Schema.optionalKey(Schema.String).pipe(
64
+ Schema.annotateKey({ description: "Filter by service name" }),
65
+ )
66
+
67
+ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
68
+ .annotate(OpenApi.Title, "Motel Telemetry API")
69
+ .annotate(OpenApi.Version, "1.0.0")
70
+ .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.")
71
+ .add(
72
+ HttpApiGroup.make("telemetry")
73
+ .annotate(OpenApi.Description, "Query traces, spans, logs, and service metadata from the local telemetry store")
74
+ .add(
75
+ HttpApiEndpoint.get("root", "/", { success: PlainText })
76
+ .annotate(OpenApi.Summary, "Root endpoint")
77
+ .annotate(OpenApi.Description, "Human-readable overview of the local telemetry server routes."),
78
+
79
+ HttpApiEndpoint.get("health", "/api/health", { success: Health })
80
+ .annotate(OpenApi.Summary, "Health check and identity handshake")
81
+ .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."),
82
+
83
+ HttpApiEndpoint.post("ingestTraces", "/v1/traces", {
84
+ payload: Schema.Unknown,
85
+ success: IngestTraceResponse,
86
+ })
87
+ .annotate(OpenApi.Summary, "Ingest OTLP traces")
88
+ .annotate(OpenApi.Description, "Accepts OTLP HTTP trace export requests and stores them in the local SQLite telemetry store."),
89
+
90
+ HttpApiEndpoint.post("ingestLogs", "/v1/logs", {
91
+ payload: Schema.Unknown,
92
+ success: IngestLogResponse,
93
+ })
94
+ .annotate(OpenApi.Summary, "Ingest OTLP logs")
95
+ .annotate(OpenApi.Description, "Accepts OTLP HTTP log export requests and stores them in the local SQLite telemetry store."),
96
+
97
+ HttpApiEndpoint.get("services", "/api/services", { success: ServiceList })
98
+ .annotate(OpenApi.Summary, "List active services")
99
+ .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."),
100
+
101
+ HttpApiEndpoint.get("traces", "/api/traces", {
102
+ query: {
103
+ service: ServiceParam,
104
+ limit: LimitParam,
105
+ lookback: LookbackParam,
106
+ cursor: CursorParam,
107
+ },
108
+ success: TraceSummaryList,
109
+ })
110
+ .annotate(OpenApi.Summary, "List recent traces")
111
+ .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."),
112
+
113
+ HttpApiEndpoint.get("searchTraces", "/api/traces/search", {
114
+ query: {
115
+ service: ServiceParam,
116
+ operation: Schema.optionalKey(Schema.String).pipe(
117
+ Schema.annotateKey({ description: "Substring match against span operation names (case-insensitive)" }),
118
+ ),
119
+ status: Schema.optionalKey(TraceSpanStatus).pipe(
120
+ Schema.annotateKey({ description: "Filter by trace health: 'error' = at least one span errored, 'ok' = no errors" }),
121
+ ),
122
+ minDurationMs: Schema.optionalKey(Schema.Number).pipe(
123
+ Schema.annotateKey({ description: "Only return traces slower than this threshold (milliseconds)" }),
124
+ ),
125
+ aiText: Schema.optionalKey(Schema.String).pipe(
126
+ Schema.annotateKey({ description: "FTS match against AI prompt/response/tool content across all spans in the trace. Tokens are prefix-matched and implicitly AND'd." }),
127
+ ),
128
+ lookback: LookbackParam,
129
+ limit: LimitParam,
130
+ cursor: CursorParam,
131
+ },
132
+ success: TraceSummaryList,
133
+ })
134
+ .annotate(OpenApi.Summary, "Search traces with filters")
135
+ .annotate(OpenApi.Description, "Search compact trace summaries with filters. Use /api/traces/{traceId} for full details. Supports cursor pagination, attr.<key> filters in the query string, and aiText for full-text search across LLM prompt/response content."),
136
+
137
+ HttpApiEndpoint.get("traceStats", "/api/traces/stats", {
138
+ query: {
139
+ groupBy: Schema.String.pipe(Schema.annotateKey({ description: "Grouping field: service, operation, status, or attr.<key>" })),
140
+ agg: Schema.Literals(["count", "avg_duration", "p95_duration", "error_rate"]),
141
+ service: ServiceParam,
142
+ operation: Schema.optionalKey(Schema.String),
143
+ status: Schema.optionalKey(TraceSpanStatus),
144
+ minDurationMs: Schema.optionalKey(Schema.Number),
145
+ lookback: LookbackParam,
146
+ limit: LimitParam,
147
+ },
148
+ success: StatList,
149
+ error: ErrorResponse,
150
+ })
151
+ .annotate(OpenApi.Summary, "Aggregate trace statistics")
152
+ .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>."),
153
+
154
+ HttpApiEndpoint.get("trace", "/api/traces/:traceId", {
155
+ params: {
156
+ traceId: Schema.String.pipe(Schema.annotateKey({ description: "Full 32-character hex trace ID" })),
157
+ },
158
+ success: TraceResponse,
159
+ error: ErrorResponse,
160
+ })
161
+ .annotate(OpenApi.Summary, "Get a single trace")
162
+ .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."),
163
+
164
+ HttpApiEndpoint.get("tracePage", "/trace/:traceId", {
165
+ params: {
166
+ traceId: Schema.String.pipe(Schema.annotateKey({ description: "Full 32-character hex trace ID" })),
167
+ },
168
+ success: HtmlText,
169
+ error: ErrorResponse,
170
+ })
171
+ .annotate(OpenApi.Summary, "Render a browser trace page")
172
+ .annotate(OpenApi.Description, "Renders a simple HTML waterfall/log view for one trace, suitable for opening from the TUI or browser."),
173
+
174
+ HttpApiEndpoint.get("traceLogs", "/api/traces/:traceId/logs", {
175
+ params: {
176
+ traceId: Schema.String.pipe(Schema.annotateKey({ description: "Full 32-character hex trace ID" })),
177
+ },
178
+ query: {
179
+ lookback: LookbackParam,
180
+ limit: LimitParam,
181
+ cursor: CursorParam,
182
+ },
183
+ success: LogList,
184
+ })
185
+ .annotate(OpenApi.Summary, "Get logs for a trace")
186
+ .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."),
187
+
188
+ HttpApiEndpoint.get("traceSpans", "/api/traces/:traceId/spans", {
189
+ params: {
190
+ traceId: Schema.String.pipe(Schema.annotateKey({ description: "Full 32-character hex trace ID" })),
191
+ },
192
+ success: SpanList,
193
+ })
194
+ .annotate(OpenApi.Summary, "List spans for a trace")
195
+ .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."),
196
+
197
+ HttpApiEndpoint.get("span", "/api/spans/:spanId", {
198
+ params: {
199
+ spanId: Schema.String.pipe(Schema.annotateKey({ description: "Full 16-character hex span ID" })),
200
+ },
201
+ success: SpanResponse,
202
+ error: ErrorResponse,
203
+ })
204
+ .annotate(OpenApi.Summary, "Get a single span")
205
+ .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."),
206
+
207
+ HttpApiEndpoint.get("spanLogs", "/api/spans/:spanId/logs", {
208
+ params: {
209
+ spanId: Schema.String.pipe(Schema.annotateKey({ description: "Full 16-character hex span ID" })),
210
+ },
211
+ query: {
212
+ lookback: LookbackParam,
213
+ limit: LimitParam,
214
+ cursor: CursorParam,
215
+ },
216
+ success: LogList,
217
+ })
218
+ .annotate(OpenApi.Summary, "Get logs for a span")
219
+ .annotate(OpenApi.Description, "Returns log records correlated with the given span. Ordered by timestamp descending. Supports cursor pagination and bounded lookback/limit defaults."),
220
+
221
+ HttpApiEndpoint.get("searchSpans", "/api/spans/search", {
222
+ query: {
223
+ service: ServiceParam,
224
+ traceId: Schema.optionalKey(Schema.String).pipe(
225
+ Schema.annotateKey({ description: "Scope search to a single trace" }),
226
+ ),
227
+ operation: Schema.optionalKey(Schema.String),
228
+ parentOperation: Schema.optionalKey(Schema.String),
229
+ status: Schema.optionalKey(TraceSpanStatus),
230
+ lookback: LookbackParam,
231
+ limit: LimitParam,
232
+ },
233
+ success: PaginatedSpanList,
234
+ })
235
+ .annotate(OpenApi.Summary, "Search spans directly")
236
+ .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."),
237
+
238
+ HttpApiEndpoint.get("logs", "/api/logs", {
239
+ query: {
240
+ service: ServiceParam,
241
+ severity: Schema.optionalKey(Schema.String).pipe(
242
+ Schema.annotateKey({ description: "Filter by log severity: TRACE, DEBUG, INFO, WARN, ERROR, FATAL (case-insensitive)" }),
243
+ ),
244
+ traceId: Schema.optionalKey(Schema.String).pipe(
245
+ Schema.annotateKey({ description: "Filter logs by trace ID" }),
246
+ ),
247
+ spanId: Schema.optionalKey(Schema.String).pipe(
248
+ Schema.annotateKey({ description: "Filter logs by span ID" }),
249
+ ),
250
+ body: Schema.optionalKey(Schema.String).pipe(
251
+ Schema.annotateKey({ description: "Substring match against log body (case-insensitive)" }),
252
+ ),
253
+ lookback: LookbackParam,
254
+ limit: LimitParam,
255
+ cursor: CursorParam,
256
+ },
257
+ success: LogList,
258
+ })
259
+ .annotate(OpenApi.Summary, "Search logs")
260
+ .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."),
261
+
262
+ HttpApiEndpoint.get("searchLogs", "/api/logs/search", {
263
+ query: {
264
+ service: ServiceParam,
265
+ severity: Schema.optionalKey(Schema.String).pipe(
266
+ Schema.annotateKey({ description: "Filter by log severity: TRACE, DEBUG, INFO, WARN, ERROR, FATAL (case-insensitive)" }),
267
+ ),
268
+ traceId: Schema.optionalKey(Schema.String),
269
+ spanId: Schema.optionalKey(Schema.String),
270
+ body: Schema.optionalKey(Schema.String),
271
+ lookback: LookbackParam,
272
+ limit: LimitParam,
273
+ cursor: CursorParam,
274
+ },
275
+ success: LogList,
276
+ })
277
+ .annotate(OpenApi.Summary, "Alias for log search")
278
+ .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."),
279
+
280
+ HttpApiEndpoint.get("logStats", "/api/logs/stats", {
281
+ query: {
282
+ groupBy: Schema.String.pipe(Schema.annotateKey({ description: "Grouping field: service, severity, scope, or attr.<key>" })),
283
+ agg: Schema.Literals(["count"]),
284
+ service: ServiceParam,
285
+ traceId: Schema.optionalKey(Schema.String),
286
+ spanId: Schema.optionalKey(Schema.String),
287
+ body: Schema.optionalKey(Schema.String),
288
+ lookback: LookbackParam,
289
+ limit: LimitParam,
290
+ },
291
+ success: StatList,
292
+ error: ErrorResponse,
293
+ })
294
+ .annotate(OpenApi.Summary, "Aggregate log statistics")
295
+ .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."),
296
+
297
+ HttpApiEndpoint.get("docs", "/api/docs", { success: DocIndex })
298
+ .annotate(OpenApi.Summary, "List available documentation")
299
+ .annotate(OpenApi.Description, "Returns an index of available documentation pages. Use GET /api/docs/{name} to fetch the full content of a specific document."),
300
+
301
+ HttpApiEndpoint.get("doc", "/api/docs/:name", {
302
+ params: {
303
+ name: Schema.String.pipe(Schema.annotateKey({ description: "Document name: 'debug' for the debug workflow skill, 'effect' for Effect-specific instrumentation guidance" })),
304
+ },
305
+ success: PlainText,
306
+ error: ErrorResponse,
307
+ })
308
+ .annotate(OpenApi.Summary, "Get a documentation page")
309
+ .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)."),
310
+
311
+ HttpApiEndpoint.get("facets", "/api/facets", {
312
+ query: {
313
+ type: Schema.Literals(["traces", "logs"]).pipe(
314
+ Schema.annotateKey({ description: "Data source to facet: 'traces' facets span columns, 'logs' facets log columns" }),
315
+ ),
316
+ field: Schema.String.pipe(
317
+ Schema.annotateKey({ description: "Column to facet. Traces: service, operation, status, attribute_keys, attribute_values. Logs: service, severity, scope. For attribute_values, also pass key=<attribute-name>." }),
318
+ ),
319
+ key: Schema.optionalKey(Schema.String).pipe(
320
+ Schema.annotateKey({ description: "Attribute key to get values for (required when field=attribute_values)." }),
321
+ ),
322
+ service: ServiceParam,
323
+ lookback: LookbackParam,
324
+ limit: LimitParam,
325
+ },
326
+ success: FacetList,
327
+ error: ErrorResponse,
328
+ })
329
+ .annotate(OpenApi.Summary, "Get facet value counts")
330
+ .annotate(OpenApi.Description, "Returns distinct values and their counts for a given field, useful for discovering what data exists before querying. Examples: ?type=logs&field=severity returns log level distribution; ?type=traces&field=attribute_keys&service=opencode lists top span attribute keys; ?type=traces&field=attribute_values&key=ai.model.id lists values seen for that key."),
331
+
332
+ // AI Call endpoints
333
+ HttpApiEndpoint.get("aiCalls", "/api/ai/calls", {
334
+ query: {
335
+ service: ServiceParam,
336
+ traceId: Schema.optionalKey(Schema.String),
337
+ sessionId: Schema.optionalKey(Schema.String).pipe(Schema.annotateKey({ description: "Filter by ai.telemetry.metadata.sessionId" })),
338
+ functionId: Schema.optionalKey(Schema.String).pipe(Schema.annotateKey({ description: "Filter by ai.telemetry.functionId" })),
339
+ provider: Schema.optionalKey(Schema.String).pipe(Schema.annotateKey({ description: "Filter by ai.model.provider (e.g. openai.responses)" })),
340
+ model: Schema.optionalKey(Schema.String).pipe(Schema.annotateKey({ description: "Filter by ai.model.id (e.g. gpt-5.4)" })),
341
+ operation: Schema.optionalKey(Schema.String).pipe(Schema.annotateKey({ description: "Filter by AI operation: streamText, generateText, streamObject, etc." })),
342
+ status: Schema.optionalKey(TraceSpanStatus),
343
+ minDurationMs: Schema.optionalKey(Schema.Number),
344
+ text: Schema.optionalKey(Schema.String).pipe(Schema.annotateKey({ description: "Case-insensitive substring search across prompt, response, and tool content" })),
345
+ lookback: LookbackParam,
346
+ limit: LimitParam,
347
+ },
348
+ success: AiCallList,
349
+ })
350
+ .annotate(OpenApi.Summary, "Search AI calls")
351
+ .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."),
352
+
353
+ HttpApiEndpoint.get("aiCall", "/api/ai/calls/:spanId", {
354
+ params: {
355
+ spanId: Schema.String.pipe(Schema.annotateKey({ description: "The span ID of the AI call" })),
356
+ },
357
+ success: AiCallDetailResponse,
358
+ error: ErrorResponse,
359
+ })
360
+ .annotate(OpenApi.Summary, "Get AI call detail")
361
+ .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."),
362
+
363
+ HttpApiEndpoint.get("aiStats", "/api/ai/stats", {
364
+ query: {
365
+ groupBy: Schema.Literals(["provider", "model", "functionId", "sessionId", "status"]).pipe(
366
+ Schema.annotateKey({ description: "Group results by this field" }),
367
+ ),
368
+ agg: Schema.Literals(["count", "avg_duration", "p95_duration", "total_input_tokens", "total_output_tokens"]).pipe(
369
+ Schema.annotateKey({ description: "Aggregation function" }),
370
+ ),
371
+ service: ServiceParam,
372
+ traceId: Schema.optionalKey(Schema.String),
373
+ sessionId: Schema.optionalKey(Schema.String),
374
+ functionId: Schema.optionalKey(Schema.String),
375
+ provider: Schema.optionalKey(Schema.String),
376
+ model: Schema.optionalKey(Schema.String),
377
+ operation: Schema.optionalKey(Schema.String),
378
+ status: Schema.optionalKey(TraceSpanStatus),
379
+ minDurationMs: Schema.optionalKey(Schema.Number),
380
+ lookback: LookbackParam,
381
+ limit: LimitParam,
382
+ },
383
+ success: StatList,
384
+ error: ErrorResponse,
385
+ })
386
+ .annotate(OpenApi.Summary, "Aggregate AI call statistics")
387
+ .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."),
388
+ )
389
+ )
390
+
391
+ export const motelOpenApiSpec = OpenApi.fromApi(MotelHttpApi)
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import type { LogItem, TraceSummaryItem } from "./domain.js"
3
+ import { LOG_LIST, LOG_STATS, parseListParams, paginateLogs, paginateSummaries, traceCursorArgs } from "./httpListPolicy.js"
4
+
5
+ const BASE_URL = "http://127.0.0.1:27686"
6
+
7
+ describe("HTTP list policy", () => {
8
+ it("bounds list parameters and extracts attribute filters", () => {
9
+ const params = parseListParams({
10
+ url: "/api/logs?limit=9999&lookback=9d&attr.session.id=abc&attrContains.message=failed",
11
+ }, LOG_LIST, BASE_URL)
12
+
13
+ expect(params.limit).toBe(500)
14
+ expect(params.lookbackMinutes).toBe(24 * 60)
15
+ expect(params.attributeFilters).toEqual({ "session.id": "abc" })
16
+ expect(params.attributeContainsFilters).toEqual({ message: "failed" })
17
+ })
18
+
19
+ it("round-trips a trace cursor through page metadata", () => {
20
+ const traces: readonly TraceSummaryItem[] = [
21
+ {
22
+ traceId: "trace-1",
23
+ serviceName: "api",
24
+ rootOperationName: "GET /first",
25
+ startedAt: new Date(1000),
26
+ isRunning: false,
27
+ durationMs: 2,
28
+ spanCount: 1,
29
+ errorCount: 0,
30
+ warnings: [],
31
+ },
32
+ {
33
+ traceId: "trace-2",
34
+ serviceName: "api",
35
+ rootOperationName: "GET /second",
36
+ startedAt: new Date(900),
37
+ isRunning: false,
38
+ durationMs: 1,
39
+ spanCount: 1,
40
+ errorCount: 0,
41
+ warnings: [],
42
+ },
43
+ ]
44
+
45
+ const page = paginateSummaries(traces, { limit: 1, lookbackMinutes: 60 })
46
+ const parsed = parseListParams({ url: `/api/traces?cursor=${page.meta.nextCursor}` }, LOG_LIST, BASE_URL)
47
+
48
+ expect(page.meta.truncated).toBe(true)
49
+ expect(traceCursorArgs(parsed.cursor)).toEqual({ cursorStartedAtMs: 1000, cursorTraceId: "trace-1" })
50
+ })
51
+
52
+ it("formats log page metadata and emits a cursor", () => {
53
+ const logs: readonly LogItem[] = [{
54
+ id: "12",
55
+ timestamp: new Date(1200),
56
+ serviceName: "api",
57
+ severityText: "INFO",
58
+ body: "ready",
59
+ traceId: null,
60
+ spanId: null,
61
+ scopeName: null,
62
+ attributes: {},
63
+ }]
64
+
65
+ const page = paginateLogs(logs, { limit: 10, lookbackMinutes: 120 })
66
+
67
+ expect(page.meta).toMatchObject({ limit: 10, lookback: "2h", returned: 1, truncated: false })
68
+ expect(page.meta.nextCursor).not.toBeNull()
69
+ })
70
+
71
+ it("keeps aggregate log queries bounded to twenty groups by default", () => {
72
+ const params = parseListParams({ url: "/api/logs/stats?groupBy=service&agg=count" }, LOG_STATS, BASE_URL)
73
+
74
+ expect(params.limit).toBe(20)
75
+ })
76
+ })
@@ -0,0 +1,129 @@
1
+ import type { LogItem, TraceSummaryItem } from "./domain.js"
2
+ import { attributeContainsFiltersFromEntries, attributeFiltersFromEntries } from "./queryFilters.js"
3
+
4
+ type CursorShape =
5
+ | { readonly kind: "trace"; readonly startedAt: number; readonly id: string }
6
+ | { readonly kind: "log"; readonly timestamp: number; readonly id: string }
7
+
8
+ export interface ListBounds {
9
+ readonly defaultLimit: number
10
+ readonly maxLimit: number
11
+ readonly defaultLookback: number
12
+ readonly maxLookback: number
13
+ }
14
+
15
+ export interface ListParams {
16
+ readonly url: URL
17
+ readonly limit: number
18
+ readonly lookbackMinutes: number
19
+ readonly cursor: CursorShape | null
20
+ readonly attributeFilters: Readonly<Record<string, string>>
21
+ readonly attributeContainsFilters: Readonly<Record<string, string>>
22
+ }
23
+
24
+ export const TRACE_LIST: ListBounds = { defaultLimit: 20, maxLimit: 100, defaultLookback: 60, maxLookback: 24 * 60 }
25
+ export const SPAN_LIST: ListBounds = { defaultLimit: 100, maxLimit: 500, defaultLookback: 60, maxLookback: 24 * 60 }
26
+ export const LOG_LIST: ListBounds = { defaultLimit: 100, maxLimit: 500, defaultLookback: 60, maxLookback: 24 * 60 }
27
+ export const AI_LIST: ListBounds = { defaultLimit: 20, maxLimit: 500, defaultLookback: 60, maxLookback: 24 * 60 }
28
+ export const TRACE_STATS: ListBounds = { defaultLimit: 20, maxLimit: 100, defaultLookback: 60, maxLookback: 24 * 60 }
29
+ export const LOG_STATS: ListBounds = { defaultLimit: 20, maxLimit: 500, defaultLookback: 60, maxLookback: 24 * 60 }
30
+
31
+ export const requestUrl = (request: { readonly url: string }, baseUrl: string) => new URL(request.url, baseUrl)
32
+
33
+ const parsePositiveInt = (value: string | undefined, defaultValue: number) => {
34
+ const parsed = Number.parseInt(value ?? "", 10)
35
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultValue
36
+ }
37
+
38
+ export const parseLimit = (value: string | null, fallback: number) => parsePositiveInt(value ?? undefined, fallback)
39
+
40
+ export const parseLookbackMinutes = (value: string | null, fallback: number) => {
41
+ if (!value) return fallback
42
+ const match = value.trim().match(/^(\d+)([mhd])$/i)
43
+ if (!match) return fallback
44
+ const amount = Number.parseInt(match[1] ?? "", 10)
45
+ if (!Number.isFinite(amount) || amount <= 0) return fallback
46
+ const unit = (match[2] ?? "m").toLowerCase()
47
+ if (unit === "d") return amount * 1440
48
+ if (unit === "h") return amount * 60
49
+ return amount
50
+ }
51
+
52
+ const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(value, max))
53
+
54
+ const decodeCursor = (value: string | null): CursorShape | null => {
55
+ if (!value) return null
56
+ try {
57
+ return JSON.parse(Buffer.from(value, "base64url").toString("utf8")) as CursorShape
58
+ } catch {
59
+ return null
60
+ }
61
+ }
62
+
63
+ const encodeCursor = (cursor: CursorShape) => Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url")
64
+
65
+ export const traceCursorArgs = (cursor: CursorShape | null) =>
66
+ cursor?.kind === "trace"
67
+ ? { cursorStartedAtMs: cursor.startedAt, cursorTraceId: cursor.id }
68
+ : {}
69
+
70
+ export const logCursorArgs = (cursor: CursorShape | null) =>
71
+ cursor?.kind === "log"
72
+ ? { cursorTimestampMs: cursor.timestamp, cursorId: cursor.id }
73
+ : {}
74
+
75
+ export const parseListParams = (request: { readonly url: string }, bounds: ListBounds, baseUrl: string): ListParams => {
76
+ const url = requestUrl(request, baseUrl)
77
+ return {
78
+ url,
79
+ limit: clamp(parseLimit(url.searchParams.get("limit"), bounds.defaultLimit), 1, bounds.maxLimit),
80
+ lookbackMinutes: clamp(parseLookbackMinutes(url.searchParams.get("lookback"), bounds.defaultLookback), 1, bounds.maxLookback),
81
+ cursor: decodeCursor(url.searchParams.get("cursor")),
82
+ attributeFilters: attributeFiltersFromEntries(url.searchParams.entries()),
83
+ attributeContainsFilters: attributeContainsFiltersFromEntries(url.searchParams.entries()),
84
+ }
85
+ }
86
+
87
+ const formatLookback = (minutes: number) => {
88
+ if (minutes % 1440 === 0) return `${minutes / 1440}d`
89
+ if (minutes % 60 === 0) return `${minutes / 60}h`
90
+ return `${minutes}m`
91
+ }
92
+
93
+ export const listMeta = (input: { readonly limit: number; readonly lookbackMinutes: number; readonly returned: number; readonly truncated: boolean; readonly nextCursor: string | null }) => ({
94
+ limit: input.limit,
95
+ lookback: formatLookback(input.lookbackMinutes),
96
+ returned: input.returned,
97
+ truncated: input.truncated,
98
+ nextCursor: input.nextCursor,
99
+ })
100
+
101
+ export const paginateSummaries = (summaries: readonly TraceSummaryItem[], options: { readonly limit: number; readonly lookbackMinutes: number }) => {
102
+ const page = summaries.slice(0, options.limit)
103
+ const last = page.at(-1)
104
+ return {
105
+ data: page,
106
+ meta: listMeta({
107
+ limit: options.limit,
108
+ lookbackMinutes: options.lookbackMinutes,
109
+ returned: page.length,
110
+ truncated: summaries.length > page.length,
111
+ nextCursor: last ? encodeCursor({ kind: "trace", startedAt: last.startedAt.getTime(), id: last.traceId }) : null,
112
+ }),
113
+ }
114
+ }
115
+
116
+ export const paginateLogs = (logs: readonly LogItem[], options: { readonly limit: number; readonly lookbackMinutes: number }) => {
117
+ const page = logs.slice(0, options.limit)
118
+ const last = page.at(-1)
119
+ return {
120
+ data: page,
121
+ meta: listMeta({
122
+ limit: options.limit,
123
+ lookbackMinutes: options.lookbackMinutes,
124
+ returned: page.length,
125
+ truncated: logs.length > page.length,
126
+ nextCursor: last ? encodeCursor({ kind: "log", timestamp: last.timestamp.getTime(), id: last.id }) : null,
127
+ }),
128
+ }
129
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,25 @@
1
+ import { RegistryProvider } from "@effect/atom-react"
2
+ import { createCliRenderer } from "@opentui/core"
3
+ import { createRoot } from "@opentui/react"
4
+ import { startupBenchMark } from "./startupBench.js"
5
+ import { StartupGate } from "./StartupGate.js"
6
+
7
+ startupBenchMark("index_module_loaded")
8
+
9
+ const renderer = await createCliRenderer({
10
+ exitOnCtrlC: false,
11
+ screenMode: "alternate-screen",
12
+ onDestroy: () => {
13
+ process.exit(0)
14
+ },
15
+ })
16
+
17
+ startupBenchMark("renderer_ready")
18
+
19
+ createRoot(renderer).render(
20
+ <RegistryProvider>
21
+ <StartupGate />
22
+ </RegistryProvider>,
23
+ )
24
+
25
+ startupBenchMark("root_render_called")