@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.
- package/AGENTS.md +180 -0
- package/LICENSE +21 -0
- package/README.md +137 -0
- package/package.json +119 -0
- package/skills/motel-debug/SKILL.md +203 -0
- package/skills/motel-debug/references/effect.md +38 -0
- package/src/App.tsx +414 -0
- package/src/StartupGate.test.ts +80 -0
- package/src/StartupGate.tsx +263 -0
- package/src/cli.ts +205 -0
- package/src/config.ts +45 -0
- package/src/daemon.test.ts +547 -0
- package/src/daemon.ts +486 -0
- package/src/domain.test.ts +62 -0
- package/src/domain.ts +291 -0
- package/src/httpApi.ts +391 -0
- package/src/httpListPolicy.test.ts +76 -0
- package/src/httpListPolicy.ts +129 -0
- package/src/index.tsx +25 -0
- package/src/instructions.ts +72 -0
- package/src/launchAgent.test.ts +430 -0
- package/src/launchAgent.ts +462 -0
- package/src/localServer.ts +568 -0
- package/src/locator.ts +138 -0
- package/src/mcp.ts +433 -0
- package/src/motel.test.ts +176 -0
- package/src/motel.ts +88 -0
- package/src/motelClient.ts +353 -0
- package/src/opentui-jsx.d.ts +11 -0
- package/src/otlp.test.ts +65 -0
- package/src/otlp.ts +162 -0
- package/src/otlpProtobuf.ts +35 -0
- package/src/queryFilters.ts +39 -0
- package/src/registry.ts +115 -0
- package/src/runtime.ts +40 -0
- package/src/server.ts +11 -0
- package/src/services/AsyncIngest.ts +82 -0
- package/src/services/LogQueryService.ts +29 -0
- package/src/services/TelemetryQuery.ts +62 -0
- package/src/services/TelemetryStore.ts +2554 -0
- package/src/services/TraceQueryService.ts +43 -0
- package/src/services/ingestRpc.ts +39 -0
- package/src/services/queryRpc.ts +15 -0
- package/src/services/telemetryQueryWorker.ts +32 -0
- package/src/services/telemetryWorker.ts +59 -0
- package/src/startupBench.ts +19 -0
- package/src/startupLifecycle.ts +57 -0
- package/src/storybook/aiChatStory.tsx +244 -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 +1053 -0
- package/src/ui/AiChatView.tsx +308 -0
- package/src/ui/AttrFilterModal.tsx +120 -0
- package/src/ui/ServiceLogs.tsx +115 -0
- package/src/ui/SpanContentView.tsx +184 -0
- package/src/ui/SpanDetail.tsx +215 -0
- package/src/ui/SpanDetailFull.tsx +224 -0
- package/src/ui/SpanDetailPane.tsx +90 -0
- package/src/ui/TraceDetailsPane.tsx +175 -0
- package/src/ui/TraceList.tsx +254 -0
- package/src/ui/Waterfall.tsx +389 -0
- package/src/ui/aiChatModel.test.ts +391 -0
- package/src/ui/aiChatModel.ts +773 -0
- package/src/ui/aiState.ts +59 -0
- package/src/ui/app/TraceListPane.tsx +39 -0
- package/src/ui/app/TraceWorkspace.tsx +452 -0
- package/src/ui/app/useAppLayout.ts +101 -0
- package/src/ui/app/useTraceScreenData.ts +590 -0
- package/src/ui/atoms.ts +131 -0
- package/src/ui/cachedLoader.test.ts +23 -0
- package/src/ui/cachedLoader.ts +60 -0
- package/src/ui/filterParser.test.ts +56 -0
- package/src/ui/filterParser.ts +45 -0
- package/src/ui/format.ts +128 -0
- package/src/ui/loaders.ts +101 -0
- package/src/ui/persistence.ts +41 -0
- package/src/ui/primitives.tsx +198 -0
- package/src/ui/state.ts +6 -0
- package/src/ui/theme.ts +160 -0
- package/src/ui/traceDetailsWidth.repro.test.ts +126 -0
- package/src/ui/traceSortNav.repro.seed.ts +62 -0
- package/src/ui/traceSortNav.repro.test.ts +231 -0
- package/src/ui/useAttrFilterPicker.ts +89 -0
- package/src/ui/useKeyboardNav.ts +1023 -0
- 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.repro.seed.ts +86 -0
- package/src/ui/waterfallNav.repro.test.ts +271 -0
- package/src/ui/waterfallNav.test.ts +453 -0
- package/src/ui/waterfallNav.ts +75 -0
- package/web/dist/assets/index-B01z9BaO.css +2 -0
- package/web/dist/assets/index-CYlBaY9O.js +22 -0
- 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
|
+
})
|