@aryasaatvik/motel 0.5.0 → 0.5.2
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 +2 -2
- package/package.json +6 -6
- package/skills/motel-debug/references/effect.md +1 -1
- package/src/cli.ts +1 -1
- package/src/httpApi.ts +1 -1
- package/src/localServer.ts +5 -5
- package/src/mcp.ts +1 -1
- package/src/motel.ts +1 -1
- package/src/services/AsyncIngest.ts +3 -3
- package/src/services/TelemetryStore.ts +23 -10
- package/src/services/ingestRpc.ts +2 -2
- package/src/services/retention.test.ts +150 -1
- package/src/services/retention.ts +44 -1
- package/src/services/telemetryWorker.ts +1 -1
- package/src/telemetry.test.ts +224 -0
- package/src/ui/aiState.ts +1 -1
- package/src/ui/atoms.ts +1 -1
- package/src/ui/serviceSelection.test.ts +1 -1
- package/web/dist/assets/index-BZhmmoqP.js +21 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-BuWb6t2M.js +0 -21
package/src/telemetry.test.ts
CHANGED
|
@@ -407,6 +407,194 @@ describe("motel telemetry store", () => {
|
|
|
407
407
|
}
|
|
408
408
|
})
|
|
409
409
|
|
|
410
|
+
|
|
411
|
+
it("keeps operation search and mappings atomic across updates, legacy repair and ingest rollback", async () => {
|
|
412
|
+
const isolatedPath = join(tempDir, "operation-savepoint.sqlite")
|
|
413
|
+
const script = `
|
|
414
|
+
const assert = (await import('node:assert/strict')).default
|
|
415
|
+
const { Database } = await import('bun:sqlite')
|
|
416
|
+
const { Effect, ManagedRuntime } = await import('effect')
|
|
417
|
+
const { TelemetryStore, makeTelemetryStoreLayer } = await import('./src/services/TelemetryStore.ts')
|
|
418
|
+
const { repairSearchRows, installRetentionSchema, SPAN_SEARCH_MAPPING_READY_KEY } = await import('./src/services/retention.ts')
|
|
419
|
+
const runtime = ManagedRuntime.make(makeTelemetryStoreLayer({ readonly: false, runRetention: false }))
|
|
420
|
+
let db
|
|
421
|
+
const now = BigInt(Date.now()) * 1000000n
|
|
422
|
+
const span = (traceId, spanId, name) => ({ traceId, spanId, name, startTimeUnixNano: String(now), endTimeUnixNano: String(now + 1000000n) })
|
|
423
|
+
const ingest = (spans) => runtime.runPromise(Effect.flatMap(TelemetryStore, (store) => store.ingestTraces({ resourceSpans: [{ resource: { attributes: [{ key: 'service.name', value: { stringValue: 'fts-regression' } }] }, scopeSpans: [{ spans }] }] })))
|
|
424
|
+
const names = (traceId) => db.query('SELECT operation_name FROM span_operation_fts WHERE trace_id = ? ORDER BY operation_name').all(traceId).map((row) => row.operation_name)
|
|
425
|
+
const ready = () => db.query('SELECT value FROM motel_maintenance WHERE key = ?').get(SPAN_SEARCH_MAPPING_READY_KEY)?.value
|
|
426
|
+
const mappingCounts = () => db.query('SELECT (SELECT count(*) FROM span_operation_fts) AS fts, (SELECT count(*) FROM span_search_rows) AS mapping, (SELECT count(*) FROM span_operation_fts f LEFT JOIN span_search_rows m ON m.id = f.rowid WHERE m.id IS NULL) AS unmapped').get()
|
|
427
|
+
try {
|
|
428
|
+
await ingest([span('normal', 'root', 'oldoperation')])
|
|
429
|
+
db = new Database(process.env.MOTEL_OTEL_DB_PATH)
|
|
430
|
+
assert.equal(ready(), 'complete')
|
|
431
|
+
await ingest([span('normal', 'root', 'newoperation')])
|
|
432
|
+
assert.deepEqual(names('normal'), ['newoperation'])
|
|
433
|
+
assert.equal((await runtime.runPromise(Effect.flatMap(TelemetryStore, (store) => store.searchSpans({ operation: 'oldoperation' })))).length, 0)
|
|
434
|
+
assert.equal((await runtime.runPromise(Effect.flatMap(TelemetryStore, (store) => store.searchSpans({ operation: 'newoperation' })))).length, 1)
|
|
435
|
+
|
|
436
|
+
await ingest(Array.from({ length: 32 }, (_, i) => span('mixed-' + i, 'root', 'mixedold')))
|
|
437
|
+
await ingest(Array.from({ length: 64 }, (_, i) => span('mixed-' + i, 'root', 'mixednew')))
|
|
438
|
+
assert.equal(db.query("SELECT count(*) AS n FROM span_operation_fts WHERE operation_name = 'mixedold'").get().n, 0)
|
|
439
|
+
assert.equal(db.query("SELECT count(*) AS n FROM span_operation_fts WHERE operation_name = 'mixednew'").get().n, 64)
|
|
440
|
+
|
|
441
|
+
await ingest([span('duplicate', 'root', 'first'), span('duplicate', 'root', 'second')])
|
|
442
|
+
assert.deepEqual(names('duplicate'), ['first', 'second'])
|
|
443
|
+
const acrossBoundary = [span('duplicate', 'root', 'early')]
|
|
444
|
+
for (let i = 1; i < 499; i++) acrossBoundary.push(span('filler-' + i, 'root', 'filler'))
|
|
445
|
+
acrossBoundary.push(span('duplicate', 'root', 'middle'), span('duplicate', 'root', 'lateone'), span('duplicate', 'root', 'latetwo'))
|
|
446
|
+
await ingest(acrossBoundary)
|
|
447
|
+
assert.deepEqual(names('duplicate'), ['lateone', 'latetwo'])
|
|
448
|
+
assert.equal(db.query("SELECT operation_name FROM spans WHERE trace_id = 'duplicate' AND span_id = 'root'").get().operation_name, 'latetwo')
|
|
449
|
+
|
|
450
|
+
// Simulate an upgraded database with unmapped historical FTS rows.
|
|
451
|
+
db.query("DELETE FROM motel_maintenance WHERE key IN (?, 'retention_repair_span_operation_fts')").run(SPAN_SEARCH_MAPPING_READY_KEY)
|
|
452
|
+
db.exec("DELETE FROM span_search_rows WHERE trace_id = 'normal'")
|
|
453
|
+
db.query("INSERT OR REPLACE INTO motel_maintenance VALUES ('retention_repair_span_operation_fts', '999999')").run()
|
|
454
|
+
installRetentionSchema(db)
|
|
455
|
+
await ingest([span('normal', 'root', 'legacyupdated')])
|
|
456
|
+
assert.deepEqual(names('normal'), ['legacyupdated'])
|
|
457
|
+
assert.match(ready(), /^repairing:[1-9][0-9]*$/)
|
|
458
|
+
for (let pass = 0; ready() !== 'complete' && pass < 20; pass++) repairSearchRows(db, 100)
|
|
459
|
+
assert.equal(ready(), 'complete')
|
|
460
|
+
|
|
461
|
+
// Fail mapping after FTS insertion; every chunk of the optional unit must roll back.
|
|
462
|
+
const before = names('normal')
|
|
463
|
+
const beforeCounts = mappingCounts()
|
|
464
|
+
db.exec("CREATE TRIGGER reject_mapping BEFORE INSERT ON span_search_rows WHEN NEW.trace_id = 'normal' BEGIN SELECT RAISE(ABORT, 'mapping failure'); END")
|
|
465
|
+
const failedChunks = Array.from({ length: 500 }, (_, i) => span('failed-chunk-' + i, 'root', 'mustrollback'))
|
|
466
|
+
failedChunks.push(span('normal', 'root', 'committedspan'))
|
|
467
|
+
await ingest(failedChunks)
|
|
468
|
+
assert.deepEqual(names('normal'), before)
|
|
469
|
+
assert.deepEqual(mappingCounts(), beforeCounts)
|
|
470
|
+
assert.equal(db.query("SELECT count(*) AS n FROM span_operation_fts WHERE operation_name = 'mustrollback'").get().n, 0)
|
|
471
|
+
assert.equal(db.query("SELECT operation_name FROM spans WHERE trace_id = 'normal' AND span_id = 'root'").get().operation_name, 'committedspan')
|
|
472
|
+
assert.equal(ready(), 'complete')
|
|
473
|
+
db.exec('DROP TRIGGER reject_mapping')
|
|
474
|
+
await ingest([span('normal', 'root', 'recovered')])
|
|
475
|
+
assert.deepEqual(names('normal'), ['recovered'])
|
|
476
|
+
|
|
477
|
+
const stableNames = names('normal')
|
|
478
|
+
const stableCounts = mappingCounts()
|
|
479
|
+
db.exec("CREATE TRIGGER reject_summary BEFORE INSERT ON trace_summaries WHEN NEW.trace_id = 'normal' BEGIN SELECT RAISE(ABORT, 'summary failure'); END")
|
|
480
|
+
await assert.rejects(ingest([span('normal', 'root', 'outerrollback')]))
|
|
481
|
+
assert.deepEqual(names('normal'), stableNames)
|
|
482
|
+
assert.deepEqual(mappingCounts(), stableCounts)
|
|
483
|
+
assert.equal(db.query("SELECT operation_name FROM spans WHERE trace_id = 'normal' AND span_id = 'root'").get().operation_name, 'recovered')
|
|
484
|
+
assert.equal(ready(), 'complete')
|
|
485
|
+
db.exec('DROP TRIGGER reject_summary')
|
|
486
|
+
} finally {
|
|
487
|
+
db?.close()
|
|
488
|
+
await runtime.dispose()
|
|
489
|
+
}
|
|
490
|
+
`
|
|
491
|
+
const child = Bun.spawn({
|
|
492
|
+
cmd: [process.execPath, "--no-env-file", "-e", script], cwd: process.cwd(),
|
|
493
|
+
env: { ...process.env, MOTEL_OTEL_DB_PATH: isolatedPath, MOTEL_OTEL_ENABLED: "false" },
|
|
494
|
+
stdout: "ignore", stderr: "pipe",
|
|
495
|
+
})
|
|
496
|
+
const stderr = await new Response(child.stderr).text()
|
|
497
|
+
expect(await child.exited, stderr).toBe(0)
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
it("repairs legacy span mappings at the pending cadence and idles after completion", async () => {
|
|
502
|
+
const isolatedPath = join(tempDir, "mapping-repair-pacing.sqlite")
|
|
503
|
+
const script = `
|
|
504
|
+
const assert = (await import('node:assert/strict')).default
|
|
505
|
+
const { Database } = await import('bun:sqlite')
|
|
506
|
+
const { Clock, Deferred, Effect, Layer, ManagedRuntime } = await import('effect')
|
|
507
|
+
const { TestClock } = await import('effect/testing')
|
|
508
|
+
const { TelemetryStore, makeTelemetryStoreLayer } = await import('./src/services/TelemetryStore.ts')
|
|
509
|
+
const { WriterDiagnostics } = await import('./src/ingestReadiness.ts')
|
|
510
|
+
const { SPAN_SEARCH_MAPPING_READY_KEY } = await import('./src/services/retention.ts')
|
|
511
|
+
const now = Date.now()
|
|
512
|
+
const nanos = BigInt(now) * 1000000n
|
|
513
|
+
const seed = ManagedRuntime.make(makeTelemetryStoreLayer({ readonly: false, runRetention: false }))
|
|
514
|
+
try {
|
|
515
|
+
await seed.runPromise(Effect.flatMap(TelemetryStore, (store) => store.ingestTraces({
|
|
516
|
+
resourceSpans: [{
|
|
517
|
+
resource: { attributes: [{ key: 'service.name', value: { stringValue: 'mapping-pacing' } }] },
|
|
518
|
+
scopeSpans: [{ spans: Array.from({ length: 250 }, (_, id) => ({
|
|
519
|
+
traceId: 'pacing-' + id, spanId: 'root', name: 'legacyoperation',
|
|
520
|
+
startTimeUnixNano: String(nanos), endTimeUnixNano: String(nanos + 1000000n),
|
|
521
|
+
})) }],
|
|
522
|
+
}],
|
|
523
|
+
})))
|
|
524
|
+
} finally { await seed.dispose() }
|
|
525
|
+
const db = new Database(process.env.MOTEL_OTEL_DB_PATH)
|
|
526
|
+
try {
|
|
527
|
+
db.exec('DELETE FROM span_search_rows')
|
|
528
|
+
db.query('DELETE FROM motel_maintenance WHERE key = ?').run(SPAN_SEARCH_MAPPING_READY_KEY)
|
|
529
|
+
db.exec("INSERT OR REPLACE INTO motel_maintenance VALUES ('retention_repair_span_operation_fts', '999999')")
|
|
530
|
+
await Effect.runPromise(Effect.scoped(Effect.gen(function* () {
|
|
531
|
+
const clock = yield* TestClock.make()
|
|
532
|
+
yield* clock.setTime(now + 2)
|
|
533
|
+
const firstPass = Deferred.makeUnsafe()
|
|
534
|
+
const passes = []
|
|
535
|
+
const publish = (event) => {
|
|
536
|
+
if (event._tag !== 'maintenance' || event.value.operation !== 'retention' || event.value.outcome === 'running') return
|
|
537
|
+
passes.push({
|
|
538
|
+
outcome: event.value.outcome,
|
|
539
|
+
mapped: db.query('SELECT count(*) AS n FROM span_search_rows').get().n,
|
|
540
|
+
state: db.query('SELECT value FROM motel_maintenance WHERE key = ?').get(SPAN_SEARCH_MAPPING_READY_KEY)?.value,
|
|
541
|
+
})
|
|
542
|
+
Deferred.doneUnsafe(firstPass, Effect.void)
|
|
543
|
+
}
|
|
544
|
+
const runtime = ManagedRuntime.make(makeTelemetryStoreLayer({ readonly: false, runRetention: true }).pipe(
|
|
545
|
+
Layer.provide(Layer.succeed(Clock.Clock, clock)),
|
|
546
|
+
Layer.provide(Layer.succeed(WriterDiagnostics, publish)),
|
|
547
|
+
))
|
|
548
|
+
yield* Effect.addFinalizer(() => runtime.disposeEffect)
|
|
549
|
+
yield* Effect.promise(() => runtime.runPromise(Effect.asVoid(TelemetryStore)))
|
|
550
|
+
yield* Deferred.await(firstPass).pipe(Effect.timeout('5 seconds'))
|
|
551
|
+
assert.deepEqual(passes, [{ outcome: 'completed', mapped: 100, state: 'repairing:250' }])
|
|
552
|
+
yield* clock.adjust('100 millis')
|
|
553
|
+
assert.deepEqual(passes, [
|
|
554
|
+
{ outcome: 'completed', mapped: 100, state: 'repairing:250' },
|
|
555
|
+
{ outcome: 'completed', mapped: 200, state: 'repairing:250' },
|
|
556
|
+
])
|
|
557
|
+
yield* clock.adjust('100 millis')
|
|
558
|
+
assert.deepEqual(passes, [
|
|
559
|
+
{ outcome: 'completed', mapped: 100, state: 'repairing:250' },
|
|
560
|
+
{ outcome: 'completed', mapped: 200, state: 'repairing:250' },
|
|
561
|
+
{ outcome: 'completed', mapped: 250, state: 'complete' },
|
|
562
|
+
])
|
|
563
|
+
yield* clock.adjust('100 millis')
|
|
564
|
+
assert.equal(passes.length, 3)
|
|
565
|
+
yield* clock.adjust('9900 millis')
|
|
566
|
+
assert.deepEqual(passes.at(-1), { outcome: 'completed', mapped: 250, state: 'complete' })
|
|
567
|
+
assert.equal(passes.length, 4)
|
|
568
|
+
yield* runtime.disposeEffect
|
|
569
|
+
yield* clock.adjust('30 seconds')
|
|
570
|
+
assert.equal(passes.length, 4)
|
|
571
|
+
})))
|
|
572
|
+
} finally { db.close() }
|
|
573
|
+
`
|
|
574
|
+
const child = Bun.spawn({
|
|
575
|
+
cmd: [process.execPath, "--no-env-file", "-e", script], cwd: process.cwd(),
|
|
576
|
+
env: {
|
|
577
|
+
...process.env,
|
|
578
|
+
MOTEL_OTEL_DB_PATH: isolatedPath, MOTEL_OTEL_ENABLED: "false",
|
|
579
|
+
MOTEL_OTEL_RETENTION_ROW_BATCH: "1000", MOTEL_OTEL_RETENTION_INTERVAL_SECONDS: "10",
|
|
580
|
+
MOTEL_OTEL_RETENTION_TRACE_BATCH: "100", MOTEL_OTEL_RETENTION_HOURS: "168",
|
|
581
|
+
MOTEL_OTEL_MAX_DB_SIZE_MB: "1024",
|
|
582
|
+
},
|
|
583
|
+
stdout: "ignore", stderr: "pipe",
|
|
584
|
+
})
|
|
585
|
+
let timedOut = false
|
|
586
|
+
const deadline = setTimeout(() => { timedOut = true; child.kill() }, 15_000)
|
|
587
|
+
try {
|
|
588
|
+
const [exitCode, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()])
|
|
589
|
+
expect(timedOut, stderr).toBe(false)
|
|
590
|
+
expect(exitCode, stderr).toBe(0)
|
|
591
|
+
} finally {
|
|
592
|
+
clearTimeout(deadline)
|
|
593
|
+
if (child.exitCode === null) child.kill()
|
|
594
|
+
await child.exited
|
|
595
|
+
}
|
|
596
|
+
}, 20_000)
|
|
597
|
+
|
|
410
598
|
it("incrementally backfills historical AI FTS content", async () => {
|
|
411
599
|
const historicalPath = join(tempDir, "historical-fts.sqlite")
|
|
412
600
|
const seedScript = `
|
|
@@ -468,6 +656,42 @@ describe("motel telemetry store", () => {
|
|
|
468
656
|
expect(result?.span.depth).toBe(1)
|
|
469
657
|
})
|
|
470
658
|
|
|
659
|
+
|
|
660
|
+
it("opens and searches a read-only pre-retention database without creating mappings", async () => {
|
|
661
|
+
const legacyPath = join(tempDir, "readonly-pre-retention.sqlite")
|
|
662
|
+
const script = `
|
|
663
|
+
const assert = (await import('node:assert/strict')).default
|
|
664
|
+
const { Database } = await import('bun:sqlite')
|
|
665
|
+
const { Effect, ManagedRuntime } = await import('effect')
|
|
666
|
+
const { TelemetryStore, makeTelemetryStoreLayer, TelemetryStoreReadonly, TelemetryStoreReadonlyLive } = await import('./src/services/TelemetryStore.ts')
|
|
667
|
+
const writer = ManagedRuntime.make(makeTelemetryStoreLayer({ readonly: false, runRetention: false }))
|
|
668
|
+
try {
|
|
669
|
+
const now = BigInt(Date.now()) * 1000000n
|
|
670
|
+
await writer.runPromise(Effect.flatMap(TelemetryStore, (store) => store.ingestTraces({ resourceSpans: [{ resource: { attributes: [{ key: 'service.name', value: { stringValue: 'legacy-readonly' } }] }, scopeSpans: [{ spans: [{ traceId: 'legacy-read', spanId: 'root', name: 'legacyoperation', startTimeUnixNano: String(now), endTimeUnixNano: String(now + 1000000n) }] }] }] })))
|
|
671
|
+
} finally { await writer.dispose() }
|
|
672
|
+
const legacy = new Database(process.env.MOTEL_OTEL_DB_PATH)
|
|
673
|
+
legacy.exec('DROP VIEW retained_spans; DROP VIEW retained_logs; DROP VIEW retained_trace_summaries; DROP TABLE span_search_rows; DROP TABLE log_search_rows; DROP TABLE retention_traces; DROP TABLE retention_logs;')
|
|
674
|
+
const version = legacy.query('PRAGMA schema_version').get().schema_version
|
|
675
|
+
legacy.close()
|
|
676
|
+
const reader = ManagedRuntime.make(TelemetryStoreReadonlyLive)
|
|
677
|
+
try {
|
|
678
|
+
const trace = await reader.runPromise(Effect.flatMap(TelemetryStoreReadonly, (store) => store.getTrace('legacy-read')))
|
|
679
|
+
assert.equal(trace?.spans.length, 1)
|
|
680
|
+
const found = await reader.runPromise(Effect.flatMap(TelemetryStoreReadonly, (store) => store.searchSpans({ operation: 'legacyoperation' })))
|
|
681
|
+
assert.equal(found.length, 1)
|
|
682
|
+
assert.equal(found[0].traceId, 'legacy-read')
|
|
683
|
+
} finally { await reader.dispose() }
|
|
684
|
+
const verify = new Database(process.env.MOTEL_OTEL_DB_PATH, { readonly: true })
|
|
685
|
+
try {
|
|
686
|
+
assert.equal(verify.query('PRAGMA schema_version').get().schema_version, version)
|
|
687
|
+
assert.equal(verify.query("SELECT count(*) AS n FROM sqlite_master WHERE name IN ('span_search_rows', 'log_search_rows', 'retained_spans')").get().n, 0)
|
|
688
|
+
} finally { verify.close() }
|
|
689
|
+
`
|
|
690
|
+
const child = Bun.spawn({ cmd: [process.execPath, "--no-env-file", "-e", script], cwd: process.cwd(), env: { ...process.env, MOTEL_OTEL_DB_PATH: legacyPath }, stdout: "ignore", stderr: "pipe" })
|
|
691
|
+
const [exitCode, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()])
|
|
692
|
+
expect(exitCode, stderr).toBe(0)
|
|
693
|
+
})
|
|
694
|
+
|
|
471
695
|
it("returns trace details through the query worker when spans share empty event arrays", async () => {
|
|
472
696
|
const workerDbPath = join(tempDir, "query-worker-shared-events.sqlite")
|
|
473
697
|
const script = `
|
package/src/ui/aiState.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Effect } from "effect"
|
|
2
|
-
import * as Atom from "effect/
|
|
2
|
+
import * as Atom from "effect/reactivity/Atom"
|
|
3
3
|
import type { AiCallDetail } from "../domain.ts"
|
|
4
4
|
import { queryRuntime } from "../runtime.ts"
|
|
5
5
|
import { TelemetryStoreReadonly } from "../services/TelemetryStore.ts"
|
package/src/ui/atoms.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import * as Atom from "effect/
|
|
1
|
+
import * as Atom from "effect/reactivity/Atom"
|
|
2
2
|
import { config, explicitServiceName } from "../config.ts"
|
|
3
3
|
import type { LogItem, TraceItem, TraceSummaryItem } from "../domain.ts"
|
|
4
4
|
import type { ThemeName } from "./theme.ts"
|
|
@@ -20,7 +20,7 @@ describe("initial TUI service selection", () => {
|
|
|
20
20
|
|
|
21
21
|
// Fresh processes exercise import-time config and persistence without module-cache leakage.
|
|
22
22
|
const result = Bun.spawnSync([process.execPath, "--no-env-file", "--eval", `
|
|
23
|
-
import * as AtomRegistry from "effect/
|
|
23
|
+
import * as AtomRegistry from "effect/reactivity/AtomRegistry"
|
|
24
24
|
import { config } from "./src/config.ts"
|
|
25
25
|
import { selectedTraceServiceAtom } from "./src/ui/atoms.ts"
|
|
26
26
|
const registry = AtomRegistry.make()
|