@kitlangton/motel 0.2.4 → 0.2.6

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 (66) hide show
  1. package/AGENTS.md +23 -8
  2. package/README.md +13 -2
  3. package/package.json +35 -19
  4. package/skills/motel-debug/SKILL.md +203 -0
  5. package/skills/motel-debug/references/effect.md +38 -0
  6. package/src/App.tsx +12 -5
  7. package/src/StartupGate.tsx +289 -0
  8. package/src/cli.ts +15 -16
  9. package/src/config.ts +7 -1
  10. package/src/daemon.test.ts +332 -51
  11. package/src/daemon.ts +105 -153
  12. package/src/httpApi.ts +1 -0
  13. package/src/httpListPolicy.test.ts +76 -0
  14. package/src/httpListPolicy.ts +129 -0
  15. package/src/index.tsx +9 -2
  16. package/src/localServer.ts +194 -313
  17. package/src/mcp.ts +2 -1
  18. package/src/motel.ts +0 -2
  19. package/src/opentui-jsx.d.ts +11 -0
  20. package/src/otlp.test.ts +65 -0
  21. package/src/otlp.ts +20 -0
  22. package/src/otlpProtobuf.ts +35 -0
  23. package/src/registry.ts +37 -11
  24. package/src/runtime.ts +2 -6
  25. package/src/services/AsyncIngest.ts +22 -8
  26. package/src/services/LogQueryService.ts +13 -27
  27. package/src/services/TelemetryQuery.ts +62 -0
  28. package/src/services/TelemetryStore.ts +546 -231
  29. package/src/services/TraceQueryService.ts +22 -56
  30. package/src/services/ingestRpc.ts +2 -4
  31. package/src/services/queryRpc.ts +15 -0
  32. package/src/services/telemetryQueryWorker.ts +32 -0
  33. package/src/services/telemetryWorker.ts +5 -8
  34. package/src/startupBench.ts +19 -0
  35. package/src/storybook/aiChatStory.tsx +1 -1
  36. package/src/telemetry.test.ts +307 -41
  37. package/src/ui/AiChatView.tsx +1 -1
  38. package/src/ui/AttrFilterModal.tsx +1 -1
  39. package/src/ui/ServiceLogs.tsx +10 -7
  40. package/src/ui/SpanContentView.tsx +24 -21
  41. package/src/ui/TraceDetailsPane.tsx +1 -1
  42. package/src/ui/TraceList.tsx +1 -1
  43. package/src/ui/aiState.ts +10 -22
  44. package/src/ui/app/TraceWorkspace.tsx +2 -1
  45. package/src/ui/app/useAppLayout.ts +1 -1
  46. package/src/ui/app/useTraceScreenData.ts +35 -23
  47. package/src/ui/atoms.ts +1 -1
  48. package/src/ui/cachedLoader.test.ts +23 -0
  49. package/src/ui/cachedLoader.ts +60 -0
  50. package/src/ui/loaders.ts +34 -53
  51. package/src/ui/persistence.ts +3 -3
  52. package/src/ui/primitives.tsx +1 -1
  53. package/src/ui/state.ts +2 -0
  54. package/src/ui/theme.ts +7 -5
  55. package/src/ui/traceDetailsWidth.repro.test.ts +12 -1
  56. package/src/ui/traceSortNav.repro.seed.ts +1 -1
  57. package/src/ui/traceSortNav.repro.test.ts +12 -2
  58. package/src/ui/useAttrFilterPicker.ts +10 -8
  59. package/src/ui/useKeyboardNav.ts +28 -5
  60. package/src/ui/waterfallNav.repro.seed.ts +1 -1
  61. package/src/ui/waterfallNav.repro.test.ts +16 -8
  62. package/web/dist/assets/index-B01z9BaO.css +2 -0
  63. package/web/dist/assets/index-M86tcih5.js +22 -0
  64. package/web/dist/index.html +2 -2
  65. package/web/dist/assets/index-DnyVo03x.js +0 -27
  66. package/web/dist/assets/index-DzuHNBGV.css +0 -2
@@ -1,5 +1,6 @@
1
+ import { Database } from "bun:sqlite"
1
2
  import { afterAll, beforeAll, describe, expect, it } from "bun:test"
2
- import { mkdtempSync, rmSync } from "node:fs"
3
+ import { copyFileSync, mkdtempSync, rmSync } from "node:fs"
3
4
  import { tmpdir } from "node:os"
4
5
  import { join } from "node:path"
5
6
  import { Effect, References } from "effect"
@@ -8,6 +9,8 @@ import { attributeFiltersFromArgs, attributeContainsFiltersFromArgs, isAttribute
8
9
  describe("motel telemetry store", () => {
9
10
  const tempDir = mkdtempSync(join(tmpdir(), "motel-test-"))
10
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
11
14
  let storeRuntime: Awaited<typeof import("./runtime.ts")>["storeRuntime"]
12
15
  let TelemetryStore: Awaited<typeof import("./services/TelemetryStore.ts")>["TelemetryStore"]
13
16
  let motelOpenApiSpec: Awaited<typeof import("./httpApi.ts")>["motelOpenApiSpec"]
@@ -23,7 +26,7 @@ describe("motel telemetry store", () => {
23
26
  const nowNanos = BigInt(Date.now()) * 1_000_000n
24
27
  const oneSecond = 1_000_000_000n
25
28
 
26
- const ingest = Effect.flatMap(TelemetryStore.asEffect(), (store) =>
29
+ const ingest = Effect.flatMap(TelemetryStore, (store) =>
27
30
  Effect.flatMap(
28
31
  store.ingestTraces({
29
32
  resourceSpans: [
@@ -126,7 +129,7 @@ describe("motel telemetry store", () => {
126
129
  },
127
130
  ],
128
131
  }),
129
- ).pipe(Effect.flatMap(() => Effect.flatMap(TelemetryStore.asEffect(), (store) =>
132
+ ).pipe(Effect.flatMap(() => Effect.flatMap(TelemetryStore, (store) =>
130
133
  store.ingestTraces({
131
134
  resourceSpans: [{
132
135
  resource: { attributes: [{ key: "service.name", value: { stringValue: "test-api" } }] },
@@ -219,13 +222,192 @@ describe("motel telemetry store", () => {
219
222
  await storeRuntime.runPromise(ingest.pipe(Effect.provideService(References.MinimumLogLevel, "None")))
220
223
  })
221
224
 
222
- afterAll(() => {
225
+ afterAll(async () => {
226
+ await storeRuntime.dispose()
223
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
+ }
224
406
  })
225
407
 
226
408
  it("filters traces by attr.* fields", async () => {
227
409
  const result = await storeRuntime.runPromise(
228
- Effect.flatMap(TelemetryStore.asEffect(), (store) =>
410
+ Effect.flatMap(TelemetryStore, (store) =>
229
411
  store.searchTraces({
230
412
  serviceName: "test-api",
231
413
  attributeFilters: {
@@ -242,7 +424,7 @@ describe("motel telemetry store", () => {
242
424
 
243
425
  it("looks up a span directly by spanId", async () => {
244
426
  const result = await storeRuntime.runPromise(
245
- Effect.flatMap(TelemetryStore.asEffect(), (store) => store.getSpan("child-1")).pipe(
427
+ Effect.flatMap(TelemetryStore, (store) => store.getSpan("child-1")).pipe(
246
428
  Effect.provideService(References.MinimumLogLevel, "None"),
247
429
  ),
248
430
  )
@@ -253,9 +435,57 @@ describe("motel telemetry store", () => {
253
435
  expect(result?.span.depth).toBe(1)
254
436
  })
255
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
+
256
486
  it("filters logs by spanId", async () => {
257
487
  const result = await storeRuntime.runPromise(
258
- Effect.flatMap(TelemetryStore.asEffect(), (store) =>
488
+ Effect.flatMap(TelemetryStore, (store) =>
259
489
  store.searchLogs({
260
490
  spanId: "child-1",
261
491
  }),
@@ -268,7 +498,7 @@ describe("motel telemetry store", () => {
268
498
 
269
499
  it("searches spans by operation, parent operation, and attr filters", async () => {
270
500
  const result = await storeRuntime.runPromise(
271
- Effect.flatMap(TelemetryStore.asEffect(), (store) =>
501
+ Effect.flatMap(TelemetryStore, (store) =>
272
502
  store.searchSpans({
273
503
  serviceName: "test-api",
274
504
  operation: "tool.call",
@@ -288,7 +518,7 @@ describe("motel telemetry store", () => {
288
518
 
289
519
  it("lists spans for a trace", async () => {
290
520
  const result = await storeRuntime.runPromise(
291
- Effect.flatMap(TelemetryStore.asEffect(), (store) => store.listTraceSpans("trace-1")).pipe(
521
+ Effect.flatMap(TelemetryStore, (store) => store.listTraceSpans("trace-1")).pipe(
292
522
  Effect.provideService(References.MinimumLogLevel, "None"),
293
523
  ),
294
524
  )
@@ -303,7 +533,7 @@ describe("motel telemetry store", () => {
303
533
 
304
534
  it("aggregates trace stats by operation", async () => {
305
535
  const result = await storeRuntime.runPromise(
306
- Effect.flatMap(TelemetryStore.asEffect(), (store) =>
536
+ Effect.flatMap(TelemetryStore, (store) =>
307
537
  store.traceStats({
308
538
  groupBy: "operation",
309
539
  agg: "avg_duration",
@@ -321,7 +551,7 @@ describe("motel telemetry store", () => {
321
551
 
322
552
  it("aggregates log stats by severity", async () => {
323
553
  const result = await storeRuntime.runPromise(
324
- Effect.flatMap(TelemetryStore.asEffect(), (store) =>
554
+ Effect.flatMap(TelemetryStore, (store) =>
325
555
  store.logStats({
326
556
  groupBy: "severity",
327
557
  agg: "count",
@@ -347,7 +577,7 @@ describe("motel telemetry store", () => {
347
577
 
348
578
  it("lists trace summaries without loading spans", async () => {
349
579
  const result = await storeRuntime.runPromise(
350
- Effect.flatMap(TelemetryStore.asEffect(), (store) =>
580
+ Effect.flatMap(TelemetryStore, (store) =>
351
581
  store.listTraceSummaries(null),
352
582
  ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
353
583
  )
@@ -365,7 +595,7 @@ describe("motel telemetry store", () => {
365
595
 
366
596
  it("lists trace summaries filtered by service", async () => {
367
597
  const result = await storeRuntime.runPromise(
368
- Effect.flatMap(TelemetryStore.asEffect(), (store) =>
598
+ Effect.flatMap(TelemetryStore, (store) =>
369
599
  store.listTraceSummaries("test-api"),
370
600
  ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
371
601
  )
@@ -375,7 +605,7 @@ describe("motel telemetry store", () => {
375
605
 
376
606
  it("searches trace summaries with status filter", async () => {
377
607
  const result = await storeRuntime.runPromise(
378
- Effect.flatMap(TelemetryStore.asEffect(), (store) =>
608
+ Effect.flatMap(TelemetryStore, (store) =>
379
609
  store.searchTraceSummaries({
380
610
  serviceName: "test-api",
381
611
  status: "error",
@@ -389,7 +619,7 @@ describe("motel telemetry store", () => {
389
619
 
390
620
  it("searches trace summaries with attribute filters", async () => {
391
621
  const result = await storeRuntime.runPromise(
392
- Effect.flatMap(TelemetryStore.asEffect(), (store) =>
622
+ Effect.flatMap(TelemetryStore, (store) =>
393
623
  store.searchTraceSummaries({
394
624
  serviceName: "test-api",
395
625
  attributeFilters: { sessionID: "session-1" },
@@ -403,7 +633,7 @@ describe("motel telemetry store", () => {
403
633
 
404
634
  it("searches trace summaries with operation filter", async () => {
405
635
  const result = await storeRuntime.runPromise(
406
- Effect.flatMap(TelemetryStore.asEffect(), (store) =>
636
+ Effect.flatMap(TelemetryStore, (store) =>
407
637
  store.searchTraceSummaries({
408
638
  serviceName: "test-api",
409
639
  operation: "tool.call",
@@ -417,7 +647,7 @@ describe("motel telemetry store", () => {
417
647
 
418
648
  it("filters logs by severity", async () => {
419
649
  const result = await storeRuntime.runPromise(
420
- Effect.flatMap(TelemetryStore.asEffect(), (store) =>
650
+ Effect.flatMap(TelemetryStore, (store) =>
421
651
  store.searchLogs({ serviceName: "test-api", severity: "ERROR" }),
422
652
  ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
423
653
  )
@@ -429,7 +659,7 @@ describe("motel telemetry store", () => {
429
659
 
430
660
  it("filters logs by severity case-insensitively", async () => {
431
661
  const result = await storeRuntime.runPromise(
432
- Effect.flatMap(TelemetryStore.asEffect(), (store) =>
662
+ Effect.flatMap(TelemetryStore, (store) =>
433
663
  store.searchLogs({ serviceName: "test-api", severity: "error" }),
434
664
  ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
435
665
  )
@@ -440,7 +670,7 @@ describe("motel telemetry store", () => {
440
670
 
441
671
  it("searches log body case-insensitively", async () => {
442
672
  const result = await storeRuntime.runPromise(
443
- Effect.flatMap(TelemetryStore.asEffect(), (store) =>
673
+ Effect.flatMap(TelemetryStore, (store) =>
444
674
  store.searchLogs({ serviceName: "test-api", body: "STREAM FAILED" }),
445
675
  ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
446
676
  )
@@ -451,7 +681,7 @@ describe("motel telemetry store", () => {
451
681
 
452
682
  it("searches spans by traceId", async () => {
453
683
  const result = await storeRuntime.runPromise(
454
- Effect.flatMap(TelemetryStore.asEffect(), (store) =>
684
+ Effect.flatMap(TelemetryStore, (store) =>
455
685
  store.searchSpans({ traceId: "trace-1" }),
456
686
  ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
457
687
  )
@@ -462,7 +692,7 @@ describe("motel telemetry store", () => {
462
692
 
463
693
  it("searches spans with attrContains substring filter", async () => {
464
694
  const result = await storeRuntime.runPromise(
465
- Effect.flatMap(TelemetryStore.asEffect(), (store) =>
695
+ Effect.flatMap(TelemetryStore, (store) =>
466
696
  store.searchSpans({
467
697
  serviceName: "test-api",
468
698
  attributeContainsFilters: { sessionID: "session" },
@@ -476,7 +706,7 @@ describe("motel telemetry store", () => {
476
706
 
477
707
  it("searches spans with attrContains case-insensitively", async () => {
478
708
  const result = await storeRuntime.runPromise(
479
- Effect.flatMap(TelemetryStore.asEffect(), (store) =>
709
+ Effect.flatMap(TelemetryStore, (store) =>
480
710
  store.searchSpans({
481
711
  serviceName: "test-api",
482
712
  attributeContainsFilters: { modelID: "GPT" },
@@ -490,7 +720,7 @@ describe("motel telemetry store", () => {
490
720
 
491
721
  it("searches logs with attrContains substring filter", async () => {
492
722
  const result = await storeRuntime.runPromise(
493
- Effect.flatMap(TelemetryStore.asEffect(), (store) =>
723
+ Effect.flatMap(TelemetryStore, (store) =>
494
724
  store.searchLogs({
495
725
  serviceName: "test-api",
496
726
  attributeContainsFilters: { tool: "sea" },
@@ -504,7 +734,7 @@ describe("motel telemetry store", () => {
504
734
 
505
735
  it("combines severity and body filters", async () => {
506
736
  const result = await storeRuntime.runPromise(
507
- Effect.flatMap(TelemetryStore.asEffect(), (store) =>
737
+ Effect.flatMap(TelemetryStore, (store) =>
508
738
  store.searchLogs({ serviceName: "test-api", severity: "INFO", body: "tool" }),
509
739
  ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
510
740
  )
@@ -515,7 +745,7 @@ describe("motel telemetry store", () => {
515
745
 
516
746
  it("computes facet status without N+1 queries", async () => {
517
747
  const result = await storeRuntime.runPromise(
518
- Effect.flatMap(TelemetryStore.asEffect(), (store) =>
748
+ Effect.flatMap(TelemetryStore, (store) =>
519
749
  store.listFacets({
520
750
  type: "traces",
521
751
  field: "status",
@@ -532,7 +762,7 @@ describe("motel telemetry store", () => {
532
762
 
533
763
  it("computes logStats with SQL aggregation", async () => {
534
764
  const result = await storeRuntime.runPromise(
535
- Effect.flatMap(TelemetryStore.asEffect(), (store) =>
765
+ Effect.flatMap(TelemetryStore, (store) =>
536
766
  store.logStats({
537
767
  groupBy: "service",
538
768
  agg: "count",
@@ -548,7 +778,7 @@ describe("motel telemetry store", () => {
548
778
 
549
779
  it("computes traceStats count via SQL", async () => {
550
780
  const result = await storeRuntime.runPromise(
551
- Effect.flatMap(TelemetryStore.asEffect(), (store) =>
781
+ Effect.flatMap(TelemetryStore, (store) =>
552
782
  store.traceStats({
553
783
  groupBy: "status",
554
784
  agg: "count",
@@ -566,7 +796,7 @@ describe("motel telemetry store", () => {
566
796
 
567
797
  it("computes traceStats error_rate via SQL", async () => {
568
798
  const result = await storeRuntime.runPromise(
569
- Effect.flatMap(TelemetryStore.asEffect(), (store) =>
799
+ Effect.flatMap(TelemetryStore, (store) =>
570
800
  store.traceStats({
571
801
  groupBy: "service",
572
802
  agg: "error_rate",
@@ -613,7 +843,7 @@ describe("motel telemetry store", () => {
613
843
 
614
844
  it("searches AI calls and returns compact summaries", async () => {
615
845
  const result = await storeRuntime.runPromise(
616
- Effect.flatMap(TelemetryStore.asEffect(), (store) =>
846
+ Effect.flatMap(TelemetryStore, (store) =>
617
847
  store.searchAiCalls({}),
618
848
  ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
619
849
  )
@@ -636,7 +866,7 @@ describe("motel telemetry store", () => {
636
866
 
637
867
  it("dedupes nested doStream spans from AI summaries", async () => {
638
868
  const result = await storeRuntime.runPromise(
639
- Effect.flatMap(TelemetryStore.asEffect(), (store) =>
869
+ Effect.flatMap(TelemetryStore, (store) =>
640
870
  store.searchAiCalls({ sessionId: "ses_test123" }),
641
871
  ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
642
872
  )
@@ -647,7 +877,7 @@ describe("motel telemetry store", () => {
647
877
 
648
878
  it("filters AI calls by model", async () => {
649
879
  const result = await storeRuntime.runPromise(
650
- Effect.flatMap(TelemetryStore.asEffect(), (store) =>
880
+ Effect.flatMap(TelemetryStore, (store) =>
651
881
  store.searchAiCalls({ model: "claude-opus-4" }),
652
882
  ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
653
883
  )
@@ -659,7 +889,7 @@ describe("motel telemetry store", () => {
659
889
 
660
890
  it("filters AI calls by sessionId", async () => {
661
891
  const result = await storeRuntime.runPromise(
662
- Effect.flatMap(TelemetryStore.asEffect(), (store) =>
892
+ Effect.flatMap(TelemetryStore, (store) =>
663
893
  store.searchAiCalls({ sessionId: "ses_test123" }),
664
894
  ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
665
895
  )
@@ -670,7 +900,7 @@ describe("motel telemetry store", () => {
670
900
 
671
901
  it("searches AI calls by text content", async () => {
672
902
  const result = await storeRuntime.runPromise(
673
- Effect.flatMap(TelemetryStore.asEffect(), (store) =>
903
+ Effect.flatMap(TelemetryStore, (store) =>
674
904
  store.searchAiCalls({ text: "joke about programming" }),
675
905
  ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
676
906
  )
@@ -683,7 +913,7 @@ describe("motel telemetry store", () => {
683
913
  // Verifies FTS indexes ai.response.text, not just ai.prompt*. The
684
914
  // seeded ai-stream-2 has response "Error: rate limited".
685
915
  const result = await storeRuntime.runPromise(
686
- Effect.flatMap(TelemetryStore.asEffect(), (store) =>
916
+ Effect.flatMap(TelemetryStore, (store) =>
687
917
  store.searchAiCalls({ text: "rate limited" }),
688
918
  ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
689
919
  )
@@ -694,7 +924,7 @@ describe("motel telemetry store", () => {
694
924
  // unicode61 tokenizer is case-insensitive by default; prefix `*`
695
925
  // handles partial terms like `"PROG"` matching `"programming"`.
696
926
  const result = await storeRuntime.runPromise(
697
- Effect.flatMap(TelemetryStore.asEffect(), (store) =>
927
+ Effect.flatMap(TelemetryStore, (store) =>
698
928
  store.searchAiCalls({ text: "PROG" }),
699
929
  ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
700
930
  )
@@ -705,7 +935,7 @@ describe("motel telemetry store", () => {
705
935
  // FTS5 treats `"`, `*`, `-`, `:` as operators; toFtsQuery must
706
936
  // strip them so raw user input never crashes the query.
707
937
  const result = await storeRuntime.runPromise(
708
- Effect.flatMap(TelemetryStore.asEffect(), (store) =>
938
+ Effect.flatMap(TelemetryStore, (store) =>
709
939
  store.searchAiCalls({ text: `"joke" - about:programming*` }),
710
940
  ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
711
941
  )
@@ -714,7 +944,7 @@ describe("motel telemetry store", () => {
714
944
 
715
945
  it("filters AI calls by operation type", async () => {
716
946
  const result = await storeRuntime.runPromise(
717
- Effect.flatMap(TelemetryStore.asEffect(), (store) =>
947
+ Effect.flatMap(TelemetryStore, (store) =>
718
948
  store.searchAiCalls({ operation: "generateText" }),
719
949
  ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
720
950
  )
@@ -725,7 +955,7 @@ describe("motel telemetry store", () => {
725
955
 
726
956
  it("gets AI call detail with full payloads", async () => {
727
957
  const result = await storeRuntime.runPromise(
728
- Effect.flatMap(TelemetryStore.asEffect(), (store) =>
958
+ Effect.flatMap(TelemetryStore, (store) =>
729
959
  store.getAiCall("ai-stream-1"),
730
960
  ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
731
961
  )
@@ -743,7 +973,7 @@ describe("motel telemetry store", () => {
743
973
 
744
974
  it("returns null for non-AI span in getAiCall", async () => {
745
975
  const result = await storeRuntime.runPromise(
746
- Effect.flatMap(TelemetryStore.asEffect(), (store) =>
976
+ Effect.flatMap(TelemetryStore, (store) =>
747
977
  store.getAiCall("root-1"),
748
978
  ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
749
979
  )
@@ -753,7 +983,7 @@ describe("motel telemetry store", () => {
753
983
 
754
984
  it("aggregates AI call stats by model", async () => {
755
985
  const result = await storeRuntime.runPromise(
756
- Effect.flatMap(TelemetryStore.asEffect(), (store) =>
986
+ Effect.flatMap(TelemetryStore, (store) =>
757
987
  store.aiCallStats({ groupBy: "model", agg: "count" }),
758
988
  ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
759
989
  )
@@ -767,7 +997,7 @@ describe("motel telemetry store", () => {
767
997
 
768
998
  it("aggregates AI call stats by status", async () => {
769
999
  const result = await storeRuntime.runPromise(
770
- Effect.flatMap(TelemetryStore.asEffect(), (store) =>
1000
+ Effect.flatMap(TelemetryStore, (store) =>
771
1001
  store.aiCallStats({ groupBy: "status", agg: "count" }),
772
1002
  ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
773
1003
  )
@@ -784,4 +1014,40 @@ describe("motel telemetry store", () => {
784
1014
  expect(motelOpenApiSpec.paths["/api/ai/calls/{spanId}"]).toBeDefined()
785
1015
  expect(motelOpenApiSpec.paths["/api/ai/stats"]).toBeDefined()
786
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
+ })
787
1053
  })
@@ -11,7 +11,7 @@ import {
11
11
  } from "./aiChatModel.ts"
12
12
  import { formatDuration, truncateText } from "./format.ts"
13
13
  import { AlignedHeaderLine, BlankRow, Divider, PlainLine, TextLine } from "./primitives.tsx"
14
- import type { AiCallDetailState } from "./state.ts"
14
+ import type { AiCallDetailState } from "./aiState.ts"
15
15
  import { colors, SEPARATOR } from "./theme.ts"
16
16
 
17
17
  export const AI_CHAT_HEADER_ROWS = 4
@@ -2,7 +2,7 @@ import { RGBA, TextAttributes } from "@opentui/core"
2
2
  import { BlankRow, TextLine } from "./primitives.tsx"
3
3
  import { colors } from "./theme.ts"
4
4
  import { fitCell, truncateText } from "./format.ts"
5
- import type { AttrFacetState, AttrPickerMode } from "./state.ts"
5
+ import type { AttrFacetState, AttrPickerMode } from "./atoms.ts"
6
6
 
7
7
  export interface AttrFilterModalProps {
8
8
  readonly width: number