@kitlangton/motel 0.1.3 → 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.
- package/AGENTS.md +11 -1
- package/package.json +5 -3
- package/src/App.tsx +239 -59
- package/src/daemon.test.ts +144 -7
- package/src/daemon.ts +113 -8
- package/src/domain.test.ts +62 -0
- package/src/domain.ts +62 -4
- package/src/httpApi.ts +4 -1
- package/src/localServer.ts +112 -121
- package/src/mcp.ts +172 -0
- package/src/motelClient.ts +166 -14
- package/src/registry.ts +26 -23
- package/src/runtime.ts +8 -2
- package/src/server.ts +10 -9
- package/src/services/AsyncIngest.ts +52 -0
- package/src/services/TelemetryStore.ts +285 -27
- package/src/services/TraceQueryService.ts +4 -2
- package/src/services/ingestRpc.ts +41 -0
- package/src/services/telemetryWorker.ts +62 -0
- package/src/storybook/aiChatStory.tsx +243 -0
- package/src/storybook/fixtures/errorState.ts +44 -0
- package/src/storybook/fixtures/imagePaste.ts +34 -0
- package/src/storybook/fixtures/index.ts +62 -0
- package/src/storybook/fixtures/kitchenSink.ts +148 -0
- package/src/storybook/fixtures/rawPrompt.ts +15 -0
- package/src/storybook/fixtures/short.ts +27 -0
- package/src/storybook/fixtures/toolHeavy.ts +65 -0
- package/src/telemetry.test.ts +61 -0
- package/src/ui/AiChatView.tsx +292 -0
- package/src/ui/SpanContentView.tsx +181 -0
- package/src/ui/SpanDetail.tsx +98 -17
- package/src/ui/TraceDetailsPane.tsx +35 -3
- package/src/ui/Waterfall.tsx +94 -167
- package/src/ui/aiChatModel.test.ts +347 -0
- package/src/ui/aiChatModel.ts +736 -0
- package/src/ui/aiState.ts +71 -0
- package/src/ui/app/TraceWorkspace.tsx +295 -120
- package/src/ui/app/useAppLayout.ts +14 -11
- package/src/ui/app/useTraceScreenData.ts +191 -35
- package/src/ui/atoms.ts +131 -0
- package/src/ui/filterParser.test.ts +56 -0
- package/src/ui/filterParser.ts +45 -0
- package/src/ui/loaders.ts +120 -0
- package/src/ui/persistence.ts +41 -0
- package/src/ui/primitives.tsx +47 -21
- package/src/ui/state.ts +4 -169
- package/src/ui/useAttrFilterPicker.ts +63 -23
- package/src/ui/useKeyboardNav.ts +576 -300
- package/src/ui/waterfallFilter.test.ts +84 -0
- package/src/ui/waterfallFilter.ts +59 -0
- package/src/ui/waterfallModel.ts +130 -0
- package/src/ui/waterfallNav.test.ts +17 -1
- package/src/ui/waterfallNav.ts +1 -1
- package/web/dist/assets/{index-DKinj-OE.js → index-DnyVo03x.js} +1 -1
- package/web/dist/index.html +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
|
)
|
package/src/motelClient.ts
CHANGED
|
@@ -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,
|
|
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(
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
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>(
|
|
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 =
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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 {
|
|
2
|
-
import {
|
|
1
|
+
import { BunRuntime } from "@effect/platform-bun"
|
|
2
|
+
import { Layer } from "effect"
|
|
3
|
+
import { ServerLive } from "./localServer.js"
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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)
|