@aryasaatvik/motel 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/AGENTS.md +180 -0
  2. package/LICENSE +21 -0
  3. package/README.md +137 -0
  4. package/package.json +119 -0
  5. package/skills/motel-debug/SKILL.md +203 -0
  6. package/skills/motel-debug/references/effect.md +38 -0
  7. package/src/App.tsx +414 -0
  8. package/src/StartupGate.test.ts +80 -0
  9. package/src/StartupGate.tsx +263 -0
  10. package/src/cli.ts +205 -0
  11. package/src/config.ts +45 -0
  12. package/src/daemon.test.ts +547 -0
  13. package/src/daemon.ts +486 -0
  14. package/src/domain.test.ts +62 -0
  15. package/src/domain.ts +291 -0
  16. package/src/httpApi.ts +391 -0
  17. package/src/httpListPolicy.test.ts +76 -0
  18. package/src/httpListPolicy.ts +129 -0
  19. package/src/index.tsx +25 -0
  20. package/src/instructions.ts +72 -0
  21. package/src/launchAgent.test.ts +430 -0
  22. package/src/launchAgent.ts +462 -0
  23. package/src/localServer.ts +568 -0
  24. package/src/locator.ts +138 -0
  25. package/src/mcp.ts +433 -0
  26. package/src/motel.test.ts +176 -0
  27. package/src/motel.ts +88 -0
  28. package/src/motelClient.ts +353 -0
  29. package/src/opentui-jsx.d.ts +11 -0
  30. package/src/otlp.test.ts +65 -0
  31. package/src/otlp.ts +162 -0
  32. package/src/otlpProtobuf.ts +35 -0
  33. package/src/queryFilters.ts +39 -0
  34. package/src/registry.ts +115 -0
  35. package/src/runtime.ts +40 -0
  36. package/src/server.ts +11 -0
  37. package/src/services/AsyncIngest.ts +82 -0
  38. package/src/services/LogQueryService.ts +29 -0
  39. package/src/services/TelemetryQuery.ts +62 -0
  40. package/src/services/TelemetryStore.ts +2554 -0
  41. package/src/services/TraceQueryService.ts +43 -0
  42. package/src/services/ingestRpc.ts +39 -0
  43. package/src/services/queryRpc.ts +15 -0
  44. package/src/services/telemetryQueryWorker.ts +32 -0
  45. package/src/services/telemetryWorker.ts +59 -0
  46. package/src/startupBench.ts +19 -0
  47. package/src/startupLifecycle.ts +57 -0
  48. package/src/storybook/aiChatStory.tsx +244 -0
  49. package/src/storybook/fixtures/errorState.ts +44 -0
  50. package/src/storybook/fixtures/imagePaste.ts +34 -0
  51. package/src/storybook/fixtures/index.ts +62 -0
  52. package/src/storybook/fixtures/kitchenSink.ts +148 -0
  53. package/src/storybook/fixtures/rawPrompt.ts +15 -0
  54. package/src/storybook/fixtures/short.ts +27 -0
  55. package/src/storybook/fixtures/toolHeavy.ts +65 -0
  56. package/src/telemetry.test.ts +1053 -0
  57. package/src/ui/AiChatView.tsx +308 -0
  58. package/src/ui/AttrFilterModal.tsx +120 -0
  59. package/src/ui/ServiceLogs.tsx +115 -0
  60. package/src/ui/SpanContentView.tsx +184 -0
  61. package/src/ui/SpanDetail.tsx +215 -0
  62. package/src/ui/SpanDetailFull.tsx +224 -0
  63. package/src/ui/SpanDetailPane.tsx +90 -0
  64. package/src/ui/TraceDetailsPane.tsx +175 -0
  65. package/src/ui/TraceList.tsx +254 -0
  66. package/src/ui/Waterfall.tsx +389 -0
  67. package/src/ui/aiChatModel.test.ts +391 -0
  68. package/src/ui/aiChatModel.ts +773 -0
  69. package/src/ui/aiState.ts +59 -0
  70. package/src/ui/app/TraceListPane.tsx +39 -0
  71. package/src/ui/app/TraceWorkspace.tsx +452 -0
  72. package/src/ui/app/useAppLayout.ts +101 -0
  73. package/src/ui/app/useTraceScreenData.ts +590 -0
  74. package/src/ui/atoms.ts +131 -0
  75. package/src/ui/cachedLoader.test.ts +23 -0
  76. package/src/ui/cachedLoader.ts +60 -0
  77. package/src/ui/filterParser.test.ts +56 -0
  78. package/src/ui/filterParser.ts +45 -0
  79. package/src/ui/format.ts +128 -0
  80. package/src/ui/loaders.ts +101 -0
  81. package/src/ui/persistence.ts +41 -0
  82. package/src/ui/primitives.tsx +198 -0
  83. package/src/ui/state.ts +6 -0
  84. package/src/ui/theme.ts +160 -0
  85. package/src/ui/traceDetailsWidth.repro.test.ts +126 -0
  86. package/src/ui/traceSortNav.repro.seed.ts +62 -0
  87. package/src/ui/traceSortNav.repro.test.ts +231 -0
  88. package/src/ui/useAttrFilterPicker.ts +89 -0
  89. package/src/ui/useKeyboardNav.ts +1023 -0
  90. package/src/ui/waterfallFilter.test.ts +84 -0
  91. package/src/ui/waterfallFilter.ts +59 -0
  92. package/src/ui/waterfallModel.ts +130 -0
  93. package/src/ui/waterfallNav.repro.seed.ts +86 -0
  94. package/src/ui/waterfallNav.repro.test.ts +271 -0
  95. package/src/ui/waterfallNav.test.ts +453 -0
  96. package/src/ui/waterfallNav.ts +75 -0
  97. package/web/dist/assets/index-B01z9BaO.css +2 -0
  98. package/web/dist/assets/index-CYlBaY9O.js +22 -0
  99. package/web/dist/index.html +13 -0
@@ -0,0 +1,1053 @@
1
+ import { Database } from "bun:sqlite"
2
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test"
3
+ import { copyFileSync, mkdtempSync, rmSync } from "node:fs"
4
+ import { tmpdir } from "node:os"
5
+ import { join } from "node:path"
6
+ import { Effect, References } from "effect"
7
+ import { attributeFiltersFromArgs, attributeContainsFiltersFromArgs, isAttributeFilterToken, isAttributeContainsToken } from "./queryFilters.js"
8
+
9
+ describe("motel telemetry store", () => {
10
+ const tempDir = mkdtempSync(join(tmpdir(), "motel-test-"))
11
+ const dbPath = join(tempDir, "telemetry.sqlite")
12
+ const previousDatabasePath = process.env.MOTEL_OTEL_DB_PATH
13
+ const previousRetentionHours = process.env.MOTEL_OTEL_RETENTION_HOURS
14
+ let storeRuntime: Awaited<typeof import("./runtime.ts")>["storeRuntime"]
15
+ let TelemetryStore: Awaited<typeof import("./services/TelemetryStore.ts")>["TelemetryStore"]
16
+ let motelOpenApiSpec: Awaited<typeof import("./httpApi.ts")>["motelOpenApiSpec"]
17
+
18
+ beforeAll(async () => {
19
+ process.env.MOTEL_OTEL_DB_PATH = dbPath
20
+ process.env.MOTEL_OTEL_RETENTION_HOURS = "24"
21
+ const suffix = `?test=${Date.now()}`
22
+ ;({ storeRuntime } = await import(`./runtime.ts${suffix}`))
23
+ ;({ TelemetryStore } = await import(`./services/TelemetryStore.ts${suffix}`))
24
+ ;({ motelOpenApiSpec } = await import(`./httpApi.ts${suffix}`))
25
+
26
+ const nowNanos = BigInt(Date.now()) * 1_000_000n
27
+ const oneSecond = 1_000_000_000n
28
+
29
+ const ingest = Effect.flatMap(TelemetryStore, (store) =>
30
+ Effect.flatMap(
31
+ store.ingestTraces({
32
+ resourceSpans: [
33
+ {
34
+ resource: {
35
+ attributes: [
36
+ { key: "service.name", value: { stringValue: "test-api" } },
37
+ { key: "deployment.environment.name", value: { stringValue: "local" } },
38
+ ],
39
+ },
40
+ scopeSpans: [
41
+ {
42
+ scope: { name: "test-scope" },
43
+ spans: [
44
+ {
45
+ traceId: "trace-1",
46
+ spanId: "root-1",
47
+ name: "SessionProcessor.stream",
48
+ kind: 2,
49
+ startTimeUnixNano: String(nowNanos),
50
+ endTimeUnixNano: String(nowNanos + 4n * oneSecond),
51
+ attributes: [
52
+ { key: "sessionID", value: { stringValue: "session-1" } },
53
+ { key: "modelID", value: { stringValue: "gpt-5.4" } },
54
+ ],
55
+ },
56
+ {
57
+ traceId: "trace-1",
58
+ spanId: "child-1",
59
+ parentSpanId: "root-1",
60
+ name: "tool.call",
61
+ kind: 1,
62
+ startTimeUnixNano: String(nowNanos + oneSecond),
63
+ endTimeUnixNano: String(nowNanos + 2n * oneSecond),
64
+ attributes: [
65
+ { key: "tool", value: { stringValue: "search" } },
66
+ ],
67
+ },
68
+ ],
69
+ },
70
+ ],
71
+ },
72
+ {
73
+ resource: {
74
+ attributes: [
75
+ { key: "service.name", value: { stringValue: "test-api" } },
76
+ { key: "deployment.environment.name", value: { stringValue: "local" } },
77
+ ],
78
+ },
79
+ scopeSpans: [
80
+ {
81
+ scope: { name: "test-scope" },
82
+ spans: [
83
+ {
84
+ traceId: "trace-2",
85
+ spanId: "root-2",
86
+ name: "SessionProcessor.stream",
87
+ kind: 2,
88
+ startTimeUnixNano: String(nowNanos + 10n * oneSecond),
89
+ endTimeUnixNano: String(nowNanos + 12n * oneSecond),
90
+ status: { code: 2 },
91
+ attributes: [
92
+ { key: "sessionID", value: { stringValue: "session-2" } },
93
+ { key: "modelID", value: { stringValue: "gpt-5.4" } },
94
+ ],
95
+ },
96
+ ],
97
+ },
98
+ ],
99
+ },
100
+ ],
101
+ }),
102
+ () => store.ingestLogs({
103
+ resourceLogs: [
104
+ {
105
+ resource: { attributes: [{ key: "service.name", value: { stringValue: "test-api" } }] },
106
+ scopeLogs: [
107
+ {
108
+ scope: { name: "app" },
109
+ logRecords: [
110
+ {
111
+ timeUnixNano: String(nowNanos + 500_000_000n),
112
+ severityText: "INFO",
113
+ traceId: "trace-1",
114
+ spanId: "child-1",
115
+ body: { stringValue: "tool call started" },
116
+ attributes: [{ key: "tool", value: { stringValue: "search" } }],
117
+ },
118
+ {
119
+ timeUnixNano: String(nowNanos + 11n * oneSecond),
120
+ severityText: "ERROR",
121
+ traceId: "trace-2",
122
+ spanId: "root-2",
123
+ body: { stringValue: "stream failed" },
124
+ attributes: [{ key: "tool", value: { stringValue: "none" } }],
125
+ },
126
+ ],
127
+ },
128
+ ],
129
+ },
130
+ ],
131
+ }),
132
+ ).pipe(Effect.flatMap(() => Effect.flatMap(TelemetryStore, (store) =>
133
+ store.ingestTraces({
134
+ resourceSpans: [{
135
+ resource: { attributes: [{ key: "service.name", value: { stringValue: "test-api" } }] },
136
+ scopeSpans: [{
137
+ scope: { name: "ai" },
138
+ spans: [
139
+ {
140
+ traceId: "trace-ai",
141
+ spanId: "ai-stream-1",
142
+ name: "ai.streamText",
143
+ kind: 1,
144
+ startTimeUnixNano: String(nowNanos + 20n * oneSecond),
145
+ endTimeUnixNano: String(nowNanos + 40n * oneSecond),
146
+ attributes: [
147
+ { key: "ai.operationId", value: { stringValue: "ai.streamText" } },
148
+ { key: "ai.telemetry.functionId", value: { stringValue: "session.llm" } },
149
+ { key: "ai.model.provider", value: { stringValue: "openai.responses" } },
150
+ { key: "ai.model.id", value: { stringValue: "gpt-5.4" } },
151
+ { key: "ai.telemetry.metadata.sessionId", value: { stringValue: "ses_test123" } },
152
+ { key: "ai.telemetry.metadata.userId", value: { stringValue: "kit" } },
153
+ { key: "ai.prompt.messages", value: { stringValue: '[{"role":"user","content":"Tell me a joke about programming"}]' } },
154
+ { key: "ai.response.text", value: { stringValue: "Why do programmers prefer dark mode? Because light attracts bugs!" } },
155
+ { key: "ai.response.finishReason", value: { stringValue: "stop" } },
156
+ { key: "ai.usage.inputTokens", value: { stringValue: "150" } },
157
+ { key: "ai.usage.outputTokens", value: { stringValue: "42" } },
158
+ { key: "ai.usage.totalTokens", value: { stringValue: "192" } },
159
+ { key: "ai.usage.cachedInputTokens", value: { stringValue: "100" } },
160
+ { key: "ai.response.msToFirstChunk", value: { stringValue: "500.5" } },
161
+ { key: "ai.response.msToFinish", value: { stringValue: "20000" } },
162
+ ],
163
+ },
164
+ {
165
+ traceId: "trace-ai",
166
+ spanId: "ai-stream-1-do",
167
+ parentSpanId: "ai-stream-1",
168
+ name: "ai.streamText.doStream",
169
+ kind: 1,
170
+ startTimeUnixNano: String(nowNanos + 21n * oneSecond),
171
+ endTimeUnixNano: String(nowNanos + 39n * oneSecond),
172
+ attributes: [
173
+ { key: "ai.operationId", value: { stringValue: "ai.streamText.doStream" } },
174
+ { key: "ai.telemetry.functionId", value: { stringValue: "session.llm" } },
175
+ { key: "ai.model.provider", value: { stringValue: "openai.responses" } },
176
+ { key: "ai.model.id", value: { stringValue: "gpt-5.4" } },
177
+ { key: "ai.telemetry.metadata.sessionId", value: { stringValue: "ses_test123" } },
178
+ { key: "ai.prompt.messages", value: { stringValue: '[{"role":"user","content":"Tell me a joke about programming"}]' } },
179
+ ],
180
+ },
181
+ {
182
+ traceId: "trace-ai",
183
+ spanId: "ai-tool-1",
184
+ parentSpanId: "ai-stream-1",
185
+ name: "ai.toolCall",
186
+ kind: 1,
187
+ startTimeUnixNano: String(nowNanos + 25n * oneSecond),
188
+ endTimeUnixNano: String(nowNanos + 26n * oneSecond),
189
+ attributes: [
190
+ { key: "ai.toolCall.name", value: { stringValue: "bash" } },
191
+ ],
192
+ },
193
+ {
194
+ traceId: "trace-ai",
195
+ spanId: "ai-stream-2",
196
+ name: "ai.generateText",
197
+ kind: 1,
198
+ startTimeUnixNano: String(nowNanos + 50n * oneSecond),
199
+ endTimeUnixNano: String(nowNanos + 55n * oneSecond),
200
+ status: { code: 2 },
201
+ attributes: [
202
+ { key: "ai.operationId", value: { stringValue: "ai.generateText" } },
203
+ { key: "ai.telemetry.functionId", value: { stringValue: "session.llm" } },
204
+ { key: "ai.model.provider", value: { stringValue: "anthropic" } },
205
+ { key: "ai.model.id", value: { stringValue: "claude-opus-4" } },
206
+ { key: "ai.telemetry.metadata.sessionId", value: { stringValue: "ses_test456" } },
207
+ { key: "ai.prompt.messages", value: { stringValue: '[{"role":"user","content":"Summarize this"}]' } },
208
+ { key: "ai.response.text", value: { stringValue: "Error: rate limited" } },
209
+ { key: "ai.response.finishReason", value: { stringValue: "error" } },
210
+ { key: "ai.usage.inputTokens", value: { stringValue: "80" } },
211
+ { key: "ai.usage.outputTokens", value: { stringValue: "10" } },
212
+ { key: "ai.usage.totalTokens", value: { stringValue: "90" } },
213
+ ],
214
+ },
215
+ ],
216
+ }],
217
+ }],
218
+ }),
219
+ ))),
220
+ )
221
+
222
+ await storeRuntime.runPromise(ingest.pipe(Effect.provideService(References.MinimumLogLevel, "None")))
223
+ })
224
+
225
+ afterAll(async () => {
226
+ await storeRuntime.dispose()
227
+ rmSync(tempDir, { recursive: true, force: true })
228
+ if (previousDatabasePath === undefined) delete process.env.MOTEL_OTEL_DB_PATH
229
+ else process.env.MOTEL_OTEL_DB_PATH = previousDatabasePath
230
+ if (previousRetentionHours === undefined) delete process.env.MOTEL_OTEL_RETENTION_HOURS
231
+ else process.env.MOTEL_OTEL_RETENTION_HOURS = previousRetentionHours
232
+ })
233
+
234
+ it("creates fresh DBs with auto_vacuum=INCREMENTAL", () => {
235
+ // Headline regression test: PRAGMA auto_vacuum is a header-level
236
+ // setting that only takes effect when set BEFORE the first CREATE
237
+ // TABLE, or after a full VACUUM. The previous code set it AFTER
238
+ // schema init, so every motel DB ever created had auto_vacuum=NONE
239
+ // and incremental_vacuum was silently a no-op — the documented
240
+ // mechanism behind the 17GB telemetry.sqlite this test exists to
241
+ // prevent.
242
+ const probe = new Database(dbPath, { readonly: true })
243
+ try {
244
+ const mode = (probe.query(`PRAGMA auto_vacuum`).get() as { auto_vacuum: number }).auto_vacuum
245
+ expect(mode).toBe(2) // 2 = INCREMENTAL
246
+ } finally {
247
+ probe.close()
248
+ }
249
+ })
250
+
251
+ it("incremental_vacuum reclaims pages back to the OS after deletes", () => {
252
+ // Proves the full reclaim chain works on the real schema: DELETE
253
+ // → wal_checkpoint → incremental_vacuum → page_count drops. With
254
+ // the previous auto_vacuum=NONE bug, page_count would not change
255
+ // no matter how many incremental_vacuum calls were made.
256
+ // Operate on a copy of the seed DB so we don't destroy state that
257
+ // later tests rely on. Checkpoint first so the copy is consistent.
258
+ const sourceProbe = new Database(dbPath)
259
+ try { sourceProbe.exec(`PRAGMA wal_checkpoint(TRUNCATE);`) } finally { sourceProbe.close() }
260
+ const clonePath = join(tempDir, "telemetry-vacuum-clone.sqlite")
261
+ copyFileSync(dbPath, clonePath)
262
+ const probe = new Database(clonePath)
263
+ try {
264
+ probe.exec(`PRAGMA busy_timeout = 5000;`)
265
+
266
+ // Bulk-insert filler so we have enough pages to make truncation
267
+ // observable. SQLite frees pages whole-page-at-a-time, so a tiny
268
+ // fixture (a handful of partial pages) won't yield a measurable
269
+ // page_count delta. 1000 rows × ~600 bytes = ~600KB ≈ 150 pages.
270
+ const stmt = probe.prepare(
271
+ `INSERT INTO spans (trace_id, span_id, parent_span_id, service_name, scope_name, operation_name, kind, start_time_ms, end_time_ms, duration_ms, status, attributes_json, resource_json, events_json) VALUES (?, ?, NULL, 'vac', 'scope', 'op', 'INTERNAL', 0, 0, 0, 'OK', '{}', '{}', '[]')`,
272
+ )
273
+ probe.exec(`BEGIN IMMEDIATE;`)
274
+ const filler = "x".repeat(512)
275
+ for (let i = 0; i < 1000; i++) {
276
+ stmt.run(`v${i.toString().padStart(8, "0")}-${filler.slice(0, 40)}`, `s${i.toString(16).padStart(15, "0")}`)
277
+ }
278
+ probe.exec(`COMMIT;`)
279
+
280
+ const pageCountBefore = (probe.query(`PRAGMA page_count`).get() as { page_count: number }).page_count
281
+ expect(pageCountBefore).toBeGreaterThan(50)
282
+
283
+ probe.exec(`DELETE FROM spans WHERE service_name = 'vac';`)
284
+
285
+ const freelistAfterDelete = (probe.query(`PRAGMA freelist_count`).get() as { freelist_count: number }).freelist_count
286
+ expect(freelistAfterDelete).toBeGreaterThan(0)
287
+
288
+ probe.exec(`PRAGMA wal_checkpoint(RESTART);`)
289
+ probe.exec(`PRAGMA incremental_vacuum;`)
290
+ probe.exec(`PRAGMA wal_checkpoint(TRUNCATE);`)
291
+
292
+ const pageCountAfter = (probe.query(`PRAGMA page_count`).get() as { page_count: number }).page_count
293
+ const freelistAfter = (probe.query(`PRAGMA freelist_count`).get() as { freelist_count: number }).freelist_count
294
+ expect(pageCountAfter).toBeLessThan(pageCountBefore)
295
+ expect(freelistAfter).toBeLessThan(freelistAfterDelete)
296
+ } finally {
297
+ probe.close()
298
+ }
299
+ })
300
+
301
+ it("retention prunes old correlated orphan logs and preserves recent logs", async () => {
302
+ const oldNanos = BigInt(Date.now() - 48 * 60 * 60 * 1000) * 1_000_000n
303
+ const recentNanos = BigInt(Date.now()) * 1_000_000n
304
+ await storeRuntime.runPromise(
305
+ Effect.flatMap(TelemetryStore, (store) =>
306
+ Effect.andThen(
307
+ store.ingestLogs({
308
+ resourceLogs: [{
309
+ resource: { attributes: [{ key: "service.name", value: { stringValue: "retention-test" } }] },
310
+ scopeLogs: [{ logRecords: [{ traceId: "missing-old-trace", timeUnixNano: String(oldNanos), body: { stringValue: "expired-correlated-log" } }, { traceId: "missing-recent-trace", timeUnixNano: String(recentNanos), body: { stringValue: "recent-correlated-log" } }] }],
311
+ }],
312
+ }),
313
+ store.runRetentionNow,
314
+ ),
315
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
316
+ )
317
+
318
+ const probe = new Database(dbPath, { readonly: true })
319
+ try {
320
+ const oldCount = (probe.query(`SELECT COUNT(*) AS c FROM logs WHERE body = 'expired-correlated-log'`).get() as { c: number }).c
321
+ const recentCount = (probe.query(`SELECT COUNT(*) AS c FROM logs WHERE body = 'recent-correlated-log'`).get() as { c: number }).c
322
+ expect(oldCount).toBe(0)
323
+ expect(recentCount).toBe(1)
324
+ } finally {
325
+ probe.close()
326
+ }
327
+ })
328
+
329
+ it("retention cleans orphan log indexes even when no logs are deleted", async () => {
330
+ const probe = new Database(dbPath)
331
+ try {
332
+ probe.query(`INSERT INTO log_attributes(log_id, key, value) VALUES (?, 'orphan', 'value')`).run(9_999_999)
333
+ probe.query(`INSERT INTO log_body_fts(log_id, body) VALUES (?, 'orphan body')`).run("9999999")
334
+ } finally {
335
+ probe.close()
336
+ }
337
+
338
+ await storeRuntime.runPromise(
339
+ Effect.flatMap(TelemetryStore, (store) => store.runRetentionNow).pipe(
340
+ Effect.provideService(References.MinimumLogLevel, "None"),
341
+ ),
342
+ )
343
+
344
+ const check = new Database(dbPath, { readonly: true })
345
+ try {
346
+ expect((check.query(`SELECT COUNT(*) AS c FROM log_attributes WHERE log_id = 9999999`).get() as { c: number }).c).toBe(0)
347
+ expect((check.query(`SELECT COUNT(*) AS c FROM log_body_fts WHERE log_id = '9999999'`).get() as { c: number }).c).toBe(0)
348
+ } finally {
349
+ check.close()
350
+ }
351
+ })
352
+
353
+ it("does not rewrite legacy auto_vacuum databases on startup", async () => {
354
+ const legacyPath = join(tempDir, "legacy-no-vacuum.sqlite")
355
+ const legacy = new Database(legacyPath)
356
+ legacy.exec(`PRAGMA auto_vacuum = NONE; CREATE TABLE legacy(value TEXT); INSERT INTO legacy VALUES ('kept');`)
357
+ legacy.close()
358
+
359
+ const open = Bun.spawn({
360
+ cmd: [process.execPath, "-e", `const { Effect } = await import('effect'); const { storeRuntime } = await import('./src/runtime.ts'); const { TelemetryStore } = await import('./src/services/TelemetryStore.ts'); await storeRuntime.runPromise(Effect.flatMap(TelemetryStore, (store) => store.listServices)); await storeRuntime.dispose()`],
361
+ cwd: process.cwd(),
362
+ env: { ...process.env, MOTEL_OTEL_DB_PATH: legacyPath },
363
+ stdout: "ignore",
364
+ stderr: "pipe",
365
+ })
366
+ expect(await open.exited).toBe(0)
367
+
368
+ const probe = new Database(legacyPath, { readonly: true })
369
+ try {
370
+ expect((probe.query(`PRAGMA auto_vacuum`).get() as { auto_vacuum: number }).auto_vacuum).toBe(0)
371
+ expect((probe.query(`SELECT value FROM legacy`).get() as { value: string }).value).toBe("kept")
372
+ } finally {
373
+ probe.close()
374
+ }
375
+ })
376
+
377
+ it("incrementally backfills historical AI FTS content", async () => {
378
+ const historicalPath = join(tempDir, "historical-fts.sqlite")
379
+ const seedScript = `
380
+ const { Effect } = await import('effect')
381
+ const { storeRuntime } = await import('./src/runtime.ts')
382
+ const { TelemetryStore } = await import('./src/services/TelemetryStore.ts')
383
+ await storeRuntime.runPromise(Effect.flatMap(TelemetryStore, (store) => store.ingestTraces({ resourceSpans: [{ resource: { attributes: [{ key: 'service.name', value: { stringValue: 'historical-fts' } }] }, scopeSpans: [{ spans: [{ traceId: 'historical-trace', spanId: 'historical-span', name: 'ai.generateText', startTimeUnixNano: '1', endTimeUnixNano: '2', attributes: [{ key: 'ai.response.text', value: { stringValue: 'historical-backfill-token' } }] }] }] }] })))
384
+ await storeRuntime.dispose()
385
+ `
386
+ const seed = Bun.spawn({ cmd: [process.execPath, "-e", seedScript], cwd: process.cwd(), env: { ...process.env, MOTEL_OTEL_DB_PATH: historicalPath }, stdout: "ignore", stderr: "pipe" })
387
+ expect(await seed.exited).toBe(0)
388
+
389
+ const damage = new Database(historicalPath)
390
+ damage.query(`INSERT INTO span_attr_fts(span_attr_fts) VALUES ('delete-all')`).run()
391
+ damage.query(`DELETE FROM motel_maintenance WHERE key = 'span_attr_fts_v1'`).run()
392
+ damage.close()
393
+
394
+ const repair = Bun.spawn({ cmd: [process.execPath, "-e", `const { Effect } = await import('effect'); const { storeRuntime } = await import('./src/runtime.ts'); const { TelemetryStore } = await import('./src/services/TelemetryStore.ts'); await storeRuntime.runPromise(Effect.flatMap(TelemetryStore, (store) => store.listServices)); await Bun.sleep(500); await storeRuntime.dispose()`], cwd: process.cwd(), env: { ...process.env, MOTEL_OTEL_DB_PATH: historicalPath }, stdout: "ignore", stderr: "pipe" })
395
+ expect(await repair.exited).toBe(0)
396
+
397
+ const probe = new Database(historicalPath, { readonly: true })
398
+ try {
399
+ const match = (probe.query(`SELECT COUNT(*) AS c FROM span_attr_fts WHERE span_attr_fts MATCH 'historical'`).get() as { c: number }).c
400
+ const marker = (probe.query(`SELECT value FROM motel_maintenance WHERE key = 'span_attr_fts_v1'`).get() as { value: string }).value
401
+ expect(match).toBe(1)
402
+ expect(marker).toBe("complete")
403
+ } finally {
404
+ probe.close()
405
+ }
406
+ })
407
+
408
+ it("filters traces by attr.* fields", async () => {
409
+ const result = await storeRuntime.runPromise(
410
+ Effect.flatMap(TelemetryStore, (store) =>
411
+ store.searchTraces({
412
+ serviceName: "test-api",
413
+ attributeFilters: {
414
+ sessionID: "session-1",
415
+ "deployment.environment.name": "local",
416
+ },
417
+ }),
418
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
419
+ )
420
+
421
+ expect(result).toHaveLength(1)
422
+ expect(result[0]?.traceId).toBe("trace-1")
423
+ })
424
+
425
+ it("looks up a span directly by spanId", async () => {
426
+ const result = await storeRuntime.runPromise(
427
+ Effect.flatMap(TelemetryStore, (store) => store.getSpan("child-1")).pipe(
428
+ Effect.provideService(References.MinimumLogLevel, "None"),
429
+ ),
430
+ )
431
+
432
+ expect(result?.traceId).toBe("trace-1")
433
+ expect(result?.rootOperationName).toBe("SessionProcessor.stream")
434
+ expect(result?.span.operationName).toBe("tool.call")
435
+ expect(result?.span.depth).toBe(1)
436
+ })
437
+
438
+ it("returns trace details through the query worker when spans share empty event arrays", async () => {
439
+ const workerDbPath = join(tempDir, "query-worker-shared-events.sqlite")
440
+ const script = `
441
+ const { Effect, ManagedRuntime } = await import("effect")
442
+ const { storeRuntime } = await import("./src/runtime.ts")
443
+ const { TelemetryStore, TelemetryStoreReadonly } = await import("./src/services/TelemetryStore.ts")
444
+ await storeRuntime.runPromise(Effect.flatMap(TelemetryStore, (store) => store.ingestTraces({
445
+ resourceSpans: [{
446
+ resource: { attributes: [{ key: "service.name", value: { stringValue: "worker-repro" } }] },
447
+ scopeSpans: [{ spans: [
448
+ { traceId: "shared-trace", spanId: "root", name: "root", startTimeUnixNano: "1", endTimeUnixNano: "3" },
449
+ { traceId: "shared-trace", spanId: "child", parentSpanId: "root", name: "child", startTimeUnixNano: "2", endTimeUnixNano: "3" },
450
+ ] }],
451
+ }],
452
+ })))
453
+ await storeRuntime.dispose()
454
+ const { TelemetryQueryLive } = await import("./src/services/TelemetryQuery.ts")
455
+ const queryRuntime = ManagedRuntime.make(TelemetryQueryLive)
456
+ try {
457
+ const trace = await queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (store) => store.getTrace("shared-trace")))
458
+ if (trace?.spans.length !== 2) throw new Error("Expected two spans")
459
+ } finally {
460
+ await queryRuntime.dispose()
461
+ }
462
+ `
463
+ const child = Bun.spawn({
464
+ cmd: [process.execPath, "-e", script],
465
+ cwd: process.cwd(),
466
+ env: { ...process.env, MOTEL_OTEL_DB_PATH: workerDbPath },
467
+ stdout: "ignore",
468
+ stderr: "pipe",
469
+ })
470
+ const [exitCode, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()])
471
+ expect(exitCode, stderr).toBe(0)
472
+ })
473
+
474
+ it("uses the canonical earliest root when directly looking up a later root span", async () => {
475
+ const result = await storeRuntime.runPromise(
476
+ Effect.flatMap(TelemetryStore, (store) => store.getSpan("ai-stream-2")).pipe(
477
+ Effect.provideService(References.MinimumLogLevel, "None"),
478
+ ),
479
+ )
480
+
481
+ expect(result?.traceId).toBe("trace-ai")
482
+ expect(result?.span.operationName).toBe("ai.generateText")
483
+ expect(result?.rootOperationName).toBe("ai.streamText")
484
+ })
485
+
486
+ it("filters logs by spanId", async () => {
487
+ const result = await storeRuntime.runPromise(
488
+ Effect.flatMap(TelemetryStore, (store) =>
489
+ store.searchLogs({
490
+ spanId: "child-1",
491
+ }),
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("searches spans by operation, parent operation, and attr filters", async () => {
500
+ const result = await storeRuntime.runPromise(
501
+ Effect.flatMap(TelemetryStore, (store) =>
502
+ store.searchSpans({
503
+ serviceName: "test-api",
504
+ operation: "tool.call",
505
+ parentOperation: "SessionProcessor.stream",
506
+ attributeFilters: {
507
+ tool: "search",
508
+ },
509
+ }),
510
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
511
+ )
512
+
513
+ expect(result).toHaveLength(1)
514
+ expect(result[0]?.traceId).toBe("trace-1")
515
+ expect(result[0]?.span.operationName).toBe("tool.call")
516
+ expect(result[0]?.parentOperationName).toBe("SessionProcessor.stream")
517
+ })
518
+
519
+ it("lists spans for a trace", async () => {
520
+ const result = await storeRuntime.runPromise(
521
+ Effect.flatMap(TelemetryStore, (store) => store.listTraceSpans("trace-1")).pipe(
522
+ Effect.provideService(References.MinimumLogLevel, "None"),
523
+ ),
524
+ )
525
+
526
+ expect(result).toHaveLength(2)
527
+ expect(result[0]?.traceId).toBe("trace-1")
528
+ })
529
+
530
+ it("documents the span lookup route in OpenAPI", () => {
531
+ expect(motelOpenApiSpec.paths["/api/spans/{spanId}"]).toBeDefined()
532
+ })
533
+
534
+ it("aggregates trace stats by operation", async () => {
535
+ const result = await storeRuntime.runPromise(
536
+ Effect.flatMap(TelemetryStore, (store) =>
537
+ store.traceStats({
538
+ groupBy: "operation",
539
+ agg: "avg_duration",
540
+ serviceName: "test-api",
541
+ }),
542
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
543
+ )
544
+
545
+ expect(result.length).toBeGreaterThanOrEqual(1)
546
+ const sessionOp = result.find((r) => r.group === "SessionProcessor.stream")
547
+ expect(sessionOp).toBeDefined()
548
+ expect(sessionOp?.count).toBe(2)
549
+ expect(sessionOp?.value).toBe(3000)
550
+ })
551
+
552
+ it("aggregates log stats by severity", async () => {
553
+ const result = await storeRuntime.runPromise(
554
+ Effect.flatMap(TelemetryStore, (store) =>
555
+ store.logStats({
556
+ groupBy: "severity",
557
+ agg: "count",
558
+ serviceName: "test-api",
559
+ }),
560
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
561
+ )
562
+
563
+ expect(result).toHaveLength(2)
564
+ const errorGroup = result.find((r) => r.group === "ERROR")
565
+ const infoGroup = result.find((r) => r.group === "INFO")
566
+ expect(errorGroup?.value).toBe(1)
567
+ expect(infoGroup?.value).toBe(1)
568
+ })
569
+
570
+ it("documents the stats routes in OpenAPI", () => {
571
+ expect(motelOpenApiSpec.paths["/api/traces/stats"]).toBeDefined()
572
+ expect(motelOpenApiSpec.paths["/api/logs/stats"]).toBeDefined()
573
+ expect(motelOpenApiSpec.paths["/api/spans/{spanId}/logs"]).toBeDefined()
574
+ expect(motelOpenApiSpec.paths["/api/spans/search"]).toBeDefined()
575
+ expect(motelOpenApiSpec.paths["/api/traces/{traceId}/spans"]).toBeDefined()
576
+ })
577
+
578
+ it("lists trace summaries without loading spans", async () => {
579
+ const result = await storeRuntime.runPromise(
580
+ Effect.flatMap(TelemetryStore, (store) =>
581
+ store.listTraceSummaries(null),
582
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
583
+ )
584
+
585
+ expect(result).toHaveLength(3) // trace-1, trace-2, trace-ai
586
+ // Ordered by start time descending — trace-ai is most recent
587
+ expect(result[0]?.traceId).toBe("trace-ai")
588
+ // Summary fields are correct for the original trace
589
+ const trace1 = result.find((r) => r.traceId === "trace-1")
590
+ expect(trace1?.serviceName).toBe("test-api")
591
+ expect(trace1?.rootOperationName).toBe("SessionProcessor.stream")
592
+ expect(trace1?.spanCount).toBe(2)
593
+ expect(trace1?.durationMs).toBe(4000)
594
+ })
595
+
596
+ it("lists trace summaries filtered by service", async () => {
597
+ const result = await storeRuntime.runPromise(
598
+ Effect.flatMap(TelemetryStore, (store) =>
599
+ store.listTraceSummaries("test-api"),
600
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
601
+ )
602
+
603
+ expect(result).toHaveLength(3) // all traces are test-api
604
+ })
605
+
606
+ it("searches trace summaries with status filter", async () => {
607
+ const result = await storeRuntime.runPromise(
608
+ Effect.flatMap(TelemetryStore, (store) =>
609
+ store.searchTraceSummaries({
610
+ serviceName: "test-api",
611
+ status: "error",
612
+ }),
613
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
614
+ )
615
+
616
+ expect(result).toHaveLength(2) // trace-2 and trace-ai (ai.generateText has error status)
617
+ expect(result.some((r) => r.traceId === "trace-2")).toBe(true)
618
+ })
619
+
620
+ it("searches trace summaries with attribute filters", async () => {
621
+ const result = await storeRuntime.runPromise(
622
+ Effect.flatMap(TelemetryStore, (store) =>
623
+ store.searchTraceSummaries({
624
+ serviceName: "test-api",
625
+ attributeFilters: { sessionID: "session-1" },
626
+ }),
627
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
628
+ )
629
+
630
+ expect(result).toHaveLength(1)
631
+ expect(result[0]?.traceId).toBe("trace-1")
632
+ })
633
+
634
+ it("searches trace summaries with operation filter", async () => {
635
+ const result = await storeRuntime.runPromise(
636
+ Effect.flatMap(TelemetryStore, (store) =>
637
+ store.searchTraceSummaries({
638
+ serviceName: "test-api",
639
+ operation: "tool.call",
640
+ }),
641
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
642
+ )
643
+
644
+ expect(result).toHaveLength(1)
645
+ expect(result[0]?.traceId).toBe("trace-1")
646
+ })
647
+
648
+ it("filters logs by severity", async () => {
649
+ const result = await storeRuntime.runPromise(
650
+ Effect.flatMap(TelemetryStore, (store) =>
651
+ store.searchLogs({ serviceName: "test-api", severity: "ERROR" }),
652
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
653
+ )
654
+
655
+ expect(result).toHaveLength(1)
656
+ expect(result[0]?.body).toBe("stream failed")
657
+ expect(result[0]?.severityText).toBe("ERROR")
658
+ })
659
+
660
+ it("filters logs by severity case-insensitively", async () => {
661
+ const result = await storeRuntime.runPromise(
662
+ Effect.flatMap(TelemetryStore, (store) =>
663
+ store.searchLogs({ serviceName: "test-api", severity: "error" }),
664
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
665
+ )
666
+
667
+ expect(result).toHaveLength(1)
668
+ expect(result[0]?.severityText).toBe("ERROR")
669
+ })
670
+
671
+ it("searches log body case-insensitively", async () => {
672
+ const result = await storeRuntime.runPromise(
673
+ Effect.flatMap(TelemetryStore, (store) =>
674
+ store.searchLogs({ serviceName: "test-api", body: "STREAM FAILED" }),
675
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
676
+ )
677
+
678
+ expect(result).toHaveLength(1)
679
+ expect(result[0]?.body).toBe("stream failed")
680
+ })
681
+
682
+ it("searches spans by traceId", async () => {
683
+ const result = await storeRuntime.runPromise(
684
+ Effect.flatMap(TelemetryStore, (store) =>
685
+ store.searchSpans({ traceId: "trace-1" }),
686
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
687
+ )
688
+
689
+ expect(result).toHaveLength(2)
690
+ expect(result.every((s) => s.traceId === "trace-1")).toBe(true)
691
+ })
692
+
693
+ it("searches spans with attrContains substring filter", async () => {
694
+ const result = await storeRuntime.runPromise(
695
+ Effect.flatMap(TelemetryStore, (store) =>
696
+ store.searchSpans({
697
+ serviceName: "test-api",
698
+ attributeContainsFilters: { sessionID: "session" },
699
+ }),
700
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
701
+ )
702
+
703
+ // Both root spans have sessionID containing "session"
704
+ expect(result.length).toBeGreaterThanOrEqual(2)
705
+ })
706
+
707
+ it("searches spans with attrContains case-insensitively", async () => {
708
+ const result = await storeRuntime.runPromise(
709
+ Effect.flatMap(TelemetryStore, (store) =>
710
+ store.searchSpans({
711
+ serviceName: "test-api",
712
+ attributeContainsFilters: { modelID: "GPT" },
713
+ }),
714
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
715
+ )
716
+
717
+ // modelID is "gpt-5.4", searching "GPT" should match case-insensitively
718
+ expect(result.length).toBeGreaterThanOrEqual(2)
719
+ })
720
+
721
+ it("searches logs with attrContains substring filter", async () => {
722
+ const result = await storeRuntime.runPromise(
723
+ Effect.flatMap(TelemetryStore, (store) =>
724
+ store.searchLogs({
725
+ serviceName: "test-api",
726
+ attributeContainsFilters: { tool: "sea" },
727
+ }),
728
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
729
+ )
730
+
731
+ expect(result).toHaveLength(1)
732
+ expect(result[0]?.body).toBe("tool call started")
733
+ })
734
+
735
+ it("combines severity and body filters", async () => {
736
+ const result = await storeRuntime.runPromise(
737
+ Effect.flatMap(TelemetryStore, (store) =>
738
+ store.searchLogs({ serviceName: "test-api", severity: "INFO", body: "tool" }),
739
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
740
+ )
741
+
742
+ expect(result).toHaveLength(1)
743
+ expect(result[0]?.body).toBe("tool call started")
744
+ })
745
+
746
+ it("computes facet status without N+1 queries", async () => {
747
+ const result = await storeRuntime.runPromise(
748
+ Effect.flatMap(TelemetryStore, (store) =>
749
+ store.listFacets({
750
+ type: "traces",
751
+ field: "status",
752
+ }),
753
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
754
+ )
755
+
756
+ expect(result).toHaveLength(2)
757
+ const errorFacet = result.find((r) => r.value === "error")
758
+ const okFacet = result.find((r) => r.value === "ok")
759
+ expect(errorFacet?.count).toBe(2) // trace-2 and trace-ai
760
+ expect(okFacet?.count).toBe(1)
761
+ })
762
+
763
+ it("computes logStats with SQL aggregation", async () => {
764
+ const result = await storeRuntime.runPromise(
765
+ Effect.flatMap(TelemetryStore, (store) =>
766
+ store.logStats({
767
+ groupBy: "service",
768
+ agg: "count",
769
+ serviceName: "test-api",
770
+ }),
771
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
772
+ )
773
+
774
+ expect(result).toHaveLength(1)
775
+ expect(result[0]?.group).toBe("test-api")
776
+ expect(result[0]?.value).toBe(2)
777
+ })
778
+
779
+ it("computes traceStats count via SQL", async () => {
780
+ const result = await storeRuntime.runPromise(
781
+ Effect.flatMap(TelemetryStore, (store) =>
782
+ store.traceStats({
783
+ groupBy: "status",
784
+ agg: "count",
785
+ serviceName: "test-api",
786
+ }),
787
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
788
+ )
789
+
790
+ expect(result).toHaveLength(2)
791
+ const errorGroup = result.find((r) => r.group === "error")
792
+ const okGroup = result.find((r) => r.group === "ok")
793
+ expect(errorGroup?.count).toBe(2) // trace-2 and trace-ai
794
+ expect(okGroup?.count).toBe(1)
795
+ })
796
+
797
+ it("computes traceStats error_rate via SQL", async () => {
798
+ const result = await storeRuntime.runPromise(
799
+ Effect.flatMap(TelemetryStore, (store) =>
800
+ store.traceStats({
801
+ groupBy: "service",
802
+ agg: "error_rate",
803
+ serviceName: "test-api",
804
+ }),
805
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
806
+ )
807
+
808
+ expect(result).toHaveLength(1)
809
+ expect(result[0]?.group).toBe("test-api")
810
+ // 2 error traces out of 3 total
811
+ expect(result[0]?.value).toBeCloseTo(2 / 3, 5)
812
+ })
813
+
814
+ it("documents the docs routes in OpenAPI", () => {
815
+ expect(motelOpenApiSpec.paths["/api/docs"]).toBeDefined()
816
+ expect(motelOpenApiSpec.paths["/api/docs/{name}"]).toBeDefined()
817
+ })
818
+
819
+ it("parses attr filters consistently for CLI-style args", () => {
820
+ expect(isAttributeFilterToken("attr.sessionID=sess_123")).toBe(true)
821
+ expect(isAttributeFilterToken("sessionID=sess_123")).toBe(false)
822
+ expect(attributeFiltersFromArgs(["attr.sessionID=sess_123", "attr.tool=search"])).toEqual({
823
+ sessionID: "sess_123",
824
+ tool: "search",
825
+ })
826
+ })
827
+
828
+ it("parses attrContains filters for CLI-style args", () => {
829
+ expect(isAttributeContainsToken("attrContains.ai.prompt=hello world")).toBe(true)
830
+ expect(isAttributeContainsToken("attr.key=exact")).toBe(false)
831
+ expect(attributeContainsFiltersFromArgs(["attrContains.ai.prompt=hello", "attr.exact=match"])).toEqual({
832
+ "ai.prompt": "hello",
833
+ })
834
+ })
835
+
836
+ it("attr filters exclude attrContains tokens", () => {
837
+ const mixed = ["attr.key=exact", "attrContains.key=substring"]
838
+ expect(attributeFiltersFromArgs(mixed)).toEqual({ key: "exact" })
839
+ expect(attributeContainsFiltersFromArgs(mixed)).toEqual({ key: "substring" })
840
+ })
841
+
842
+ // AI Call tests
843
+
844
+ it("searches AI calls and returns compact summaries", async () => {
845
+ const result = await storeRuntime.runPromise(
846
+ Effect.flatMap(TelemetryStore, (store) =>
847
+ store.searchAiCalls({}),
848
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
849
+ )
850
+
851
+ expect(result).toHaveLength(3) // ai.streamText, ai.toolCall, ai.generateText
852
+ const streamCall = result.find((c) => c.spanId === "ai-stream-1")
853
+ expect(streamCall).toBeDefined()
854
+ expect(streamCall?.operation).toBe("streamText")
855
+ expect(streamCall?.model).toBe("gpt-5.4")
856
+ expect(streamCall?.provider).toBe("openai.responses")
857
+ expect(streamCall?.sessionId).toBe("ses_test123")
858
+ expect(streamCall?.userId).toBe("kit")
859
+ expect(streamCall?.finishReason).toBe("stop")
860
+ expect(streamCall?.promptPreview).toContain("Tell me a joke")
861
+ expect(streamCall?.responsePreview).toContain("dark mode")
862
+ expect(streamCall?.toolCallCount).toBe(1)
863
+ expect(streamCall?.usage?.inputTokens).toBe(150)
864
+ expect(streamCall?.usage?.outputTokens).toBe(42)
865
+ })
866
+
867
+ it("dedupes nested doStream spans from AI summaries", async () => {
868
+ const result = await storeRuntime.runPromise(
869
+ Effect.flatMap(TelemetryStore, (store) =>
870
+ store.searchAiCalls({ sessionId: "ses_test123" }),
871
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
872
+ )
873
+
874
+ expect(result.map((call) => call.spanId)).toEqual(["ai-stream-1"])
875
+ expect(result.map((call) => call.spanId)).not.toContain("ai-stream-1-do")
876
+ })
877
+
878
+ it("filters AI calls by model", async () => {
879
+ const result = await storeRuntime.runPromise(
880
+ Effect.flatMap(TelemetryStore, (store) =>
881
+ store.searchAiCalls({ model: "claude-opus-4" }),
882
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
883
+ )
884
+
885
+ expect(result).toHaveLength(1)
886
+ expect(result[0]?.model).toBe("claude-opus-4")
887
+ expect(result[0]?.status).toBe("error")
888
+ })
889
+
890
+ it("filters AI calls by sessionId", async () => {
891
+ const result = await storeRuntime.runPromise(
892
+ Effect.flatMap(TelemetryStore, (store) =>
893
+ store.searchAiCalls({ sessionId: "ses_test123" }),
894
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
895
+ )
896
+
897
+ expect(result).toHaveLength(1)
898
+ expect(result[0]?.spanId).toBe("ai-stream-1")
899
+ })
900
+
901
+ it("searches AI calls by text content", async () => {
902
+ const result = await storeRuntime.runPromise(
903
+ Effect.flatMap(TelemetryStore, (store) =>
904
+ store.searchAiCalls({ text: "joke about programming" }),
905
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
906
+ )
907
+
908
+ expect(result).toHaveLength(1)
909
+ expect(result[0]?.spanId).toBe("ai-stream-1")
910
+ })
911
+
912
+ it("matches AI calls via words in the response text", async () => {
913
+ // Verifies FTS indexes ai.response.text, not just ai.prompt*. The
914
+ // seeded ai-stream-2 has response "Error: rate limited".
915
+ const result = await storeRuntime.runPromise(
916
+ Effect.flatMap(TelemetryStore, (store) =>
917
+ store.searchAiCalls({ text: "rate limited" }),
918
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
919
+ )
920
+ expect(result.map((r) => r.spanId)).toContain("ai-stream-2")
921
+ })
922
+
923
+ it("matches AI calls case-insensitively and with partial words", async () => {
924
+ // unicode61 tokenizer is case-insensitive by default; prefix `*`
925
+ // handles partial terms like `"PROG"` matching `"programming"`.
926
+ const result = await storeRuntime.runPromise(
927
+ Effect.flatMap(TelemetryStore, (store) =>
928
+ store.searchAiCalls({ text: "PROG" }),
929
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
930
+ )
931
+ expect(result.map((r) => r.spanId)).toContain("ai-stream-1")
932
+ })
933
+
934
+ it("ignores FTS special characters without syntax errors", async () => {
935
+ // FTS5 treats `"`, `*`, `-`, `:` as operators; toFtsQuery must
936
+ // strip them so raw user input never crashes the query.
937
+ const result = await storeRuntime.runPromise(
938
+ Effect.flatMap(TelemetryStore, (store) =>
939
+ store.searchAiCalls({ text: `"joke" - about:programming*` }),
940
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
941
+ )
942
+ expect(result.map((r) => r.spanId)).toContain("ai-stream-1")
943
+ })
944
+
945
+ it("filters AI calls by operation type", async () => {
946
+ const result = await storeRuntime.runPromise(
947
+ Effect.flatMap(TelemetryStore, (store) =>
948
+ store.searchAiCalls({ operation: "generateText" }),
949
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
950
+ )
951
+
952
+ expect(result).toHaveLength(1)
953
+ expect(result[0]?.operation).toBe("generateText")
954
+ })
955
+
956
+ it("gets AI call detail with full payloads", async () => {
957
+ const result = await storeRuntime.runPromise(
958
+ Effect.flatMap(TelemetryStore, (store) =>
959
+ store.getAiCall("ai-stream-1"),
960
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
961
+ )
962
+
963
+ expect(result).not.toBeNull()
964
+ expect(result?.model).toBe("gpt-5.4")
965
+ expect(result?.promptMessages).toBeDefined()
966
+ expect(result?.responseText).toContain("dark mode")
967
+ expect(result?.toolCalls).toHaveLength(1)
968
+ expect(result?.toolCalls[0]?.name).toBe("bash")
969
+ expect(result?.usage?.inputTokens).toBe(150)
970
+ expect(result?.timing.msToFirstChunk).toBe(500.5)
971
+ expect(result?.timing.msToFinish).toBe(20000)
972
+ })
973
+
974
+ it("returns null for non-AI span in getAiCall", async () => {
975
+ const result = await storeRuntime.runPromise(
976
+ Effect.flatMap(TelemetryStore, (store) =>
977
+ store.getAiCall("root-1"),
978
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
979
+ )
980
+
981
+ expect(result).toBeNull()
982
+ })
983
+
984
+ it("aggregates AI call stats by model", async () => {
985
+ const result = await storeRuntime.runPromise(
986
+ Effect.flatMap(TelemetryStore, (store) =>
987
+ store.aiCallStats({ groupBy: "model", agg: "count" }),
988
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
989
+ )
990
+
991
+ expect(result.length).toBeGreaterThanOrEqual(2)
992
+ const gpt = result.find((r) => r.group === "gpt-5.4")
993
+ const claude = result.find((r) => r.group === "claude-opus-4")
994
+ expect(gpt?.count).toBeGreaterThanOrEqual(1)
995
+ expect(claude?.count).toBe(1)
996
+ })
997
+
998
+ it("aggregates AI call stats by status", async () => {
999
+ const result = await storeRuntime.runPromise(
1000
+ Effect.flatMap(TelemetryStore, (store) =>
1001
+ store.aiCallStats({ groupBy: "status", agg: "count" }),
1002
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
1003
+ )
1004
+
1005
+ const okGroup = result.find((r) => r.group === "ok")
1006
+ const errorGroup = result.find((r) => r.group === "error")
1007
+ expect(okGroup).toBeDefined()
1008
+ expect(errorGroup).toBeDefined()
1009
+ expect(errorGroup?.count).toBe(1)
1010
+ })
1011
+
1012
+ it("documents the AI routes in OpenAPI", () => {
1013
+ expect(motelOpenApiSpec.paths["/api/ai/calls"]).toBeDefined()
1014
+ expect(motelOpenApiSpec.paths["/api/ai/calls/{spanId}"]).toBeDefined()
1015
+ expect(motelOpenApiSpec.paths["/api/ai/stats"]).toBeDefined()
1016
+ })
1017
+
1018
+ it("lists services with recent child-span activity even when the root started earlier", async () => {
1019
+ const nowNanos = BigInt(Date.now()) * 1_000_000n
1020
+ const oldRootNanos = nowNanos - 2n * 24n * 60n * 60n * 1_000_000_000n
1021
+ await storeRuntime.runPromise(
1022
+ Effect.flatMap(TelemetryStore, (store) => store.ingestTraces({
1023
+ resourceSpans: [{
1024
+ resource: { attributes: [{ key: "service.name", value: { stringValue: "active-child-only" } }] },
1025
+ scopeSpans: [{
1026
+ spans: [{
1027
+ traceId: "trace-active-child",
1028
+ spanId: "old-root",
1029
+ name: "old.root",
1030
+ startTimeUnixNano: String(oldRootNanos),
1031
+ endTimeUnixNano: String(oldRootNanos + 1_000_000n),
1032
+ }, {
1033
+ traceId: "trace-active-child",
1034
+ spanId: "recent-child",
1035
+ parentSpanId: "old-root",
1036
+ name: "recent.child",
1037
+ startTimeUnixNano: String(nowNanos),
1038
+ endTimeUnixNano: String(nowNanos + 1_000_000n),
1039
+ }],
1040
+ }],
1041
+ }],
1042
+ })).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
1043
+ )
1044
+
1045
+ const services = await storeRuntime.runPromise(
1046
+ Effect.flatMap(TelemetryStore, (store) => store.listServices).pipe(
1047
+ Effect.provideService(References.MinimumLogLevel, "None"),
1048
+ ),
1049
+ )
1050
+
1051
+ expect(services).toContain("active-child-only")
1052
+ })
1053
+ })