@kitlangton/motel 0.2.0 → 0.2.1

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 (48) hide show
  1. package/AGENTS.md +5 -0
  2. package/package.json +5 -3
  3. package/src/App.tsx +233 -59
  4. package/src/daemon.test.ts +144 -7
  5. package/src/daemon.ts +113 -8
  6. package/src/domain.test.ts +62 -0
  7. package/src/domain.ts +16 -0
  8. package/src/localServer.ts +111 -121
  9. package/src/mcp.ts +172 -0
  10. package/src/motelClient.ts +166 -14
  11. package/src/registry.ts +26 -23
  12. package/src/runtime.ts +8 -2
  13. package/src/server.ts +10 -9
  14. package/src/services/AsyncIngest.ts +52 -0
  15. package/src/services/TelemetryStore.ts +151 -26
  16. package/src/services/TraceQueryService.ts +3 -1
  17. package/src/services/ingestRpc.ts +41 -0
  18. package/src/services/telemetryWorker.ts +62 -0
  19. package/src/storybook/aiChatStory.tsx +243 -0
  20. package/src/storybook/fixtures/errorState.ts +44 -0
  21. package/src/storybook/fixtures/imagePaste.ts +34 -0
  22. package/src/storybook/fixtures/index.ts +62 -0
  23. package/src/storybook/fixtures/kitchenSink.ts +148 -0
  24. package/src/storybook/fixtures/rawPrompt.ts +15 -0
  25. package/src/storybook/fixtures/short.ts +27 -0
  26. package/src/storybook/fixtures/toolHeavy.ts +65 -0
  27. package/src/telemetry.test.ts +28 -0
  28. package/src/ui/AiChatView.tsx +292 -0
  29. package/src/ui/SpanContentView.tsx +181 -0
  30. package/src/ui/SpanDetail.tsx +98 -17
  31. package/src/ui/TraceDetailsPane.tsx +2 -1
  32. package/src/ui/Waterfall.tsx +38 -138
  33. package/src/ui/aiChatModel.test.ts +347 -0
  34. package/src/ui/aiChatModel.ts +736 -0
  35. package/src/ui/aiState.ts +71 -0
  36. package/src/ui/app/TraceWorkspace.tsx +291 -120
  37. package/src/ui/app/useAppLayout.ts +14 -11
  38. package/src/ui/app/useTraceScreenData.ts +173 -39
  39. package/src/ui/atoms.ts +131 -0
  40. package/src/ui/loaders.ts +120 -0
  41. package/src/ui/persistence.ts +41 -0
  42. package/src/ui/primitives.tsx +27 -13
  43. package/src/ui/state.ts +4 -199
  44. package/src/ui/useAttrFilterPicker.ts +63 -23
  45. package/src/ui/useKeyboardNav.ts +552 -364
  46. package/src/ui/waterfallModel.ts +130 -0
  47. package/src/ui/waterfallNav.test.ts +17 -1
  48. package/src/ui/waterfallNav.ts +1 -1
package/src/mcp.ts CHANGED
@@ -13,6 +13,13 @@ const Attributes = Schema.optional(
13
13
  }),
14
14
  )
15
15
 
16
+ const AttributeContains = Schema.optional(
17
+ Schema.Record(Schema.String, Schema.String).annotate({
18
+ description:
19
+ "Case-insensitive substring attribute filters. Key is the attribute name WITHOUT the 'attrContains.' prefix (it is added for you). Values must be strings.",
20
+ }),
21
+ )
22
+
16
23
  const Lookback = Schema.optional(
17
24
  Schema.String.annotate({
18
25
  description:
@@ -42,6 +49,12 @@ const Status = Schema.optional(
42
49
  }),
43
50
  )
44
51
 
52
+ const Severity = Schema.optional(
53
+ Schema.String.annotate({
54
+ description: "Filter by log severity, e.g. TRACE, DEBUG, INFO, WARN, ERROR, FATAL.",
55
+ }),
56
+ )
57
+
45
58
  const StatusTool = Tool.make("motel_status", {
46
59
  description:
47
60
  "Check which motel instance this shim is connected to. Call this FIRST if any other tool errors, to confirm the connection. Returns url, version, workdir, whether the cwd matches, and how many motel instances are running on this machine.",
@@ -123,11 +136,65 @@ const GetTraceLogsTool = Tool.make("motel_get_trace_logs", {
123
136
  success: Schema.Unknown,
124
137
  }).annotate(Tool.Readonly, true)
125
138
 
139
+ const GetTraceSpansTool = Tool.make("motel_get_trace_spans", {
140
+ description:
141
+ "Fetch the flat span list for a specific trace. Use this when you already know the traceId and want to inspect span durations, status, parents, and raw attributes without the nested trace wrapper.",
142
+ parameters: Schema.Struct({
143
+ traceId: Schema.String.annotate({ description: "Full 32-character hex trace ID." }),
144
+ }),
145
+ success: Schema.Unknown,
146
+ }).annotate(Tool.Readonly, true)
147
+
148
+ const SearchSpansTool = Tool.make("motel_search_spans", {
149
+ description:
150
+ "Search spans directly by service, traceId, operation, parentOperation, status, time window, and raw OTel attributes. Use this when traces are too coarse and you need to find the exact span or suspicious operation first.",
151
+ parameters: Schema.Struct({
152
+ service: ServiceParam,
153
+ traceId: Schema.optional(
154
+ Schema.String.annotate({ description: "Scope search to a single trace ID." }),
155
+ ),
156
+ operation: Schema.optional(
157
+ Schema.String.annotate({ description: "Substring match on span operation name." }),
158
+ ),
159
+ parentOperation: Schema.optional(
160
+ Schema.String.annotate({ description: "Substring match on parent operation name." }),
161
+ ),
162
+ status: Status,
163
+ attributes: Attributes,
164
+ attributeContains: AttributeContains,
165
+ lookback: Lookback,
166
+ limit: Limit,
167
+ }),
168
+ success: Schema.Unknown,
169
+ }).annotate(Tool.Readonly, true)
170
+
171
+ const GetSpanTool = Tool.make("motel_get_span", {
172
+ description:
173
+ "Fetch a single span by its 16-character hex spanId. Use this after motel_search_spans to inspect one span's full payload, parent trace, raw tags, and events.",
174
+ parameters: Schema.Struct({
175
+ spanId: Schema.String.annotate({ description: "Full 16-character hex span ID." }),
176
+ }),
177
+ success: Schema.Unknown,
178
+ }).annotate(Tool.Readonly, true)
179
+
180
+ const GetSpanLogsTool = Tool.make("motel_get_span_logs", {
181
+ description:
182
+ "Fetch log records correlated with a specific span. Use this after motel_get_span when you need the exact logs emitted from that one span, not the entire trace.",
183
+ parameters: Schema.Struct({
184
+ spanId: Schema.String.annotate({ description: "Full 16-character hex span ID." }),
185
+ lookback: Lookback,
186
+ limit: Limit,
187
+ cursor: Cursor,
188
+ }),
189
+ success: Schema.Unknown,
190
+ }).annotate(Tool.Readonly, true)
191
+
126
192
  const SearchLogsTool = Tool.make("motel_search_logs", {
127
193
  description:
128
194
  "Search logs by service, trace/span correlation, body substring, time window, and arbitrary OTel attributes. Returns log entries with a nextCursor. For logs tied to a known traceId, prefer motel_get_trace_logs — it is more focused.",
129
195
  parameters: Schema.Struct({
130
196
  service: ServiceParam,
197
+ severity: Severity,
131
198
  traceId: Schema.optional(
132
199
  Schema.String.annotate({ description: "Filter by trace ID." }),
133
200
  ),
@@ -138,6 +205,7 @@ const SearchLogsTool = Tool.make("motel_search_logs", {
138
205
  Schema.String.annotate({ description: "Substring match on log body (case-sensitive)." }),
139
206
  ),
140
207
  attributes: Attributes,
208
+ attributeContains: AttributeContains,
141
209
  lookback: Lookback,
142
210
  limit: Limit,
143
211
  cursor: Cursor,
@@ -145,6 +213,79 @@ const SearchLogsTool = Tool.make("motel_search_logs", {
145
213
  success: Schema.Unknown,
146
214
  }).annotate(Tool.Readonly, true)
147
215
 
216
+ const SearchAiCallsTool = Tool.make("motel_search_ai_calls", {
217
+ description:
218
+ "Search normalized AI calls such as streamText and generateText by session, provider, model, functionId, operation, duration, status, or free-text prompt/response content. Use this for LLM-specific investigations rather than raw span search.",
219
+ parameters: Schema.Struct({
220
+ service: ServiceParam,
221
+ traceId: Schema.optional(Schema.String.annotate({ description: "Filter by trace ID." })),
222
+ sessionId: Schema.optional(Schema.String.annotate({ description: "Filter by normalized AI sessionId." })),
223
+ functionId: Schema.optional(Schema.String.annotate({ description: "Filter by AI functionId, e.g. session.llm." })),
224
+ provider: Schema.optional(Schema.String.annotate({ description: "Filter by provider, e.g. openai.responses." })),
225
+ model: Schema.optional(Schema.String.annotate({ description: "Filter by model ID." })),
226
+ operation: Schema.optional(Schema.String.annotate({ description: "Filter by normalized AI operation, e.g. streamText." })),
227
+ status: Status,
228
+ minDurationMs: Schema.optional(Schema.Number.annotate({ description: "Only return AI calls slower than this (ms)." })),
229
+ text: Schema.optional(Schema.String.annotate({ description: "Case-insensitive substring match across prompt, response, and tool content." })),
230
+ lookback: Lookback,
231
+ limit: Limit,
232
+ }),
233
+ success: Schema.Unknown,
234
+ }).annotate(Tool.Readonly, true)
235
+
236
+ const GetAiCallTool = Tool.make("motel_get_ai_call", {
237
+ description:
238
+ "Fetch the full detail for one AI call by spanId, including complete prompt messages, response payloads, tool calls, token usage, provider metadata, and correlated logs.",
239
+ parameters: Schema.Struct({
240
+ spanId: Schema.String.annotate({ description: "The span ID of the AI call." }),
241
+ }),
242
+ success: Schema.Unknown,
243
+ }).annotate(Tool.Readonly, true)
244
+
245
+ const AiStatsTool = Tool.make("motel_ai_stats", {
246
+ description:
247
+ "Aggregate AI call statistics grouped by provider, model, functionId, sessionId, or status. Use this before paging raw AI calls when you want to understand which models are slowest or which functions consume the most tokens.",
248
+ parameters: Schema.Struct({
249
+ groupBy: Schema.Literals(["provider", "model", "functionId", "sessionId", "status"]),
250
+ agg: Schema.Literals(["count", "avg_duration", "p95_duration", "total_input_tokens", "total_output_tokens"]),
251
+ service: ServiceParam,
252
+ traceId: Schema.optional(Schema.String),
253
+ sessionId: Schema.optional(Schema.String),
254
+ functionId: Schema.optional(Schema.String),
255
+ provider: Schema.optional(Schema.String),
256
+ model: Schema.optional(Schema.String),
257
+ operation: Schema.optional(Schema.String),
258
+ status: Status,
259
+ minDurationMs: Schema.optional(Schema.Number),
260
+ lookback: Lookback,
261
+ limit: Limit,
262
+ }),
263
+ success: Schema.Unknown,
264
+ }).annotate(Tool.Readonly, true)
265
+
266
+ const DocsIndexTool = Tool.make("motel_docs_index", {
267
+ description:
268
+ "List the documentation pages bundled with motel, such as the debug workflow and Effect guide. Use this before motel_get_doc if you are unsure which docs are available.",
269
+ parameters: Tool.EmptyParams,
270
+ success: Schema.Unknown,
271
+ }).annotate(Tool.Readonly, true)
272
+
273
+ const GetDocTool = Tool.make("motel_get_doc", {
274
+ description:
275
+ "Fetch a bundled motel documentation page as markdown text. Useful for giving an agent the exact debug workflow or Effect instrumentation guidance without leaving MCP.",
276
+ parameters: Schema.Struct({
277
+ name: Schema.String.annotate({ description: "Document name, e.g. 'debug' or 'effect'." }),
278
+ }),
279
+ success: Schema.Unknown,
280
+ }).annotate(Tool.Readonly, true)
281
+
282
+ const OpenApiTool = Tool.make("motel_openapi", {
283
+ description:
284
+ "Fetch motel's OpenAPI JSON document. Use this when you need the authoritative HTTP API surface or want to compare MCP coverage against the server routes.",
285
+ parameters: Tool.EmptyParams,
286
+ success: Schema.Unknown,
287
+ }).annotate(Tool.Readonly, true)
288
+
148
289
  const TraceStatsTool = Tool.make("motel_traces_stats", {
149
290
  description:
150
291
  "Aggregate statistics across traces: count, average duration, p95 duration, or error rate, grouped by a field like service, operation, status, or attr.<key>. Use this BEFORE paginating raw traces when you want to understand the shape of the data — for example 'what tools are the slowest' or 'which services are erroring'.",
@@ -189,9 +330,19 @@ const MotelToolkit = Toolkit.make(
189
330
  SearchTracesTool,
190
331
  GetTraceTool,
191
332
  GetTraceLogsTool,
333
+ GetTraceSpansTool,
334
+ SearchSpansTool,
335
+ GetSpanTool,
336
+ GetSpanLogsTool,
192
337
  SearchLogsTool,
338
+ SearchAiCallsTool,
339
+ GetAiCallTool,
340
+ AiStatsTool,
193
341
  TraceStatsTool,
194
342
  LogStatsTool,
343
+ DocsIndexTool,
344
+ GetDocTool,
345
+ OpenApiTool,
195
346
  )
196
347
 
197
348
  const asResult = <A>(effect: Effect.Effect<A, { readonly message: string }>) =>
@@ -234,11 +385,32 @@ const ToolHandlers = MotelToolkit.toLayer(
234
385
  motel_get_trace_logs: ({ traceId, lookback, limit, cursor }) =>
235
386
  asResult(client.getTraceLogs(traceId, { lookback, limit, cursor })),
236
387
 
388
+ motel_get_trace_spans: ({ traceId }) => asResult(client.getTraceSpans(traceId)),
389
+
390
+ motel_search_spans: (input) => asResult(client.searchSpans(input)),
391
+
392
+ motel_get_span: ({ spanId }) => asResult(client.getSpan(spanId)),
393
+
394
+ motel_get_span_logs: ({ spanId, lookback, limit, cursor }) =>
395
+ asResult(client.getSpanLogs(spanId, { lookback, limit, cursor })),
396
+
237
397
  motel_search_logs: (input) => asResult(client.searchLogs(input)),
238
398
 
399
+ motel_search_ai_calls: (input) => asResult(client.searchAiCalls(input)),
400
+
401
+ motel_get_ai_call: ({ spanId }) => asResult(client.getAiCall(spanId)),
402
+
403
+ motel_ai_stats: (input) => asResult(client.aiCallStats(input)),
404
+
239
405
  motel_traces_stats: (input) => asResult(client.traceStats(input)),
240
406
 
241
407
  motel_logs_stats: (input) => asResult(client.logStats(input)),
408
+
409
+ motel_docs_index: () => asResult(client.docs),
410
+
411
+ motel_get_doc: ({ name }) => asResult(Effect.map(client.getDoc(name), (data) => ({ data }))),
412
+
413
+ motel_openapi: () => asResult(client.openapi),
242
414
  }
243
415
  }),
244
416
  )
@@ -13,6 +13,7 @@ export class MotelHttpError extends Error {
13
13
 
14
14
  type QueryValue = string | number | boolean | null | undefined
15
15
  type Query = Readonly<Record<string, QueryValue>>
16
+ type AttributeFilters = Readonly<Record<string, string>>
16
17
 
17
18
  const appendQuery = (url: URL, query: Query | undefined) => {
18
19
  if (!query) return url
@@ -23,14 +24,20 @@ const appendQuery = (url: URL, query: Query | undefined) => {
23
24
  return url
24
25
  }
25
26
 
26
- const appendAttributes = (url: URL, attributes: Readonly<Record<string, string>> | undefined) => {
27
+ const appendAttributes = (url: URL, prefix: "attr" | "attrContains", attributes: AttributeFilters | undefined) => {
27
28
  if (!attributes) return url
28
29
  for (const [key, value] of Object.entries(attributes)) {
29
- url.searchParams.set(`attr.${key}`, value)
30
+ url.searchParams.set(`${prefix}.${key}`, value)
30
31
  }
31
32
  return url
32
33
  }
33
34
 
35
+ const appendAllAttributes = (
36
+ url: URL,
37
+ attributes: AttributeFilters | undefined,
38
+ attributeContains: AttributeFilters | undefined,
39
+ ) => appendAttributes(appendAttributes(url, "attr", attributes), "attrContains", attributeContains)
40
+
34
41
  export type SearchTracesInput = {
35
42
  readonly service?: string
36
43
  readonly operation?: string
@@ -39,18 +46,33 @@ export type SearchTracesInput = {
39
46
  readonly lookback?: string
40
47
  readonly limit?: number
41
48
  readonly cursor?: string
42
- readonly attributes?: Readonly<Record<string, string>>
49
+ readonly attributes?: AttributeFilters
50
+ readonly attributeContains?: AttributeFilters
51
+ }
52
+
53
+ export type SearchSpansInput = {
54
+ readonly service?: string
55
+ readonly traceId?: string
56
+ readonly operation?: string
57
+ readonly parentOperation?: string
58
+ readonly status?: "ok" | "error"
59
+ readonly lookback?: string
60
+ readonly limit?: number
61
+ readonly attributes?: AttributeFilters
62
+ readonly attributeContains?: AttributeFilters
43
63
  }
44
64
 
45
65
  export type SearchLogsInput = {
46
66
  readonly service?: string
67
+ readonly severity?: string
47
68
  readonly traceId?: string
48
69
  readonly spanId?: string
49
70
  readonly body?: string
50
71
  readonly lookback?: string
51
72
  readonly limit?: number
52
73
  readonly cursor?: string
53
- readonly attributes?: Readonly<Record<string, string>>
74
+ readonly attributes?: AttributeFilters
75
+ readonly attributeContains?: AttributeFilters
54
76
  }
55
77
 
56
78
  export type TraceStatsInput = {
@@ -62,7 +84,7 @@ export type TraceStatsInput = {
62
84
  readonly minDurationMs?: number
63
85
  readonly lookback?: string
64
86
  readonly limit?: number
65
- readonly attributes?: Readonly<Record<string, string>>
87
+ readonly attributes?: AttributeFilters
66
88
  }
67
89
 
68
90
  export type LogStatsInput = {
@@ -73,32 +95,77 @@ export type LogStatsInput = {
73
95
  readonly body?: string
74
96
  readonly lookback?: string
75
97
  readonly limit?: number
76
- readonly attributes?: Readonly<Record<string, string>>
98
+ readonly attributes?: AttributeFilters
77
99
  }
78
100
 
79
101
  export type FacetsInput = {
80
102
  readonly type: "traces" | "logs"
81
103
  readonly field: string
104
+ readonly key?: string
82
105
  readonly service?: string
83
106
  readonly lookback?: string
84
107
  readonly limit?: number
85
108
  }
86
109
 
110
+ export type TraceLogOptions = {
111
+ readonly lookback?: string
112
+ readonly limit?: number
113
+ readonly cursor?: string
114
+ }
115
+
116
+ export type AiCallSearchInput = {
117
+ readonly service?: string
118
+ readonly traceId?: string
119
+ readonly sessionId?: string
120
+ readonly functionId?: string
121
+ readonly provider?: string
122
+ readonly model?: string
123
+ readonly operation?: string
124
+ readonly status?: "ok" | "error"
125
+ readonly minDurationMs?: number
126
+ readonly text?: string
127
+ readonly lookback?: string
128
+ readonly limit?: number
129
+ }
130
+
131
+ export type AiCallStatsInput = {
132
+ readonly groupBy: "provider" | "model" | "functionId" | "sessionId" | "status"
133
+ readonly agg: "count" | "avg_duration" | "p95_duration" | "total_input_tokens" | "total_output_tokens"
134
+ readonly service?: string
135
+ readonly traceId?: string
136
+ readonly sessionId?: string
137
+ readonly functionId?: string
138
+ readonly provider?: string
139
+ readonly model?: string
140
+ readonly operation?: string
141
+ readonly status?: "ok" | "error"
142
+ readonly minDurationMs?: number
143
+ readonly lookback?: string
144
+ readonly limit?: number
145
+ }
146
+
87
147
  export class MotelClient extends Context.Service<
88
148
  MotelClient,
89
149
  {
90
150
  readonly searchTraces: (input: SearchTracesInput) => Effect.Effect<unknown, MotelHttpError>
151
+ readonly searchSpans: (input: SearchSpansInput) => Effect.Effect<unknown, MotelHttpError>
91
152
  readonly getTrace: (traceId: string) => Effect.Effect<unknown, MotelHttpError>
92
- readonly getTraceLogs: (
93
- traceId: string,
94
- options: { readonly lookback?: string; readonly limit?: number; readonly cursor?: string },
95
- ) => Effect.Effect<unknown, MotelHttpError>
153
+ readonly getTraceSpans: (traceId: string) => Effect.Effect<unknown, MotelHttpError>
154
+ readonly getTraceLogs: (traceId: string, options: TraceLogOptions) => Effect.Effect<unknown, MotelHttpError>
155
+ readonly getSpan: (spanId: string) => Effect.Effect<unknown, MotelHttpError>
156
+ readonly getSpanLogs: (spanId: string, options: TraceLogOptions) => Effect.Effect<unknown, MotelHttpError>
96
157
  readonly searchLogs: (input: SearchLogsInput) => Effect.Effect<unknown, MotelHttpError>
158
+ readonly searchAiCalls: (input: AiCallSearchInput) => Effect.Effect<unknown, MotelHttpError>
159
+ readonly getAiCall: (spanId: string) => Effect.Effect<unknown, MotelHttpError>
160
+ readonly aiCallStats: (input: AiCallStatsInput) => Effect.Effect<unknown, MotelHttpError>
97
161
  readonly traceStats: (input: TraceStatsInput) => Effect.Effect<unknown, MotelHttpError>
98
162
  readonly logStats: (input: LogStatsInput) => Effect.Effect<unknown, MotelHttpError>
99
163
  readonly facets: (input: FacetsInput) => Effect.Effect<unknown, MotelHttpError>
100
164
  readonly services: Effect.Effect<unknown, MotelHttpError>
101
165
  readonly health: Effect.Effect<unknown, MotelHttpError>
166
+ readonly docs: Effect.Effect<unknown, MotelHttpError>
167
+ readonly getDoc: (name: string) => Effect.Effect<string, MotelHttpError>
168
+ readonly openapi: Effect.Effect<unknown, MotelHttpError>
102
169
  }
103
170
  >()("motel/MotelClient") {}
104
171
 
@@ -107,13 +174,18 @@ export const MotelClientLive = Layer.effect(
107
174
  Effect.gen(function* () {
108
175
  const locator = yield* Locator
109
176
 
110
- const get = <A = unknown>(path: string, query?: Query, attributes?: Readonly<Record<string, string>>) =>
177
+ const get = <A = unknown>(
178
+ path: string,
179
+ query?: Query,
180
+ attributes?: AttributeFilters,
181
+ attributeContains?: AttributeFilters,
182
+ ) =>
111
183
  Effect.gen(function* () {
112
184
  const { url } = yield* Effect.mapError(
113
185
  locator.resolve,
114
186
  (err) => new MotelHttpError(0, err.message),
115
187
  )
116
- const target = appendAttributes(appendQuery(new URL(path, url), query), attributes)
188
+ const target = appendAllAttributes(appendQuery(new URL(path, url), query), attributes, attributeContains)
117
189
  return yield* Effect.tryPromise({
118
190
  try: async () => {
119
191
  const res = await fetch(target, { signal: AbortSignal.timeout(5000) })
@@ -138,10 +210,23 @@ export const MotelClientLive = Layer.effect(
138
210
  lookback: input.lookback,
139
211
  limit: input.limit,
140
212
  cursor: input.cursor,
141
- }, input.attributes),
213
+ }, input.attributes, input.attributeContains),
214
+
215
+ searchSpans: (input) =>
216
+ get("/api/spans/search", {
217
+ service: input.service,
218
+ traceId: input.traceId,
219
+ operation: input.operation,
220
+ parentOperation: input.parentOperation,
221
+ status: input.status,
222
+ lookback: input.lookback,
223
+ limit: input.limit,
224
+ }, input.attributes, input.attributeContains),
142
225
 
143
226
  getTrace: (traceId) => get(`/api/traces/${encodeURIComponent(traceId)}`),
144
227
 
228
+ getTraceSpans: (traceId) => get(`/api/traces/${encodeURIComponent(traceId)}/spans`),
229
+
145
230
  getTraceLogs: (traceId, options) =>
146
231
  get(`/api/traces/${encodeURIComponent(traceId)}/logs`, {
147
232
  lookback: options.lookback,
@@ -149,16 +234,61 @@ export const MotelClientLive = Layer.effect(
149
234
  cursor: options.cursor,
150
235
  }),
151
236
 
237
+ getSpan: (spanId) => get(`/api/spans/${encodeURIComponent(spanId)}`),
238
+
239
+ getSpanLogs: (spanId, options) =>
240
+ get(`/api/spans/${encodeURIComponent(spanId)}/logs`, {
241
+ lookback: options.lookback,
242
+ limit: options.limit,
243
+ cursor: options.cursor,
244
+ }),
245
+
152
246
  searchLogs: (input) =>
153
247
  get("/api/logs/search", {
154
248
  service: input.service,
249
+ severity: input.severity,
155
250
  traceId: input.traceId,
156
251
  spanId: input.spanId,
157
252
  body: input.body,
158
253
  lookback: input.lookback,
159
254
  limit: input.limit,
160
255
  cursor: input.cursor,
161
- }, input.attributes),
256
+ }, input.attributes, input.attributeContains),
257
+
258
+ searchAiCalls: (input) =>
259
+ get("/api/ai/calls", {
260
+ service: input.service,
261
+ traceId: input.traceId,
262
+ sessionId: input.sessionId,
263
+ functionId: input.functionId,
264
+ provider: input.provider,
265
+ model: input.model,
266
+ operation: input.operation,
267
+ status: input.status,
268
+ minDurationMs: input.minDurationMs,
269
+ text: input.text,
270
+ lookback: input.lookback,
271
+ limit: input.limit,
272
+ }),
273
+
274
+ getAiCall: (spanId) => get(`/api/ai/calls/${encodeURIComponent(spanId)}`),
275
+
276
+ aiCallStats: (input) =>
277
+ get("/api/ai/stats", {
278
+ groupBy: input.groupBy,
279
+ agg: input.agg,
280
+ service: input.service,
281
+ traceId: input.traceId,
282
+ sessionId: input.sessionId,
283
+ functionId: input.functionId,
284
+ provider: input.provider,
285
+ model: input.model,
286
+ operation: input.operation,
287
+ status: input.status,
288
+ minDurationMs: input.minDurationMs,
289
+ lookback: input.lookback,
290
+ limit: input.limit,
291
+ }),
162
292
 
163
293
  traceStats: (input) =>
164
294
  get("/api/traces/stats", {
@@ -188,6 +318,7 @@ export const MotelClientLive = Layer.effect(
188
318
  get("/api/facets", {
189
319
  type: input.type,
190
320
  field: input.field,
321
+ key: input.key,
191
322
  service: input.service,
192
323
  lookback: input.lookback,
193
324
  limit: input.limit,
@@ -196,6 +327,27 @@ export const MotelClientLive = Layer.effect(
196
327
  services: get("/api/services"),
197
328
 
198
329
  health: get("/api/health"),
330
+
331
+ docs: get("/api/docs"),
332
+
333
+ getDoc: (name) =>
334
+ Effect.gen(function* () {
335
+ const { url } = yield* Effect.mapError(locator.resolve, (err) => new MotelHttpError(0, err.message))
336
+ return yield* Effect.tryPromise({
337
+ try: async () => {
338
+ const res = await fetch(new URL(`/api/docs/${encodeURIComponent(name)}`, url), {
339
+ signal: AbortSignal.timeout(5000),
340
+ })
341
+ const body = await res.text()
342
+ if (!res.ok) throw new MotelHttpError(res.status, body)
343
+ return body
344
+ },
345
+ catch: (err) =>
346
+ err instanceof MotelHttpError ? err : new MotelHttpError(0, (err as Error).message),
347
+ }).pipe(Effect.tapError((err) => (err.status === 0 ? locator.invalidate : Effect.void)))
348
+ }),
349
+
350
+ openapi: get("/openapi.json"),
199
351
  }
200
352
  }),
201
353
  )
package/src/registry.ts CHANGED
@@ -16,23 +16,18 @@ export type RegistryEntry = {
16
16
  readonly workdir: string
17
17
  readonly startedAt: string
18
18
  readonly version: string
19
+ /**
20
+ * The SQLite database path the daemon is serving. Optional because
21
+ * older daemon builds omit it; consumers should treat a missing
22
+ * value as "unknown" and fall back to whatever validation path
23
+ * they would have used before this field existed (typically an
24
+ * HTTP /api/health probe).
25
+ */
26
+ readonly databasePath?: string
19
27
  }
20
28
 
21
29
  const entryPath = (pid: number) => path.join(registryDir(), `${pid}.json`)
22
30
 
23
- let currentEntryPath: string | null = null
24
- let signalHandlersRegistered = false
25
-
26
- const cleanup = () => {
27
- if (!currentEntryPath) return
28
- try {
29
- fs.unlinkSync(currentEntryPath)
30
- } catch {
31
- // already gone — ignore
32
- }
33
- currentEntryPath = null
34
- }
35
-
36
31
  export const isAlive = (pid: number): boolean => {
37
32
  try {
38
33
  process.kill(pid, 0)
@@ -72,15 +67,23 @@ export const writeRegistryEntry = (entry: RegistryEntry) => {
72
67
  fs.mkdirSync(registryDir(), { recursive: true })
73
68
  const file = entryPath(entry.pid)
74
69
  fs.writeFileSync(file, JSON.stringify(entry, null, 2), "utf8")
75
- currentEntryPath = file
76
- if (!signalHandlersRegistered) {
77
- signalHandlersRegistered = true
78
- process.on("exit", cleanup)
79
- for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
80
- process.on(sig, () => {
81
- cleanup()
82
- process.exit(0)
83
- })
84
- }
70
+ }
71
+
72
+ /**
73
+ * Remove this daemon's registry entry. Intended to be called from a
74
+ * Layer release so the scope-managed server shutdown removes the entry
75
+ * in the same finalizer chain that stops the socket. Historically this
76
+ * was done via ad-hoc process-signal handlers installed here that ran
77
+ * `process.exit(0)` — which races with the Effect runtime's own SIGINT
78
+ * handling and short-circuits the Bun server's graceful stop. The
79
+ * server (via BunRuntime.runMain) now owns signal handling; registry
80
+ * cleanup rides along on scope release.
81
+ */
82
+ export const removeRegistryEntry = (pid: number) => {
83
+ try {
84
+ fs.unlinkSync(entryPath(pid))
85
+ } catch {
86
+ // Already gone — another cleanup path won the race, or the entry
87
+ // was never written.
85
88
  }
86
89
  }
package/src/runtime.ts CHANGED
@@ -6,7 +6,7 @@ import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base"
6
6
  import { Layer, ManagedRuntime } from "effect"
7
7
  import { config } from "./config.js"
8
8
  import { LogQueryServiceLive } from "./services/LogQueryService.js"
9
- import { TelemetryStoreLive } from "./services/TelemetryStore.js"
9
+ import { TelemetryStoreLive, TelemetryStoreReadonlyLive } from "./services/TelemetryStore.js"
10
10
  import { TraceQueryServiceLive } from "./services/TraceQueryService.js"
11
11
 
12
12
  const telemetryLayer = NodeSdk.layer(() => ({
@@ -30,9 +30,15 @@ const telemetryLayer = NodeSdk.layer(() => ({
30
30
  },
31
31
  }))
32
32
 
33
- const QueryServicesLive = Layer.mergeAll(TraceQueryServiceLive, LogQueryServiceLive).pipe(Layer.provideMerge(TelemetryStoreLive))
33
+ // TUI-side services are readonly — a daemon/worker writer owns the DB
34
+ // lock while ingests are in flight, and trying to grab the write lock
35
+ // for schema init on startup causes "database is locked" on bun dev.
36
+ const QueryServicesLive = Layer.mergeAll(TraceQueryServiceLive, LogQueryServiceLive).pipe(Layer.provideMerge(TelemetryStoreReadonlyLive))
34
37
 
35
38
  const QueryRuntimeLive = config.otel.enabled ? Layer.mergeAll(QueryServicesLive, telemetryLayer) : QueryServicesLive
36
39
 
37
40
  export const queryRuntime = ManagedRuntime.make(QueryRuntimeLive)
41
+ // `storeRuntime` is the full writer runtime, exposed for the telemetry
42
+ // test suite (and any future tooling that needs the ingest side). The
43
+ // TUI itself only consumes `queryRuntime`, which is readonly.
38
44
  export const storeRuntime = ManagedRuntime.make(TelemetryStoreLive)
package/src/server.ts CHANGED
@@ -1,10 +1,11 @@
1
- import { config } from "./config.js"
2
- import { startLocalServer } from "./localServer.js"
1
+ import { BunRuntime } from "@effect/platform-bun"
2
+ import { Layer } from "effect"
3
+ import { ServerLive } from "./localServer.js"
3
4
 
4
- await startLocalServer()
5
-
6
- console.log(`motel local telemetry server listening on ${config.otel.queryUrl}`)
7
-
8
- await new Promise(() => {
9
- // keep process alive
10
- })
5
+ // `BunRuntime.runMain` installs signal handlers that interrupt the root
6
+ // fiber on SIGINT/SIGTERM; `Layer.launch` holds the scope open until
7
+ // then. On interruption the scope closes top-down: RegistryLayer's
8
+ // release removes the daemon's registry entry, BunHttpServer's release
9
+ // calls server.stop(), SQLite connections close — all through layer
10
+ // finalizers instead of ad-hoc process.exit handlers.
11
+ Layer.launch(ServerLive).pipe(BunRuntime.runMain)