@kitlangton/motel 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +142 -0
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/package.json +92 -0
- package/src/App.tsx +217 -0
- package/src/cli.ts +258 -0
- package/src/config.ts +39 -0
- package/src/daemon.test.ts +59 -0
- package/src/daemon.ts +398 -0
- package/src/domain.ts +233 -0
- package/src/httpApi.ts +384 -0
- package/src/index.tsx +18 -0
- package/src/instructions.ts +72 -0
- package/src/localServer.ts +699 -0
- package/src/locator.ts +138 -0
- package/src/mcp.ts +260 -0
- package/src/motel.ts +86 -0
- package/src/motelClient.ts +201 -0
- package/src/otlp.ts +142 -0
- package/src/queryFilters.ts +39 -0
- package/src/registry.ts +86 -0
- package/src/runtime.ts +38 -0
- package/src/server.ts +10 -0
- package/src/services/LogQueryService.ts +43 -0
- package/src/services/TelemetryStore.ts +1821 -0
- package/src/services/TraceQueryService.ts +71 -0
- package/src/telemetry.test.ts +726 -0
- package/src/ui/ServiceLogs.tsx +112 -0
- package/src/ui/SpanDetail.tsx +134 -0
- package/src/ui/SpanDetailFull.tsx +224 -0
- package/src/ui/SpanDetailPane.tsx +91 -0
- package/src/ui/TraceDetailsPane.tsx +169 -0
- package/src/ui/TraceList.tsx +128 -0
- package/src/ui/Waterfall.tsx +412 -0
- package/src/ui/app/TraceListPane.tsx +34 -0
- package/src/ui/app/TraceWorkspace.tsx +254 -0
- package/src/ui/app/useAppLayout.ts +79 -0
- package/src/ui/app/useTraceScreenData.ts +411 -0
- package/src/ui/format.ts +119 -0
- package/src/ui/primitives.tsx +170 -0
- package/src/ui/state.ts +137 -0
- package/src/ui/theme.ts +153 -0
- package/src/ui/traceDetailsWidth.repro.test.ts +115 -0
- package/src/ui/traceSortNav.repro.seed.ts +62 -0
- package/src/ui/traceSortNav.repro.test.ts +220 -0
- package/src/ui/useKeyboardNav.ts +532 -0
- package/src/ui/waterfallNav.repro.seed.ts +86 -0
- package/src/ui/waterfallNav.repro.test.ts +263 -0
- package/src/ui/waterfallNav.test.ts +422 -0
- package/src/ui/waterfallNav.ts +75 -0
- package/web/dist/assets/index-BEKIiisE.js +27 -0
- package/web/dist/assets/index-DzuHNBGV.css +2 -0
- package/web/dist/index.html +13 -0
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from "bun:test"
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
import { Effect, References } from "effect"
|
|
6
|
+
import { attributeFiltersFromArgs, attributeContainsFiltersFromArgs, isAttributeFilterToken, isAttributeContainsToken } from "./queryFilters.js"
|
|
7
|
+
|
|
8
|
+
describe("motel telemetry store", () => {
|
|
9
|
+
const tempDir = mkdtempSync(join(tmpdir(), "motel-test-"))
|
|
10
|
+
const dbPath = join(tempDir, "telemetry.sqlite")
|
|
11
|
+
let storeRuntime: Awaited<typeof import("./runtime.ts")>["storeRuntime"]
|
|
12
|
+
let TelemetryStore: Awaited<typeof import("./services/TelemetryStore.ts")>["TelemetryStore"]
|
|
13
|
+
let motelOpenApiSpec: Awaited<typeof import("./httpApi.ts")>["motelOpenApiSpec"]
|
|
14
|
+
|
|
15
|
+
beforeAll(async () => {
|
|
16
|
+
process.env.MOTEL_OTEL_DB_PATH = dbPath
|
|
17
|
+
process.env.MOTEL_OTEL_RETENTION_HOURS = "24"
|
|
18
|
+
const suffix = `?test=${Date.now()}`
|
|
19
|
+
;({ storeRuntime } = await import(`./runtime.ts${suffix}`))
|
|
20
|
+
;({ TelemetryStore } = await import(`./services/TelemetryStore.ts${suffix}`))
|
|
21
|
+
;({ motelOpenApiSpec } = await import(`./httpApi.ts${suffix}`))
|
|
22
|
+
|
|
23
|
+
const nowNanos = BigInt(Date.now()) * 1_000_000n
|
|
24
|
+
const oneSecond = 1_000_000_000n
|
|
25
|
+
|
|
26
|
+
const ingest = Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
27
|
+
Effect.flatMap(
|
|
28
|
+
store.ingestTraces({
|
|
29
|
+
resourceSpans: [
|
|
30
|
+
{
|
|
31
|
+
resource: {
|
|
32
|
+
attributes: [
|
|
33
|
+
{ key: "service.name", value: { stringValue: "test-api" } },
|
|
34
|
+
{ key: "deployment.environment.name", value: { stringValue: "local" } },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
scopeSpans: [
|
|
38
|
+
{
|
|
39
|
+
scope: { name: "test-scope" },
|
|
40
|
+
spans: [
|
|
41
|
+
{
|
|
42
|
+
traceId: "trace-1",
|
|
43
|
+
spanId: "root-1",
|
|
44
|
+
name: "SessionProcessor.stream",
|
|
45
|
+
kind: 2,
|
|
46
|
+
startTimeUnixNano: String(nowNanos),
|
|
47
|
+
endTimeUnixNano: String(nowNanos + 4n * oneSecond),
|
|
48
|
+
attributes: [
|
|
49
|
+
{ key: "sessionID", value: { stringValue: "session-1" } },
|
|
50
|
+
{ key: "modelID", value: { stringValue: "gpt-5.4" } },
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
traceId: "trace-1",
|
|
55
|
+
spanId: "child-1",
|
|
56
|
+
parentSpanId: "root-1",
|
|
57
|
+
name: "tool.call",
|
|
58
|
+
kind: 1,
|
|
59
|
+
startTimeUnixNano: String(nowNanos + oneSecond),
|
|
60
|
+
endTimeUnixNano: String(nowNanos + 2n * oneSecond),
|
|
61
|
+
attributes: [
|
|
62
|
+
{ key: "tool", value: { stringValue: "search" } },
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
resource: {
|
|
71
|
+
attributes: [
|
|
72
|
+
{ key: "service.name", value: { stringValue: "test-api" } },
|
|
73
|
+
{ key: "deployment.environment.name", value: { stringValue: "local" } },
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
scopeSpans: [
|
|
77
|
+
{
|
|
78
|
+
scope: { name: "test-scope" },
|
|
79
|
+
spans: [
|
|
80
|
+
{
|
|
81
|
+
traceId: "trace-2",
|
|
82
|
+
spanId: "root-2",
|
|
83
|
+
name: "SessionProcessor.stream",
|
|
84
|
+
kind: 2,
|
|
85
|
+
startTimeUnixNano: String(nowNanos + 10n * oneSecond),
|
|
86
|
+
endTimeUnixNano: String(nowNanos + 12n * oneSecond),
|
|
87
|
+
status: { code: 2 },
|
|
88
|
+
attributes: [
|
|
89
|
+
{ key: "sessionID", value: { stringValue: "session-2" } },
|
|
90
|
+
{ key: "modelID", value: { stringValue: "gpt-5.4" } },
|
|
91
|
+
],
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
}),
|
|
99
|
+
() => store.ingestLogs({
|
|
100
|
+
resourceLogs: [
|
|
101
|
+
{
|
|
102
|
+
resource: { attributes: [{ key: "service.name", value: { stringValue: "test-api" } }] },
|
|
103
|
+
scopeLogs: [
|
|
104
|
+
{
|
|
105
|
+
scope: { name: "app" },
|
|
106
|
+
logRecords: [
|
|
107
|
+
{
|
|
108
|
+
timeUnixNano: String(nowNanos + 500_000_000n),
|
|
109
|
+
severityText: "INFO",
|
|
110
|
+
traceId: "trace-1",
|
|
111
|
+
spanId: "child-1",
|
|
112
|
+
body: { stringValue: "tool call started" },
|
|
113
|
+
attributes: [{ key: "tool", value: { stringValue: "search" } }],
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
timeUnixNano: String(nowNanos + 11n * oneSecond),
|
|
117
|
+
severityText: "ERROR",
|
|
118
|
+
traceId: "trace-2",
|
|
119
|
+
spanId: "root-2",
|
|
120
|
+
body: { stringValue: "stream failed" },
|
|
121
|
+
attributes: [{ key: "tool", value: { stringValue: "none" } }],
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
}),
|
|
129
|
+
).pipe(Effect.flatMap(() => Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
130
|
+
store.ingestTraces({
|
|
131
|
+
resourceSpans: [{
|
|
132
|
+
resource: { attributes: [{ key: "service.name", value: { stringValue: "test-api" } }] },
|
|
133
|
+
scopeSpans: [{
|
|
134
|
+
scope: { name: "ai" },
|
|
135
|
+
spans: [
|
|
136
|
+
{
|
|
137
|
+
traceId: "trace-ai",
|
|
138
|
+
spanId: "ai-stream-1",
|
|
139
|
+
name: "ai.streamText",
|
|
140
|
+
kind: 1,
|
|
141
|
+
startTimeUnixNano: String(nowNanos + 20n * oneSecond),
|
|
142
|
+
endTimeUnixNano: String(nowNanos + 40n * oneSecond),
|
|
143
|
+
attributes: [
|
|
144
|
+
{ key: "ai.operationId", value: { stringValue: "ai.streamText" } },
|
|
145
|
+
{ key: "ai.telemetry.functionId", value: { stringValue: "session.llm" } },
|
|
146
|
+
{ key: "ai.model.provider", value: { stringValue: "openai.responses" } },
|
|
147
|
+
{ key: "ai.model.id", value: { stringValue: "gpt-5.4" } },
|
|
148
|
+
{ key: "ai.telemetry.metadata.sessionId", value: { stringValue: "ses_test123" } },
|
|
149
|
+
{ key: "ai.telemetry.metadata.userId", value: { stringValue: "kit" } },
|
|
150
|
+
{ key: "ai.prompt.messages", value: { stringValue: '[{"role":"user","content":"Tell me a joke about programming"}]' } },
|
|
151
|
+
{ key: "ai.response.text", value: { stringValue: "Why do programmers prefer dark mode? Because light attracts bugs!" } },
|
|
152
|
+
{ key: "ai.response.finishReason", value: { stringValue: "stop" } },
|
|
153
|
+
{ key: "ai.usage.inputTokens", value: { stringValue: "150" } },
|
|
154
|
+
{ key: "ai.usage.outputTokens", value: { stringValue: "42" } },
|
|
155
|
+
{ key: "ai.usage.totalTokens", value: { stringValue: "192" } },
|
|
156
|
+
{ key: "ai.usage.cachedInputTokens", value: { stringValue: "100" } },
|
|
157
|
+
{ key: "ai.response.msToFirstChunk", value: { stringValue: "500.5" } },
|
|
158
|
+
{ key: "ai.response.msToFinish", value: { stringValue: "20000" } },
|
|
159
|
+
],
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
traceId: "trace-ai",
|
|
163
|
+
spanId: "ai-tool-1",
|
|
164
|
+
parentSpanId: "ai-stream-1",
|
|
165
|
+
name: "ai.toolCall",
|
|
166
|
+
kind: 1,
|
|
167
|
+
startTimeUnixNano: String(nowNanos + 25n * oneSecond),
|
|
168
|
+
endTimeUnixNano: String(nowNanos + 26n * oneSecond),
|
|
169
|
+
attributes: [
|
|
170
|
+
{ key: "ai.toolCall.name", value: { stringValue: "bash" } },
|
|
171
|
+
],
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
traceId: "trace-ai",
|
|
175
|
+
spanId: "ai-stream-2",
|
|
176
|
+
name: "ai.generateText",
|
|
177
|
+
kind: 1,
|
|
178
|
+
startTimeUnixNano: String(nowNanos + 50n * oneSecond),
|
|
179
|
+
endTimeUnixNano: String(nowNanos + 55n * oneSecond),
|
|
180
|
+
status: { code: 2 },
|
|
181
|
+
attributes: [
|
|
182
|
+
{ key: "ai.operationId", value: { stringValue: "ai.generateText" } },
|
|
183
|
+
{ key: "ai.telemetry.functionId", value: { stringValue: "session.llm" } },
|
|
184
|
+
{ key: "ai.model.provider", value: { stringValue: "anthropic" } },
|
|
185
|
+
{ key: "ai.model.id", value: { stringValue: "claude-opus-4" } },
|
|
186
|
+
{ key: "ai.telemetry.metadata.sessionId", value: { stringValue: "ses_test456" } },
|
|
187
|
+
{ key: "ai.prompt.messages", value: { stringValue: '[{"role":"user","content":"Summarize this"}]' } },
|
|
188
|
+
{ key: "ai.response.text", value: { stringValue: "Error: rate limited" } },
|
|
189
|
+
{ key: "ai.response.finishReason", value: { stringValue: "error" } },
|
|
190
|
+
{ key: "ai.usage.inputTokens", value: { stringValue: "80" } },
|
|
191
|
+
{ key: "ai.usage.outputTokens", value: { stringValue: "10" } },
|
|
192
|
+
{ key: "ai.usage.totalTokens", value: { stringValue: "90" } },
|
|
193
|
+
],
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
}],
|
|
197
|
+
}],
|
|
198
|
+
}),
|
|
199
|
+
))),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
await storeRuntime.runPromise(ingest.pipe(Effect.provideService(References.MinimumLogLevel, "None")))
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
afterAll(() => {
|
|
206
|
+
rmSync(tempDir, { recursive: true, force: true })
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it("filters traces by attr.* fields", async () => {
|
|
210
|
+
const result = await storeRuntime.runPromise(
|
|
211
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
212
|
+
store.searchTraces({
|
|
213
|
+
serviceName: "test-api",
|
|
214
|
+
attributeFilters: {
|
|
215
|
+
sessionID: "session-1",
|
|
216
|
+
"deployment.environment.name": "local",
|
|
217
|
+
},
|
|
218
|
+
}),
|
|
219
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
expect(result).toHaveLength(1)
|
|
223
|
+
expect(result[0]?.traceId).toBe("trace-1")
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it("looks up a span directly by spanId", async () => {
|
|
227
|
+
const result = await storeRuntime.runPromise(
|
|
228
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) => store.getSpan("child-1")).pipe(
|
|
229
|
+
Effect.provideService(References.MinimumLogLevel, "None"),
|
|
230
|
+
),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
expect(result?.traceId).toBe("trace-1")
|
|
234
|
+
expect(result?.rootOperationName).toBe("SessionProcessor.stream")
|
|
235
|
+
expect(result?.span.operationName).toBe("tool.call")
|
|
236
|
+
expect(result?.span.depth).toBe(1)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it("filters logs by spanId", async () => {
|
|
240
|
+
const result = await storeRuntime.runPromise(
|
|
241
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
242
|
+
store.searchLogs({
|
|
243
|
+
spanId: "child-1",
|
|
244
|
+
}),
|
|
245
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
expect(result).toHaveLength(1)
|
|
249
|
+
expect(result[0]?.body).toBe("tool call started")
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it("searches spans by operation, parent operation, and attr filters", async () => {
|
|
253
|
+
const result = await storeRuntime.runPromise(
|
|
254
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
255
|
+
store.searchSpans({
|
|
256
|
+
serviceName: "test-api",
|
|
257
|
+
operation: "tool.call",
|
|
258
|
+
parentOperation: "SessionProcessor.stream",
|
|
259
|
+
attributeFilters: {
|
|
260
|
+
tool: "search",
|
|
261
|
+
},
|
|
262
|
+
}),
|
|
263
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
expect(result).toHaveLength(1)
|
|
267
|
+
expect(result[0]?.traceId).toBe("trace-1")
|
|
268
|
+
expect(result[0]?.span.operationName).toBe("tool.call")
|
|
269
|
+
expect(result[0]?.parentOperationName).toBe("SessionProcessor.stream")
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it("lists spans for a trace", async () => {
|
|
273
|
+
const result = await storeRuntime.runPromise(
|
|
274
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) => store.listTraceSpans("trace-1")).pipe(
|
|
275
|
+
Effect.provideService(References.MinimumLogLevel, "None"),
|
|
276
|
+
),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
expect(result).toHaveLength(2)
|
|
280
|
+
expect(result[0]?.traceId).toBe("trace-1")
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it("documents the span lookup route in OpenAPI", () => {
|
|
284
|
+
expect(motelOpenApiSpec.paths["/api/spans/{spanId}"]).toBeDefined()
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it("aggregates trace stats by operation", async () => {
|
|
288
|
+
const result = await storeRuntime.runPromise(
|
|
289
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
290
|
+
store.traceStats({
|
|
291
|
+
groupBy: "operation",
|
|
292
|
+
agg: "avg_duration",
|
|
293
|
+
serviceName: "test-api",
|
|
294
|
+
}),
|
|
295
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
expect(result.length).toBeGreaterThanOrEqual(1)
|
|
299
|
+
const sessionOp = result.find((r) => r.group === "SessionProcessor.stream")
|
|
300
|
+
expect(sessionOp).toBeDefined()
|
|
301
|
+
expect(sessionOp?.count).toBe(2)
|
|
302
|
+
expect(sessionOp?.value).toBe(3000)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it("aggregates log stats by severity", async () => {
|
|
306
|
+
const result = await storeRuntime.runPromise(
|
|
307
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
308
|
+
store.logStats({
|
|
309
|
+
groupBy: "severity",
|
|
310
|
+
agg: "count",
|
|
311
|
+
serviceName: "test-api",
|
|
312
|
+
}),
|
|
313
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
expect(result).toHaveLength(2)
|
|
317
|
+
const errorGroup = result.find((r) => r.group === "ERROR")
|
|
318
|
+
const infoGroup = result.find((r) => r.group === "INFO")
|
|
319
|
+
expect(errorGroup?.value).toBe(1)
|
|
320
|
+
expect(infoGroup?.value).toBe(1)
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it("documents the stats routes in OpenAPI", () => {
|
|
324
|
+
expect(motelOpenApiSpec.paths["/api/traces/stats"]).toBeDefined()
|
|
325
|
+
expect(motelOpenApiSpec.paths["/api/logs/stats"]).toBeDefined()
|
|
326
|
+
expect(motelOpenApiSpec.paths["/api/spans/{spanId}/logs"]).toBeDefined()
|
|
327
|
+
expect(motelOpenApiSpec.paths["/api/spans/search"]).toBeDefined()
|
|
328
|
+
expect(motelOpenApiSpec.paths["/api/traces/{traceId}/spans"]).toBeDefined()
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it("lists trace summaries without loading spans", async () => {
|
|
332
|
+
const result = await storeRuntime.runPromise(
|
|
333
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
334
|
+
store.listTraceSummaries(null),
|
|
335
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
expect(result).toHaveLength(3) // trace-1, trace-2, trace-ai
|
|
339
|
+
// Ordered by start time descending — trace-ai is most recent
|
|
340
|
+
expect(result[0]?.traceId).toBe("trace-ai")
|
|
341
|
+
// Summary fields are correct for the original trace
|
|
342
|
+
const trace1 = result.find((r) => r.traceId === "trace-1")
|
|
343
|
+
expect(trace1?.serviceName).toBe("test-api")
|
|
344
|
+
expect(trace1?.rootOperationName).toBe("SessionProcessor.stream")
|
|
345
|
+
expect(trace1?.spanCount).toBe(2)
|
|
346
|
+
expect(trace1?.durationMs).toBe(4000)
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
it("lists trace summaries filtered by service", async () => {
|
|
350
|
+
const result = await storeRuntime.runPromise(
|
|
351
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
352
|
+
store.listTraceSummaries("test-api"),
|
|
353
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
expect(result).toHaveLength(3) // all traces are test-api
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
it("searches trace summaries with status filter", async () => {
|
|
360
|
+
const result = await storeRuntime.runPromise(
|
|
361
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
362
|
+
store.searchTraceSummaries({
|
|
363
|
+
serviceName: "test-api",
|
|
364
|
+
status: "error",
|
|
365
|
+
}),
|
|
366
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
expect(result).toHaveLength(2) // trace-2 and trace-ai (ai.generateText has error status)
|
|
370
|
+
expect(result.some((r) => r.traceId === "trace-2")).toBe(true)
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
it("searches trace summaries with attribute filters", async () => {
|
|
374
|
+
const result = await storeRuntime.runPromise(
|
|
375
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
376
|
+
store.searchTraceSummaries({
|
|
377
|
+
serviceName: "test-api",
|
|
378
|
+
attributeFilters: { sessionID: "session-1" },
|
|
379
|
+
}),
|
|
380
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
expect(result).toHaveLength(1)
|
|
384
|
+
expect(result[0]?.traceId).toBe("trace-1")
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
it("searches trace summaries with operation filter", async () => {
|
|
388
|
+
const result = await storeRuntime.runPromise(
|
|
389
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
390
|
+
store.searchTraceSummaries({
|
|
391
|
+
serviceName: "test-api",
|
|
392
|
+
operation: "tool.call",
|
|
393
|
+
}),
|
|
394
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
expect(result).toHaveLength(1)
|
|
398
|
+
expect(result[0]?.traceId).toBe("trace-1")
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
it("filters logs by severity", async () => {
|
|
402
|
+
const result = await storeRuntime.runPromise(
|
|
403
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
404
|
+
store.searchLogs({ serviceName: "test-api", severity: "ERROR" }),
|
|
405
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
expect(result).toHaveLength(1)
|
|
409
|
+
expect(result[0]?.body).toBe("stream failed")
|
|
410
|
+
expect(result[0]?.severityText).toBe("ERROR")
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
it("filters logs by severity case-insensitively", async () => {
|
|
414
|
+
const result = await storeRuntime.runPromise(
|
|
415
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
416
|
+
store.searchLogs({ serviceName: "test-api", severity: "error" }),
|
|
417
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
expect(result).toHaveLength(1)
|
|
421
|
+
expect(result[0]?.severityText).toBe("ERROR")
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
it("searches log body case-insensitively", async () => {
|
|
425
|
+
const result = await storeRuntime.runPromise(
|
|
426
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
427
|
+
store.searchLogs({ serviceName: "test-api", body: "STREAM FAILED" }),
|
|
428
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
expect(result).toHaveLength(1)
|
|
432
|
+
expect(result[0]?.body).toBe("stream failed")
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
it("searches spans by traceId", async () => {
|
|
436
|
+
const result = await storeRuntime.runPromise(
|
|
437
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
438
|
+
store.searchSpans({ traceId: "trace-1" }),
|
|
439
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
expect(result).toHaveLength(2)
|
|
443
|
+
expect(result.every((s) => s.traceId === "trace-1")).toBe(true)
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
it("searches spans with attrContains substring filter", async () => {
|
|
447
|
+
const result = await storeRuntime.runPromise(
|
|
448
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
449
|
+
store.searchSpans({
|
|
450
|
+
serviceName: "test-api",
|
|
451
|
+
attributeContainsFilters: { sessionID: "session" },
|
|
452
|
+
}),
|
|
453
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
// Both root spans have sessionID containing "session"
|
|
457
|
+
expect(result.length).toBeGreaterThanOrEqual(2)
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
it("searches spans with attrContains case-insensitively", async () => {
|
|
461
|
+
const result = await storeRuntime.runPromise(
|
|
462
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
463
|
+
store.searchSpans({
|
|
464
|
+
serviceName: "test-api",
|
|
465
|
+
attributeContainsFilters: { modelID: "GPT" },
|
|
466
|
+
}),
|
|
467
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
// modelID is "gpt-5.4", searching "GPT" should match case-insensitively
|
|
471
|
+
expect(result.length).toBeGreaterThanOrEqual(2)
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
it("searches logs with attrContains substring filter", async () => {
|
|
475
|
+
const result = await storeRuntime.runPromise(
|
|
476
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
477
|
+
store.searchLogs({
|
|
478
|
+
serviceName: "test-api",
|
|
479
|
+
attributeContainsFilters: { tool: "sea" },
|
|
480
|
+
}),
|
|
481
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
expect(result).toHaveLength(1)
|
|
485
|
+
expect(result[0]?.body).toBe("tool call started")
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
it("combines severity and body filters", async () => {
|
|
489
|
+
const result = await storeRuntime.runPromise(
|
|
490
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
491
|
+
store.searchLogs({ serviceName: "test-api", severity: "INFO", body: "tool" }),
|
|
492
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
expect(result).toHaveLength(1)
|
|
496
|
+
expect(result[0]?.body).toBe("tool call started")
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
it("computes facet status without N+1 queries", async () => {
|
|
500
|
+
const result = await storeRuntime.runPromise(
|
|
501
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
502
|
+
store.listFacets({
|
|
503
|
+
type: "traces",
|
|
504
|
+
field: "status",
|
|
505
|
+
}),
|
|
506
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
expect(result).toHaveLength(2)
|
|
510
|
+
const errorFacet = result.find((r) => r.value === "error")
|
|
511
|
+
const okFacet = result.find((r) => r.value === "ok")
|
|
512
|
+
expect(errorFacet?.count).toBe(2) // trace-2 and trace-ai
|
|
513
|
+
expect(okFacet?.count).toBe(1)
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
it("computes logStats with SQL aggregation", async () => {
|
|
517
|
+
const result = await storeRuntime.runPromise(
|
|
518
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
519
|
+
store.logStats({
|
|
520
|
+
groupBy: "service",
|
|
521
|
+
agg: "count",
|
|
522
|
+
serviceName: "test-api",
|
|
523
|
+
}),
|
|
524
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
expect(result).toHaveLength(1)
|
|
528
|
+
expect(result[0]?.group).toBe("test-api")
|
|
529
|
+
expect(result[0]?.value).toBe(2)
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
it("computes traceStats count via SQL", async () => {
|
|
533
|
+
const result = await storeRuntime.runPromise(
|
|
534
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
535
|
+
store.traceStats({
|
|
536
|
+
groupBy: "status",
|
|
537
|
+
agg: "count",
|
|
538
|
+
serviceName: "test-api",
|
|
539
|
+
}),
|
|
540
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
expect(result).toHaveLength(2)
|
|
544
|
+
const errorGroup = result.find((r) => r.group === "error")
|
|
545
|
+
const okGroup = result.find((r) => r.group === "ok")
|
|
546
|
+
expect(errorGroup?.count).toBe(2) // trace-2 and trace-ai
|
|
547
|
+
expect(okGroup?.count).toBe(1)
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
it("computes traceStats error_rate via SQL", async () => {
|
|
551
|
+
const result = await storeRuntime.runPromise(
|
|
552
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
553
|
+
store.traceStats({
|
|
554
|
+
groupBy: "service",
|
|
555
|
+
agg: "error_rate",
|
|
556
|
+
serviceName: "test-api",
|
|
557
|
+
}),
|
|
558
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
expect(result).toHaveLength(1)
|
|
562
|
+
expect(result[0]?.group).toBe("test-api")
|
|
563
|
+
// 2 error traces out of 3 total
|
|
564
|
+
expect(result[0]?.value).toBeCloseTo(2 / 3, 5)
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
it("documents the docs routes in OpenAPI", () => {
|
|
568
|
+
expect(motelOpenApiSpec.paths["/api/docs"]).toBeDefined()
|
|
569
|
+
expect(motelOpenApiSpec.paths["/api/docs/{name}"]).toBeDefined()
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
it("parses attr filters consistently for CLI-style args", () => {
|
|
573
|
+
expect(isAttributeFilterToken("attr.sessionID=sess_123")).toBe(true)
|
|
574
|
+
expect(isAttributeFilterToken("sessionID=sess_123")).toBe(false)
|
|
575
|
+
expect(attributeFiltersFromArgs(["attr.sessionID=sess_123", "attr.tool=search"])).toEqual({
|
|
576
|
+
sessionID: "sess_123",
|
|
577
|
+
tool: "search",
|
|
578
|
+
})
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
it("parses attrContains filters for CLI-style args", () => {
|
|
582
|
+
expect(isAttributeContainsToken("attrContains.ai.prompt=hello world")).toBe(true)
|
|
583
|
+
expect(isAttributeContainsToken("attr.key=exact")).toBe(false)
|
|
584
|
+
expect(attributeContainsFiltersFromArgs(["attrContains.ai.prompt=hello", "attr.exact=match"])).toEqual({
|
|
585
|
+
"ai.prompt": "hello",
|
|
586
|
+
})
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
it("attr filters exclude attrContains tokens", () => {
|
|
590
|
+
const mixed = ["attr.key=exact", "attrContains.key=substring"]
|
|
591
|
+
expect(attributeFiltersFromArgs(mixed)).toEqual({ key: "exact" })
|
|
592
|
+
expect(attributeContainsFiltersFromArgs(mixed)).toEqual({ key: "substring" })
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
// AI Call tests
|
|
596
|
+
|
|
597
|
+
it("searches AI calls and returns compact summaries", async () => {
|
|
598
|
+
const result = await storeRuntime.runPromise(
|
|
599
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
600
|
+
store.searchAiCalls({}),
|
|
601
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
expect(result).toHaveLength(3) // ai.streamText, ai.toolCall, ai.generateText
|
|
605
|
+
const streamCall = result.find((c) => c.spanId === "ai-stream-1")
|
|
606
|
+
expect(streamCall).toBeDefined()
|
|
607
|
+
expect(streamCall?.operation).toBe("streamText")
|
|
608
|
+
expect(streamCall?.model).toBe("gpt-5.4")
|
|
609
|
+
expect(streamCall?.provider).toBe("openai.responses")
|
|
610
|
+
expect(streamCall?.sessionId).toBe("ses_test123")
|
|
611
|
+
expect(streamCall?.userId).toBe("kit")
|
|
612
|
+
expect(streamCall?.finishReason).toBe("stop")
|
|
613
|
+
expect(streamCall?.promptPreview).toContain("Tell me a joke")
|
|
614
|
+
expect(streamCall?.responsePreview).toContain("dark mode")
|
|
615
|
+
expect(streamCall?.toolCallCount).toBe(1)
|
|
616
|
+
expect(streamCall?.usage?.inputTokens).toBe(150)
|
|
617
|
+
expect(streamCall?.usage?.outputTokens).toBe(42)
|
|
618
|
+
})
|
|
619
|
+
|
|
620
|
+
it("filters AI calls by model", async () => {
|
|
621
|
+
const result = await storeRuntime.runPromise(
|
|
622
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
623
|
+
store.searchAiCalls({ model: "claude-opus-4" }),
|
|
624
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
expect(result).toHaveLength(1)
|
|
628
|
+
expect(result[0]?.model).toBe("claude-opus-4")
|
|
629
|
+
expect(result[0]?.status).toBe("error")
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
it("filters AI calls by sessionId", async () => {
|
|
633
|
+
const result = await storeRuntime.runPromise(
|
|
634
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
635
|
+
store.searchAiCalls({ sessionId: "ses_test123" }),
|
|
636
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
expect(result).toHaveLength(1)
|
|
640
|
+
expect(result[0]?.spanId).toBe("ai-stream-1")
|
|
641
|
+
})
|
|
642
|
+
|
|
643
|
+
it("searches AI calls by text content", async () => {
|
|
644
|
+
const result = await storeRuntime.runPromise(
|
|
645
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
646
|
+
store.searchAiCalls({ text: "joke about programming" }),
|
|
647
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
expect(result).toHaveLength(1)
|
|
651
|
+
expect(result[0]?.spanId).toBe("ai-stream-1")
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
it("filters AI calls by operation type", async () => {
|
|
655
|
+
const result = await storeRuntime.runPromise(
|
|
656
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
657
|
+
store.searchAiCalls({ operation: "generateText" }),
|
|
658
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
expect(result).toHaveLength(1)
|
|
662
|
+
expect(result[0]?.operation).toBe("generateText")
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
it("gets AI call detail with full payloads", async () => {
|
|
666
|
+
const result = await storeRuntime.runPromise(
|
|
667
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
668
|
+
store.getAiCall("ai-stream-1"),
|
|
669
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
expect(result).not.toBeNull()
|
|
673
|
+
expect(result?.model).toBe("gpt-5.4")
|
|
674
|
+
expect(result?.promptMessages).toBeDefined()
|
|
675
|
+
expect(result?.responseText).toContain("dark mode")
|
|
676
|
+
expect(result?.toolCalls).toHaveLength(1)
|
|
677
|
+
expect(result?.toolCalls[0]?.name).toBe("bash")
|
|
678
|
+
expect(result?.usage?.inputTokens).toBe(150)
|
|
679
|
+
expect(result?.timing.msToFirstChunk).toBe(500.5)
|
|
680
|
+
expect(result?.timing.msToFinish).toBe(20000)
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
it("returns null for non-AI span in getAiCall", async () => {
|
|
684
|
+
const result = await storeRuntime.runPromise(
|
|
685
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
686
|
+
store.getAiCall("root-1"),
|
|
687
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
expect(result).toBeNull()
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
it("aggregates AI call stats by model", async () => {
|
|
694
|
+
const result = await storeRuntime.runPromise(
|
|
695
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
696
|
+
store.aiCallStats({ groupBy: "model", agg: "count" }),
|
|
697
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
expect(result.length).toBeGreaterThanOrEqual(2)
|
|
701
|
+
const gpt = result.find((r) => r.group === "gpt-5.4")
|
|
702
|
+
const claude = result.find((r) => r.group === "claude-opus-4")
|
|
703
|
+
expect(gpt?.count).toBeGreaterThanOrEqual(1)
|
|
704
|
+
expect(claude?.count).toBe(1)
|
|
705
|
+
})
|
|
706
|
+
|
|
707
|
+
it("aggregates AI call stats by status", async () => {
|
|
708
|
+
const result = await storeRuntime.runPromise(
|
|
709
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
710
|
+
store.aiCallStats({ groupBy: "status", agg: "count" }),
|
|
711
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
const okGroup = result.find((r) => r.group === "ok")
|
|
715
|
+
const errorGroup = result.find((r) => r.group === "error")
|
|
716
|
+
expect(okGroup).toBeDefined()
|
|
717
|
+
expect(errorGroup).toBeDefined()
|
|
718
|
+
expect(errorGroup?.count).toBe(1)
|
|
719
|
+
})
|
|
720
|
+
|
|
721
|
+
it("documents the AI routes in OpenAPI", () => {
|
|
722
|
+
expect(motelOpenApiSpec.paths["/api/ai/calls"]).toBeDefined()
|
|
723
|
+
expect(motelOpenApiSpec.paths["/api/ai/calls/{spanId}"]).toBeDefined()
|
|
724
|
+
expect(motelOpenApiSpec.paths["/api/ai/stats"]).toBeDefined()
|
|
725
|
+
})
|
|
726
|
+
})
|