@aryasaatvik/motel 0.3.4 → 0.4.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.
@@ -2,6 +2,8 @@ import { Database } from "bun:sqlite"
2
2
  import * as BunFileSystem from "@effect/platform-bun/BunFileSystem"
3
3
  import { dirname } from "node:path"
4
4
  import { Cause, Clock, Effect, FileSystem, Layer, Schedule, Context } from "effect"
5
+ import { installRetentionSchema, checkpointPassive, mergeFts, retainBatch, repairSearchRows } from "./retention.js"
6
+ import { WriterDiagnostics } from "../ingestReadiness.ts"
5
7
  import { config } from "../config.js"
6
8
  import type { AiCallDetail, AiCallSummary, FacetItem, LogItem, SpanItem, StatsItem, TraceItem, TraceSummaryItem, TraceSpanEvent, TraceSpanItem } from "../domain.js"
7
9
  import { AI_ATTR_MAP, AI_FTS_KEYS, AI_TEXT_SEARCH_KEYS, truncatePreview } from "../domain.js"
@@ -568,12 +570,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
568
570
  -- this the WAL happily runs into the hundreds of MB and queries
569
571
  -- start paying the cost of walking the WAL on every read.
570
572
  PRAGMA wal_autocheckpoint = 4000;
571
- -- Hard floor for the WAL file. Auto-checkpoint controls *when*
572
- -- pages move out of the WAL; size_limit controls how much the
573
- -- WAL file is allowed to grow on disk. 128MB is generous enough
574
- -- to absorb a long write burst without blocking on truncation,
575
- -- tight enough that a wedged retention loop can't hide a 20GB
576
- -- WAL the way a default no-limit configuration can.
573
+ -- Retained WAL size after a reset; not a cap while readers hold snapshots.
577
574
  PRAGMA journal_size_limit = 134217728;
578
575
 
579
576
  CREATE TABLE IF NOT EXISTS spans (
@@ -616,6 +613,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
616
613
  CREATE INDEX IF NOT EXISTS idx_logs_trace_time ON logs(trace_id, timestamp_ms DESC);
617
614
  CREATE INDEX IF NOT EXISTS idx_logs_span_time ON logs(span_id, timestamp_ms DESC);
618
615
  CREATE INDEX IF NOT EXISTS idx_logs_severity_time ON logs(severity_text, timestamp_ms DESC);
616
+ CREATE INDEX IF NOT EXISTS idx_logs_severity_nocase_time ON logs(severity_text COLLATE NOCASE, timestamp_ms DESC);
619
617
 
620
618
  CREATE TABLE IF NOT EXISTS trace_summaries (
621
619
  trace_id TEXT PRIMARY KEY,
@@ -786,6 +784,18 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
786
784
  try { db.exec(`PRAGMA busy_timeout = 15000;`) } catch { /* ignore */ }
787
785
  } // end: if (!opts.readonly) writer init
788
786
 
787
+ if (!opts.readonly) {
788
+ installRetentionSchema(db)
789
+ db.exec("CREATE INDEX IF NOT EXISTS idx_spans_roots ON spans(trace_id, start_time_ms) WHERE parent_span_id IS NULL")
790
+ }
791
+ const hasRetention = db.query("SELECT 1 FROM sqlite_master WHERE name = 'retained_spans'").get() !== null
792
+ const spanSource = hasRetention ? "retained_spans" : "spans"
793
+ const logSource = hasRetention ? "retained_logs" : "logs"
794
+ const traceSource = hasRetention ? "retained_trace_summaries" : "trace_summaries"
795
+ const evictingTrace = hasRetention ? db.query("SELECT 1 FROM retention_traces WHERE trace_id = ?") : null
796
+ const mapSpanSearch = hasRetention ? db.query("INSERT OR REPLACE INTO span_search_rows VALUES (?, ?, ?)") : null
797
+ const mapLogSearch = hasRetention ? db.query("INSERT OR REPLACE INTO log_search_rows VALUES (?, ?)") : null
798
+
789
799
  const insertSpan = db.query(`
790
800
  INSERT INTO spans (
791
801
  trace_id, span_id, parent_span_id, service_name, scope_name, operation_name, kind,
@@ -833,7 +843,11 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
833
843
  return
834
844
  }
835
845
  const transaction = db.transaction(() => {
836
- for (const traceId of new Set(rows.map((row) => row.trace_id))) upsertTraceSummary.run(traceId)
846
+ for (const traceId of new Set(rows.map((row) => row.trace_id))) {
847
+ // Ingest updates existing summaries transactionally. Rebuilding them on every
848
+ // maintenance tick repeatedly scans large traces and delays unrelated writes.
849
+ if (!evictingTrace?.get(traceId) && !db.query("SELECT 1 FROM trace_summaries WHERE trace_id = ?").get(traceId)) upsertTraceSummary.run(traceId)
850
+ }
837
851
  db.query(`INSERT OR REPLACE INTO motel_maintenance(key, value) VALUES ('trace_summary_cursor', ?)`).run(String(rows.at(-1)!.rowid))
838
852
  })
839
853
  transaction()
@@ -866,7 +880,9 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
866
880
  if (operations.length === 1) {
867
881
  const [traceId, spanId, operationName] = operations[0]!
868
882
  deleteSpanOperationSearch.run(traceId, spanId)
869
- insertSpanOperationSearch.run(traceId, spanId, operationName)
883
+ db.query("DELETE FROM span_search_rows WHERE trace_id = ? AND span_id = ?").run(traceId, spanId)
884
+ const result = insertSpanOperationSearch.run(traceId, spanId, operationName)
885
+ mapSpanSearch?.run(Number(result.lastInsertRowid), traceId, spanId)
870
886
  return
871
887
  }
872
888
 
@@ -876,13 +892,17 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
876
892
  deleteSpanOperationSearchManyByCount.set(operations.length, deleteQuery)
877
893
  }
878
894
  deleteQuery.run(...operations.flatMap(([traceId, spanId]) => [traceId, spanId]))
895
+ for (const [traceId, spanId] of operations) db.query("DELETE FROM span_search_rows WHERE trace_id = ? AND span_id = ?").run(traceId, spanId)
879
896
 
880
897
  let insertQuery = insertSpanOperationSearchManyByCount.get(operations.length)
881
898
  if (!insertQuery) {
882
899
  insertQuery = db.query(`INSERT INTO span_operation_fts (trace_id, span_id, operation_name) VALUES ${operations.map(() => "(?, ?, ?)").join(", ")}`)
883
900
  insertSpanOperationSearchManyByCount.set(operations.length, insertQuery)
884
901
  }
885
- insertQuery.run(...operations.flatMap(([traceId, spanId, operationName]) => [traceId, spanId, operationName]))
902
+ const result = insertQuery.run(...operations.flatMap(([traceId, spanId, operationName]) => [traceId, spanId, operationName]))
903
+ // FTS allocates consecutive rowids within this trigger-free multi-row INSERT.
904
+ const firstId = Number(result.lastInsertRowid) - operations.length + 1
905
+ for (const [index, [traceId, spanId]] of operations.entries()) mapSpanSearch?.run(firstId + index, traceId, spanId)
886
906
  }
887
907
  const insertLogAttribute = db.query(`INSERT INTO log_attributes (log_id, key, value) VALUES (?, ?, ?)`)
888
908
  const logAttributeInsertManyByCount = new Map<number, ReturnType<Database["query"]>>()
@@ -907,7 +927,8 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
907
927
  if (entries.length === 0) return
908
928
  if (entries.length === 1) {
909
929
  const [logId, body] = entries[0]!
910
- insertLogBodySearch.run(logId, body)
930
+ const result = insertLogBodySearch.run(logId, body)
931
+ mapLogSearch?.run(Number(result.lastInsertRowid), Number(logId))
911
932
  return
912
933
  }
913
934
  let query = insertLogBodySearchManyByCount.get(entries.length)
@@ -915,161 +936,53 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
915
936
  query = db.query(`INSERT INTO log_body_fts (log_id, body) VALUES ${entries.map(() => "(?, ?)").join(", ")}`)
916
937
  insertLogBodySearchManyByCount.set(entries.length, query)
917
938
  }
918
- query.run(...entries.flatMap(([logId, body]) => [logId, body]))
939
+ const result = query.run(...entries.flatMap(([logId, body]) => [logId, body]))
940
+ const firstId = Number(result.lastInsertRowid) - entries.length + 1
941
+ for (const [index, [logId]] of entries.entries()) mapLogSearch?.run(firstId + index, Number(logId))
919
942
  }
920
943
 
921
944
  const maxDbSizeBytes = config.otel.maxDbSizeMb * 1024 * 1024
922
945
 
923
- // Freelist-ratio thresholds for the adaptive reclaim loop. Below the
924
- // LOW threshold there's nothing worth doing; above HIGH we are in the
925
- // 17GB-DB-with-10GB-freelist failure mode and need to reclaim aggressively
926
- // even if it costs writer-lock time.
927
- const FREELIST_LOW_RATIO = 0.05
928
- const FREELIST_MID_RATIO = 0.20
929
- const FREELIST_HIGH_RATIO = 0.50
930
- const VACUUM_PAGES_NORMAL = 2000 // ~8MB/pass
931
- const VACUUM_PAGES_BUSY = 20000 // ~80MB/pass — used when freelist > 20%
932
- const VACUUM_PAGES_PANIC = 50000 // ~200MB/pass — only when ratio > 50%
933
-
934
- const ftsTableNames = ["span_attr_fts", "log_body_fts", "span_operation_fts"] as const
935
-
936
- const incrementalFtsMerge = (pages: number) => {
937
- // FTS5 segment merges drop tombstone rows that DELETE leaves behind.
938
- // Without periodic merges, deleted FTS rows stay on disk indefinitely
939
- // — a major source of freelist pages on a heavy-deletion workload.
940
- // `merge=N` is a bounded, online operation: it merges at most N
941
- // pages of work and returns. Per FTS5 docs, missing tables silently
942
- // throw; we swallow because not every DB has every FTS table.
943
- for (const name of ftsTableNames) {
944
- try { db.query(`INSERT INTO ${name}(${name}) VALUES (?)`).run(`merge=${pages}`) } catch { /* table absent or older schema */ }
945
- }
946
- }
946
+ const publishDiagnostic = yield* WriterDiagnostics
947
+ const observeMaintenance = <A, E, R>(operation: string, effect: Effect.Effect<A, E, R>) =>
948
+ Effect.suspend(() => {
949
+ const startedAt = Date.now()
950
+ publishDiagnostic({ _tag: "maintenance", value: { operation, startedAt, durationMs: null, outcome: "running" } })
951
+ return effect.pipe(Effect.onExit((exit) => Effect.sync(() => publishDiagnostic({
952
+ _tag: "maintenance", value: { operation, startedAt, durationMs: Date.now() - startedAt, outcome: exit._tag === "Success" ? "completed" : "failed" },
953
+ }))))
954
+ })
947
955
 
956
+ const checkpoint = () => {
957
+ const value = checkpointPassive(db)
958
+ publishDiagnostic({ _tag: "checkpoint", value })
959
+ return value
960
+ }
948
961
  const reclaimSpace = Effect.fn("motel/TelemetryStore.reclaimSpace")(function* () {
949
962
  yield* Effect.sync(() => {
950
- const pageCount = (db.query(`PRAGMA page_count`).get() as { page_count: number }).page_count
951
- const freePages = (db.query(`PRAGMA freelist_count`).get() as { freelist_count: number }).freelist_count
952
- if (pageCount === 0) return
953
- const ratio = freePages / pageCount
954
- if (ratio < FREELIST_LOW_RATIO) return
955
-
956
- // Adaptive vacuum sizing — fixed 2000 pages/min could not keep
957
- // up with sustained deletions, leaking 10GB of freelist over
958
- // time. Scale the per-pass work to the size of the backlog so
959
- // we stay roughly proportional to the deficit.
960
- const pages =
961
- ratio >= FREELIST_HIGH_RATIO ? VACUUM_PAGES_PANIC :
962
- ratio >= FREELIST_MID_RATIO ? VACUUM_PAGES_BUSY :
963
- VACUUM_PAGES_NORMAL
964
-
965
- try { db.exec(`PRAGMA incremental_vacuum(${pages});`) } catch { /* ignore */ }
966
-
967
- // In WAL mode incremental_vacuum only moves pages — the file
968
- // shrinks on the next checkpoint. PASSIVE silently skips when
969
- // readers are active (the failure mode the agent's research
970
- // flagged: checkpoint starvation). Use RESTART normally and
971
- // TRUNCATE in panic mode to physically shrink the WAL when it
972
- // has grown.
973
- const mode = ratio >= FREELIST_HIGH_RATIO ? "TRUNCATE" : "RESTART"
974
- try { db.exec(`PRAGMA wal_checkpoint(${mode});`) } catch { /* ignore */ }
963
+ // Never wait out busy_timeout for a WAL reader. Defer physical reclaim until
964
+ // all frames are checkpointed; a held snapshot can grow WAL beyond size_limit.
965
+ if (checkpoint().deferred) return
966
+ const freePages = (db.query("PRAGMA freelist_count").get() as { freelist_count: number }).freelist_count
967
+ if (freePages > 0) db.exec("PRAGMA incremental_vacuum(256)")
975
968
  })
976
969
  })
977
970
 
971
+ let maintenancePending = false
978
972
  const cleanupExpired = Effect.fn("motel/TelemetryStore.cleanupExpired")(function* () {
979
973
  const now = yield* Clock.currentTimeMillis
980
-
981
974
  yield* Effect.sync(() => {
982
- const cutoff = now - config.otel.retentionHours * 60 * 60 * 1000
983
-
984
- // Evict at TRACE granularity so we never leave a trace half-gutted
985
- // (previous logic deleted oldest 20% of spans, which happily sliced
986
- // across traces and corrupted the summary rebuild). Running traces
987
- // are protected — only `active_span_count = 0` summaries are in
988
- // scope for eviction.
989
- const toEvict = new Set<string>()
990
-
991
- // Time-based: completed traces whose last span ended before cutoff.
992
- const timeExpired = db.query(
993
- `SELECT trace_id FROM trace_summaries WHERE active_span_count = 0 AND ended_at_ms > 0 AND ended_at_ms < ? ORDER BY ended_at_ms ASC LIMIT ?`,
994
- ).all(cutoff, config.otel.retentionTraceBatch) as readonly { trace_id: string }[]
995
- for (const row of timeExpired) toEvict.add(row.trace_id)
996
-
997
- // Size-based: if actual data exceeds the target, drop one bounded
998
- // batch of the oldest completed traces. `(page_count - freelist_count)`
999
- // ignores freed-but-not-vacuumed pages so a large freelist doesn't
1000
- // trigger a deletion death spiral.
1001
- const pageCount = (db.query(`PRAGMA page_count`).get() as { page_count: number }).page_count
1002
- const freePages = (db.query(`PRAGMA freelist_count`).get() as { freelist_count: number }).freelist_count
1003
- const pageSize = (db.query(`PRAGMA page_size`).get() as { page_size: number }).page_size
1004
- const dbSize = (pageCount - freePages) * pageSize
1005
- if (dbSize > maxDbSizeBytes) {
1006
- const oldest = db.query(
1007
- `SELECT trace_id FROM trace_summaries WHERE active_span_count = 0 ORDER BY started_at_ms ASC LIMIT ?`,
1008
- ).all(config.otel.retentionTraceBatch) as readonly { trace_id: string }[]
1009
- // Set.add dedupes overlap with the time-expired batch above.
1010
- for (const row of oldest) toEvict.add(row.trace_id)
1011
- }
1012
-
1013
- // Logs have their own retention boundary. A correlated log may refer
1014
- // to a trace that was sampled elsewhere or never reached Motel, so
1015
- // tying log eviction to trace_summaries lets those rows grow forever.
1016
- const expiredLogs = db.query(`DELETE FROM logs WHERE id IN (SELECT id FROM logs WHERE timestamp_ms < ? ORDER BY timestamp_ms ASC LIMIT ?)`).run(cutoff, config.otel.retentionLogBatch)
1017
- let deletedLogs = Number(expiredLogs.changes) > 0
1018
- if (dbSize > maxDbSizeBytes) {
1019
- const oversizedLogs = db.query(`DELETE FROM logs WHERE id IN (SELECT id FROM logs ORDER BY timestamp_ms ASC LIMIT ?)`).run(config.otel.retentionLogBatch)
1020
- deletedLogs = deletedLogs || Number(oversizedLogs.changes) > 0
1021
- }
1022
-
1023
- // Batch the trace-id list so the IN placeholders stay under
1024
- // SQLite's default limit (~999). Each batch wipes every row
1025
- // reachable from those trace_ids across the cascade tables.
1026
- const traceIds = Array.from(toEvict)
1027
- const BATCH_SIZE = 500
1028
- for (let offset = 0; offset < traceIds.length; offset += BATCH_SIZE) {
1029
- const batch = traceIds.slice(offset, offset + BATCH_SIZE)
1030
- const placeholders = batch.map(() => "?").join(",")
1031
- db.query(`DELETE FROM span_attributes WHERE trace_id IN (${placeholders})`).run(...batch)
1032
- try {
1033
- db.query(`DELETE FROM span_operation_fts WHERE trace_id IN (${placeholders})`).run(...batch)
1034
- } catch {
1035
- // FTS table may not exist on old DBs.
1036
- }
1037
- db.query(`DELETE FROM spans WHERE trace_id IN (${placeholders})`).run(...batch)
1038
- db.query(`DELETE FROM logs WHERE trace_id IN (${placeholders})`).run(...batch)
1039
- db.query(`DELETE FROM trace_summaries WHERE trace_id IN (${placeholders})`).run(...batch)
1040
- }
1041
-
1042
- // Log-side orphans (log_attributes + FTS) are keyed by log.id,
1043
- // so prune what no longer has a parent log row.
1044
- const orphanAttributes = db.query(`DELETE FROM log_attributes WHERE rowid IN (SELECT log_attributes.rowid FROM log_attributes WHERE NOT EXISTS (SELECT 1 FROM logs WHERE logs.id = log_attributes.log_id) LIMIT ?)`).run(config.otel.retentionLogBatch)
1045
- let deletedOrphans = Number(orphanAttributes.changes) > 0
1046
- try {
1047
- const orphanFts = db.query(`DELETE FROM log_body_fts WHERE rowid IN (SELECT rowid FROM log_body_fts WHERE NOT EXISTS (SELECT 1 FROM logs WHERE logs.id = CAST(log_body_fts.log_id AS INTEGER)) LIMIT ?)`).run(config.otel.retentionLogBatch)
1048
- deletedOrphans = deletedOrphans || Number(orphanFts.changes) > 0
1049
- } catch {
1050
- // FTS table may not exist on old DBs.
1051
- }
1052
-
1053
- // Checkpoint after a big delete pass so the freed pages land
1054
- // in the main DB file and become eligible for incremental
1055
- // vacuum. Use RESTART (not PASSIVE): PASSIVE silently no-ops
1056
- // when readers are active, which is the documented mechanism
1057
- // behind WAL/freelist starvation when ingest is busy.
1058
- if (toEvict.size === 0 && !deletedLogs && !deletedOrphans) return
1059
- try { db.exec(`PRAGMA wal_checkpoint(RESTART);`) } catch { /* ignore */ }
1060
-
1061
- // Incremental FTS5 merge — DELETE on an FTS5-indexed row
1062
- // leaves a tombstone in the segment tree that only `merge`
1063
- // reclaims. Skipping this is the second compounding cause
1064
- // (after fixed-size vacuum) of the slow freelist accretion
1065
- // that took the DB to 17GB. 100 pages of merge work per
1066
- // retention tick is bounded and runs in milliseconds.
1067
- incrementalFtsMerge(100)
1068
-
1069
- // Actual page reclamation lives in `reclaimSpace`, which
1070
- // runs on its own faster cadence so the file shrinks even
1071
- // when no traces are evicted in a given retention tick (e.g.
1072
- // after a large historical eviction has already happened).
975
+ const result = retainBatch(db, {
976
+ cutoff: now - config.otel.retentionHours * 60 * 60 * 1000,
977
+ maxBytes: maxDbSizeBytes,
978
+ traces: config.otel.retentionTraceBatch,
979
+ logs: config.otel.retentionLogBatch,
980
+ rows: config.otel.retentionRowBatch,
981
+ })
982
+ maintenancePending = result.pending && result.rows > 0
983
+ repairSearchRows(db, Math.min(100, config.otel.retentionRowBatch))
984
+ mergeFts(db, 100)
985
+ checkpoint()
1073
986
  })
1074
987
  })
1075
988
 
@@ -1077,18 +990,20 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
1077
990
  // the HTTP event loop and no second writer duplicates cleanup work.
1078
991
  if (opts.runRetention) {
1079
992
  // Cleanup runs on the telemetry worker, never the HTTP event loop.
1080
- yield* Effect.forkScoped(Effect.repeat(
1081
- Effect.andThen(reconcileTraceSummaries, cleanupExpired()).pipe(Effect.catchCause((cause) => Effect.logWarning(`motel: maintenance pass failed: ${Cause.pretty(cause)}`))),
1082
- Schedule.spaced(`${config.otel.retentionIntervalSeconds} seconds`),
1083
- ))
1084
-
1085
- // Page reclamation runs on a separate, faster cadence (10s) and
1086
- // is independent of the eviction loop. The reason: a single sweep
1087
- // at 60s intervals can move only ~8MB of pages before the next
1088
- // burst of inserts grows the freelist again. Decoupling lets us
1089
- // catch up adaptively (see VACUUM_PAGES_BUSY/PANIC) without
1090
- // changing the cost of the heavier delete sweep.
1091
- yield* Effect.forkScoped(Effect.repeat(reclaimSpace(), Schedule.spaced("10 seconds")))
993
+ const retentionLoop: Effect.Effect<void> = Effect.suspend(() =>
994
+ observeMaintenance("retention", Effect.andThen(reconcileTraceSummaries, cleanupExpired())).pipe(
995
+ Effect.catchCause((cause) => {
996
+ maintenancePending = false
997
+ return Effect.logWarning(`motel: maintenance pass failed: ${Cause.pretty(cause)}`)
998
+ }),
999
+ Effect.andThen(Effect.suspend(() => Effect.sleep(maintenancePending ? "100 millis" : `${config.otel.retentionIntervalSeconds} seconds`))),
1000
+ Effect.andThen(Effect.suspend(() => retentionLoop)),
1001
+ ),
1002
+ )
1003
+ yield* Effect.forkScoped(retentionLoop)
1004
+
1005
+ // Physical reclamation has a small fixed page budget and defers for readers.
1006
+ yield* Effect.forkScoped(Effect.repeat(observeMaintenance("reclaim", reclaimSpace()).pipe(Effect.catchCause((cause) => Effect.logWarning(`motel: reclaim failed: ${Cause.pretty(cause)}`))), Schedule.spaced("10 seconds")))
1092
1007
 
1093
1008
  // Periodically refresh query planner stats. `PRAGMA optimize` is a
1094
1009
  // no-op when nothing has changed, so this is essentially free on idle
@@ -1098,7 +1013,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
1098
1013
  const refreshPlannerStats = Effect.sync(() => {
1099
1014
  try { db.exec(`PRAGMA optimize;`) } catch { /* ignore */ }
1100
1015
  })
1101
- yield* Effect.forkScoped(Effect.repeat(refreshPlannerStats, Schedule.spaced("15 minutes")))
1016
+ yield* Effect.forkScoped(Effect.repeat(observeMaintenance("planner", refreshPlannerStats), Schedule.spaced("15 minutes")))
1102
1017
  }
1103
1018
 
1104
1019
  // Incrementally rebuild historical AI attributes in bounded batches.
@@ -1162,7 +1077,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
1162
1077
  for (const span of scopeSpans.spans ?? []) {
1163
1078
  const traceId = normalizeOtlpBinaryId(span.traceId, 16)
1164
1079
  const spanId = normalizeOtlpBinaryId(span.spanId, 8)
1165
- if (!traceId || !spanId) continue
1080
+ if (!traceId || !spanId || evictingTrace?.get(traceId)) continue
1166
1081
  const parentSpanId = normalizeOtlpBinaryId(span.parentSpanId, 8)
1167
1082
  const spanAttributes = attributeMap(span.attributes)
1168
1083
  const mergedAttributes = { ...resourceAttributes, ...spanAttributes }
@@ -1235,8 +1150,10 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
1235
1150
  const body = stringifyValue(parseAnyValue(record.body))
1236
1151
  const rawTraceId = attributes.traceId || attributes.trace_id || record.traceId || null
1237
1152
  const rawSpanId = attributes.spanId || attributes.span_id || record.spanId || null
1153
+ const traceId = normalizeOtlpBinaryId(rawTraceId, 16)
1154
+ if (traceId && evictingTrace?.get(traceId)) continue
1238
1155
  const result = insertLog.run(
1239
- normalizeOtlpBinaryId(rawTraceId, 16),
1156
+ traceId,
1240
1157
  normalizeOtlpBinaryId(rawSpanId, 8),
1241
1158
  serviceName,
1242
1159
  scopeName,
@@ -1275,9 +1192,9 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
1275
1192
  // long-running trace can emit a current child after its root ages
1276
1193
  // outside the lookback window.
1277
1194
  const rows = db.query(`
1278
- SELECT service_name FROM spans WHERE start_time_ms >= ?
1195
+ SELECT service_name FROM ${spanSource} WHERE start_time_ms >= ?
1279
1196
  UNION
1280
- SELECT service_name FROM logs WHERE timestamp_ms >= ?
1197
+ SELECT service_name FROM ${logSource} WHERE timestamp_ms >= ?
1281
1198
  ORDER BY service_name ASC
1282
1199
  `).all(cutoff, cutoff) as Array<{ service_name: string }>
1283
1200
  return rows.map((row) => row.service_name)
@@ -1290,7 +1207,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
1290
1207
  if (traceIds.length === 0) return [] as readonly TraceItem[]
1291
1208
  const placeholders = traceIds.map(() => "?").join(", ")
1292
1209
  const rows = db.query(`
1293
- SELECT * FROM spans
1210
+ SELECT * FROM ${spanSource}
1294
1211
  WHERE trace_id IN (${placeholders})
1295
1212
  ORDER BY start_time_ms ASC
1296
1213
  `).all(...traceIds) as SpanRow[]
@@ -1337,7 +1254,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
1337
1254
 
1338
1255
  return db.query(`
1339
1256
  SELECT trace_id, service_name, root_operation_name, started_at_ms, ended_at_ms, active_span_count, duration_ms, span_count, error_count
1340
- FROM trace_summaries
1257
+ FROM ${traceSource}
1341
1258
  WHERE ${clauses.join(" AND ")}
1342
1259
  ORDER BY started_at_ms DESC, trace_id DESC
1343
1260
  LIMIT ?
@@ -1380,7 +1297,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
1380
1297
  clauses.push("trace_id IN (SELECT DISTINCT trace_id FROM span_operation_fts WHERE span_operation_fts MATCH ?)")
1381
1298
  params.push(ftsQuery)
1382
1299
  } else {
1383
- clauses.push("trace_id IN (SELECT DISTINCT trace_id FROM spans WHERE operation_name LIKE ? COLLATE NOCASE)")
1300
+ clauses.push(`trace_id IN (SELECT DISTINCT trace_id FROM ${spanSource} WHERE operation_name LIKE ? COLLATE NOCASE)`)
1384
1301
  params.push(`%${input.operation}%`)
1385
1302
  }
1386
1303
  }
@@ -1412,7 +1329,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
1412
1329
 
1413
1330
  const rows = db.query(`
1414
1331
  SELECT trace_id, service_name, root_operation_name, started_at_ms, ended_at_ms, active_span_count, duration_ms, span_count, error_count
1415
- FROM trace_summaries
1332
+ FROM ${traceSource}
1416
1333
  WHERE ${clauses.join(" AND ")}
1417
1334
  ORDER BY started_at_ms DESC, trace_id DESC
1418
1335
  LIMIT ?
@@ -1426,7 +1343,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
1426
1343
  yield* Effect.annotateCurrentSpan("trace.trace_id", traceId)
1427
1344
  return yield* Effect.sync(() => {
1428
1345
  const rows = db.query(`
1429
- SELECT * FROM spans WHERE trace_id = ? ORDER BY start_time_ms ASC
1346
+ SELECT * FROM ${spanSource} WHERE trace_id = ? ORDER BY start_time_ms ASC
1430
1347
  `).all(traceId) as SpanRow[]
1431
1348
  return rows.length === 0 ? null : buildTrace(traceId, rows)
1432
1349
  })
@@ -1436,7 +1353,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
1436
1353
  yield* Effect.annotateCurrentSpan("trace.span_id", spanId)
1437
1354
  return yield* Effect.sync(() => {
1438
1355
  // Fetch only the target span row (uses idx_spans_span_id)
1439
- const spanRow = db.query(`SELECT * FROM spans WHERE span_id = ? LIMIT 1`).get(spanId) as SpanRow | null
1356
+ const spanRow = db.query(`SELECT * FROM ${spanSource} WHERE span_id = ? LIMIT 1`).get(spanId) as SpanRow | null
1440
1357
  if (!spanRow) return null
1441
1358
 
1442
1359
  const traceId = spanRow.trace_id
@@ -1450,11 +1367,11 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
1450
1367
  const ancestors = db.query(`
1451
1368
  WITH RECURSIVE ancestors(span_id, parent_span_id, operation_name, hop) AS (
1452
1369
  SELECT span_id, parent_span_id, operation_name, 1
1453
- FROM spans WHERE trace_id = ? AND span_id = ?
1370
+ FROM ${spanSource} WHERE trace_id = ? AND span_id = ?
1454
1371
  UNION ALL
1455
1372
  SELECT s.span_id, s.parent_span_id, s.operation_name, a.hop + 1
1456
1373
  FROM ancestors a
1457
- JOIN spans s ON s.trace_id = ? AND s.span_id = a.parent_span_id
1374
+ JOIN ${spanSource} s ON s.trace_id = ? AND s.span_id = a.parent_span_id
1458
1375
  )
1459
1376
  SELECT span_id, parent_span_id, operation_name, hop FROM ancestors ORDER BY hop ASC
1460
1377
  `).all(traceId, spanRow.parent_span_id, traceId) as Array<{ span_id: string; parent_span_id: string | null; operation_name: string; hop: number }>
@@ -1464,7 +1381,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
1464
1381
  }
1465
1382
 
1466
1383
  const rootRow = db.query(`
1467
- SELECT operation_name FROM spans
1384
+ SELECT operation_name FROM ${spanSource}
1468
1385
  WHERE trace_id = ? AND parent_span_id IS NULL
1469
1386
  ORDER BY start_time_ms ASC LIMIT 1
1470
1387
  `).get(traceId) as { operation_name: string } | null
@@ -1482,7 +1399,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
1482
1399
 
1483
1400
  const listTraceSpans = Effect.fn("motel/TelemetryStore.listTraceSpans")(function* (traceId: string) {
1484
1401
  return yield* Effect.sync(() => {
1485
- const rows = db.query(`SELECT * FROM spans WHERE trace_id = ? ORDER BY start_time_ms ASC`).all(traceId) as SpanRow[]
1402
+ const rows = db.query(`SELECT * FROM ${spanSource} WHERE trace_id = ? ORDER BY start_time_ms ASC`).all(traceId) as SpanRow[]
1486
1403
  return rows.length === 0 ? [] as readonly SpanItem[] : buildSpanItems(traceId, rows)
1487
1404
  })
1488
1405
  })
@@ -1507,7 +1424,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
1507
1424
  // to drive the parent-context lookup. Parsing the heavy
1508
1425
  // `*_json` blobs is deferred until after we've sliced down
1509
1426
  // to the final `limit`.
1510
- let fromSql = "FROM spans AS s"
1427
+ let fromSql = `FROM ${spanSource} AS s`
1511
1428
  const joinParams: Array<string | number> = []
1512
1429
  const clauses: string[] = ["s.start_time_ms >= ?"]
1513
1430
  const params: Array<string | number> = [cutoff]
@@ -1548,75 +1465,60 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
1548
1465
  }
1549
1466
 
1550
1467
  const candidateRows = db.query(`
1551
- SELECT s.trace_id, s.span_id, s.parent_span_id, s.operation_name, s.start_time_ms
1468
+ SELECT s.trace_id, s.span_id, s.parent_span_id, s.operation_name, s.start_time_ms,
1469
+ (SELECT p.operation_name FROM ${spanSource} p WHERE p.trace_id = s.trace_id AND p.span_id = s.parent_span_id) AS parent_operation_name
1552
1470
  ${fromSql}
1553
1471
  WHERE ${clauses.join(" AND ")}
1554
1472
  ORDER BY s.start_time_ms DESC
1555
1473
  LIMIT ?
1556
- `).all(...joinParams, ...params, candidateLimit) as Array<{ trace_id: string; span_id: string; parent_span_id: string | null; operation_name: string; start_time_ms: number }>
1557
-
1558
- const traceIds = [...new Set(candidateRows.map((row) => row.trace_id))]
1559
- if (traceIds.length === 0) return [] as readonly SpanItem[]
1474
+ `).all(...joinParams, ...params, candidateLimit) as Array<{ trace_id: string; span_id: string; parent_span_id: string | null; operation_name: string; start_time_ms: number; parent_operation_name: string | null }>
1560
1475
 
1476
+ // Keep the public Unicode substring matching and candidate-window semantics.
1477
+ // Parent metadata is an indexed point lookup, not whole-trace hydration.
1478
+ const parentOperationNeedle = input.parentOperation?.toLowerCase() ?? null
1479
+ const filteredLite = candidateRows.filter((row) => !parentOperationNeedle || row.parent_operation_name?.toLowerCase().includes(parentOperationNeedle)).slice(0, limit)
1480
+ if (filteredLite.length === 0) return [] as readonly SpanItem[]
1561
1481
  const keyOf = (traceId: string, spanId: string) => `${traceId}:${spanId}`
1562
- const spanContextById = new Map<string, { readonly parentSpanId: string | null; readonly operationName: string }>()
1563
-
1564
- // Bulk-prefetch parent metadata for every span in every trace
1565
- // touched by the candidate set. One indexed scan per trace_id
1566
- // is much cheaper than a per-span lookup loop while computing
1567
- // depth, and we get the trace-root lookup in the same pass.
1568
- const placeholders = traceIds.map(() => "?").join(", ")
1569
- const allSpanRows = db.query(`
1570
- SELECT trace_id, span_id, parent_span_id, operation_name, start_time_ms
1571
- FROM spans
1572
- WHERE trace_id IN (${placeholders})
1573
- `).all(...traceIds) as Array<{ trace_id: string; span_id: string; parent_span_id: string | null; operation_name: string; start_time_ms: number }>
1574
-
1575
- const rootOperationByTraceId = new Map<string, { operationName: string; startTimeMs: number }>()
1576
- for (const row of allSpanRows) {
1577
- spanContextById.set(keyOf(row.trace_id, row.span_id), {
1578
- parentSpanId: row.parent_span_id,
1579
- operationName: row.operation_name,
1580
- })
1581
- if (row.parent_span_id === null) {
1582
- const existing = rootOperationByTraceId.get(row.trace_id)
1583
- if (!existing || row.start_time_ms < existing.startTimeMs) {
1584
- rootOperationByTraceId.set(row.trace_id, { operationName: row.operation_name, startTimeMs: row.start_time_ms })
1585
- }
1586
- }
1587
- }
1588
-
1482
+ const anchorValues = filteredLite.map(() => "(?, ?)").join(", ")
1483
+ // UNION deduplicates cycles. Only ancestors of returned spans are loaded;
1484
+ // unrelated siblings and their large metadata never enter this traversal.
1485
+ const ancestors = db.query(`
1486
+ WITH RECURSIVE ancestors(trace_id, span_id, parent_span_id, operation_name) AS (
1487
+ SELECT trace_id, span_id, parent_span_id, operation_name FROM ${spanSource}
1488
+ WHERE (trace_id, span_id) IN (VALUES ${anchorValues})
1489
+ UNION
1490
+ SELECT s.trace_id, s.span_id, s.parent_span_id, s.operation_name
1491
+ FROM ${spanSource} s JOIN ancestors a ON s.trace_id = a.trace_id AND s.span_id = a.parent_span_id
1492
+ ) SELECT * FROM ancestors
1493
+ `).all(...filteredLite.flatMap((row) => [row.trace_id, row.span_id])) as Array<{ trace_id: string; span_id: string; parent_span_id: string | null; operation_name: string }>
1494
+ const spanContextById = new Map(ancestors.map((row) => [keyOf(row.trace_id, row.span_id), { parentSpanId: row.parent_span_id, operationName: row.operation_name }]))
1589
1495
  const getSpanContext = (traceId: string, spanId: string) => spanContextById.get(keyOf(traceId, spanId)) ?? null
1590
-
1591
- const depthById = new Map<string, number>()
1592
- const getDepth = (traceId: string, spanId: string, visiting = new Set<string>()): number => {
1593
- const key = keyOf(traceId, spanId)
1594
- const cached = depthById.get(key)
1595
- if (cached !== undefined) return cached
1596
- if (visiting.has(key)) return 0
1597
- visiting.add(key)
1598
- const context = getSpanContext(traceId, spanId)
1599
- const depth = context?.parentSpanId ? getDepth(traceId, context.parentSpanId, visiting) + 1 : 0
1600
- depthById.set(key, depth)
1601
- return depth
1496
+ const rootOperationByTraceId = new Map<string, { operationName: string }>()
1497
+ for (const traceId of new Set(filteredLite.map((row) => row.trace_id))) {
1498
+ const root = db.query(`SELECT operation_name FROM ${spanSource} WHERE trace_id = ? AND parent_span_id IS NULL ORDER BY start_time_ms LIMIT 1`).get(traceId) as { operation_name: string } | null
1499
+ if (root) rootOperationByTraceId.set(traceId, { operationName: root.operation_name })
1602
1500
  }
1603
-
1604
- // Apply parentOperation post-filter on the lite candidate set
1605
- // (cheap — string compare against cached parent op) and then
1606
- // slice down to the final result size before parsing any JSON.
1607
- const parentOperationNeedle = input.parentOperation?.toLowerCase() ?? null
1608
- const filteredLite: typeof candidateRows = []
1609
- for (const row of candidateRows) {
1610
- if (parentOperationNeedle) {
1611
- const parent = row.parent_span_id ? getSpanContext(row.trace_id, row.parent_span_id) : null
1612
- if (!parent?.operationName.toLowerCase().includes(parentOperationNeedle)) continue
1501
+ const depthById = new Map<string, number>()
1502
+ const getDepth = (traceId: string, spanId: string): number => {
1503
+ const path: string[] = []
1504
+ const visiting = new Set<string>()
1505
+ let current: string | null = spanId
1506
+ let depth = 0
1507
+ while (current) {
1508
+ const key = keyOf(traceId, current)
1509
+ const cached = depthById.get(key)
1510
+ if (cached !== undefined) { depth = cached; break }
1511
+ if (visiting.has(key)) break
1512
+ const context = getSpanContext(traceId, current)
1513
+ if (!context?.parentSpanId) { depthById.set(key, 0); break }
1514
+ visiting.add(key)
1515
+ path.push(key)
1516
+ current = context.parentSpanId
1613
1517
  }
1614
- filteredLite.push(row)
1615
- if (filteredLite.length >= limit) break
1518
+ for (const key of path.reverse()) depthById.set(key, ++depth)
1519
+ return depthById.get(keyOf(traceId, spanId)) ?? 0
1616
1520
  }
1617
1521
 
1618
- if (filteredLite.length === 0) return [] as readonly SpanItem[]
1619
-
1620
1522
  // Hydrate only the kept rows: one batched fetch of the full
1621
1523
  // SpanRow (with resource_json / attributes_json / events_json)
1622
1524
  // using SQLite's row-value `IN` syntax, then parseSpanRow per
@@ -1624,7 +1526,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
1624
1526
  // sees the same ordering the candidate scan produced.
1625
1527
  const keptValues = filteredLite.map(() => "(?, ?)").join(", ")
1626
1528
  const fullRows = db.query(`
1627
- SELECT * FROM spans WHERE (trace_id, span_id) IN (VALUES ${keptValues})
1529
+ SELECT * FROM ${spanSource} WHERE (trace_id, span_id) IN (VALUES ${keptValues})
1628
1530
  `).all(...filteredLite.flatMap((row) => [row.trace_id, row.span_id])) as SpanRow[]
1629
1531
  const fullRowByKey = new Map<string, SpanRow>()
1630
1532
  for (const row of fullRows) {
@@ -1671,7 +1573,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
1671
1573
  params.push(input.serviceName)
1672
1574
  }
1673
1575
  if (input.severity) {
1674
- clauses.push(`severity_text = ?`)
1576
+ clauses.push(`severity_text = ? COLLATE NOCASE`)
1675
1577
  params.push(input.severity.toUpperCase())
1676
1578
  }
1677
1579
  if (input.traceId) {
@@ -1717,7 +1619,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
1717
1619
  const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""
1718
1620
  const limit = input.limit ?? config.otel.logFetchLimit
1719
1621
  const rows = db.query(`
1720
- SELECT * FROM logs
1622
+ SELECT * FROM ${logSource}
1721
1623
  ${where}
1722
1624
  ORDER BY timestamp_ms DESC, id DESC
1723
1625
  LIMIT ?
@@ -1831,7 +1733,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
1831
1733
  if (input.agg === "p95_duration") {
1832
1734
  const rows = db.query(`
1833
1735
  SELECT ${groupExpr} AS grp, duration_ms
1834
- FROM trace_summaries
1736
+ FROM ${traceSource}
1835
1737
  WHERE ${whereClauses.join(" AND ")}
1836
1738
  `).all(...whereParams) as Array<{ grp: string; duration_ms: number }>
1837
1739
 
@@ -1850,7 +1752,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
1850
1752
 
1851
1753
  const rows = db.query(`
1852
1754
  SELECT ${groupExpr} AS grp, ${aggExpr} AS value, COUNT(*) AS count
1853
- FROM trace_summaries
1755
+ FROM ${traceSource}
1854
1756
  WHERE ${whereClauses.join(" AND ")}
1855
1757
  GROUP BY grp
1856
1758
  ORDER BY value DESC
@@ -1884,7 +1786,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
1884
1786
  const group = input.groupBy === "service"
1885
1787
  ? log.serviceName
1886
1788
  : input.groupBy === "severity"
1887
- ? log.severityText
1789
+ ? log.severityText.toUpperCase()
1888
1790
  : input.groupBy === "scope"
1889
1791
  ? log.scopeName ?? "unknown"
1890
1792
  : isAttrGroupBy
@@ -1930,14 +1832,14 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
1930
1832
  const groupExpr = input.groupBy === "service"
1931
1833
  ? "service_name"
1932
1834
  : input.groupBy === "severity"
1933
- ? "severity_text"
1835
+ ? "UPPER(severity_text)"
1934
1836
  : input.groupBy === "scope"
1935
1837
  ? "COALESCE(scope_name, 'unknown')"
1936
1838
  : "'unknown'"
1937
1839
 
1938
1840
  const rows = db.query(`
1939
1841
  SELECT ${groupExpr} AS grp, COUNT(*) AS count
1940
- FROM logs
1842
+ FROM ${logSource}
1941
1843
  ${where}
1942
1844
  GROUP BY grp
1943
1845
  ORDER BY count DESC
@@ -1964,7 +1866,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
1964
1866
  if (input.field === "service") {
1965
1867
  const rows = db.query(`
1966
1868
  SELECT service_name AS value, COUNT(*) AS count
1967
- FROM logs
1869
+ FROM ${logSource}
1968
1870
  WHERE timestamp_ms >= ?
1969
1871
  GROUP BY service_name
1970
1872
  ORDER BY count DESC, value ASC
@@ -1974,11 +1876,11 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
1974
1876
  }
1975
1877
  if (input.field === "severity") {
1976
1878
  const rows = db.query(`
1977
- SELECT severity_text AS value, COUNT(*) AS count
1978
- FROM logs
1879
+ SELECT UPPER(severity_text) AS value, COUNT(*) AS count
1880
+ FROM ${logSource}
1979
1881
  WHERE timestamp_ms >= ?
1980
1882
  ${input.serviceName ? "AND service_name = ?" : ""}
1981
- GROUP BY severity_text
1883
+ GROUP BY value
1982
1884
  ORDER BY count DESC, value ASC
1983
1885
  LIMIT ?
1984
1886
  `).all(...(input.serviceName ? [cutoff, input.serviceName, limit] : [cutoff, limit])) as Array<{ value: string; count: number }>
@@ -1987,7 +1889,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
1987
1889
  if (input.field === "scope") {
1988
1890
  const rows = db.query(`
1989
1891
  SELECT COALESCE(scope_name, 'unknown') AS value, COUNT(*) AS count
1990
- FROM logs
1892
+ FROM ${logSource}
1991
1893
  WHERE timestamp_ms >= ?
1992
1894
  ${input.serviceName ? "AND service_name = ?" : ""}
1993
1895
  GROUP BY COALESCE(scope_name, 'unknown')
@@ -2002,7 +1904,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
2002
1904
  if (input.field === "service") {
2003
1905
  const rows = db.query(`
2004
1906
  SELECT service_name AS value, COUNT(*) AS count
2005
- FROM trace_summaries
1907
+ FROM ${traceSource}
2006
1908
  WHERE started_at_ms >= ?
2007
1909
  GROUP BY service_name
2008
1910
  ORDER BY count DESC, value ASC
@@ -2013,7 +1915,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
2013
1915
  if (input.field === "operation") {
2014
1916
  const rows = db.query(`
2015
1917
  SELECT root_operation_name AS value, COUNT(*) AS count
2016
- FROM trace_summaries
1918
+ FROM ${traceSource}
2017
1919
  WHERE started_at_ms >= ?
2018
1920
  ${input.serviceName ? "AND service_name = ?" : ""}
2019
1921
  GROUP BY root_operation_name
@@ -2025,7 +1927,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
2025
1927
  if (input.field === "status") {
2026
1928
  const rows = db.query(`
2027
1929
  SELECT CASE WHEN error_count > 0 THEN 'error' ELSE 'ok' END AS value, COUNT(*) AS count
2028
- FROM trace_summaries
1930
+ FROM ${traceSource}
2029
1931
  WHERE started_at_ms >= ?
2030
1932
  ${input.serviceName ? "AND service_name = ?" : ""}
2031
1933
  GROUP BY value
@@ -2054,10 +1956,10 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
2054
1956
  const params: Array<string | number> = []
2055
1957
  let traceFilter: string
2056
1958
  if (input.serviceName) {
2057
- traceFilter = `(SELECT trace_id FROM trace_summaries WHERE started_at_ms >= ? AND service_name = ?)`
1959
+ traceFilter = `(SELECT trace_id FROM ${traceSource} WHERE started_at_ms >= ? AND service_name = ?)`
2058
1960
  params.push(cutoff, input.serviceName)
2059
1961
  } else {
2060
- traceFilter = `(SELECT trace_id FROM trace_summaries WHERE started_at_ms >= ?)`
1962
+ traceFilter = `(SELECT trace_id FROM ${traceSource} WHERE started_at_ms >= ?)`
2061
1963
  params.push(cutoff)
2062
1964
  }
2063
1965
  params.push(FACET_VALUE_MAX_LEN, limit)
@@ -2088,7 +1990,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
2088
1990
  const rows = db.query(`
2089
1991
  SELECT sa.value AS value, COUNT(DISTINCT sa.trace_id) AS count
2090
1992
  FROM span_attributes sa
2091
- JOIN spans s ON s.trace_id = sa.trace_id AND s.span_id = sa.span_id
1993
+ JOIN ${spanSource} s ON s.trace_id = sa.trace_id AND s.span_id = sa.span_id
2092
1994
  WHERE sa.key = ? AND LENGTH(sa.value) < ?
2093
1995
  AND s.start_time_ms >= ?
2094
1996
  ${input.serviceName ? "AND s.service_name = ?" : ""}
@@ -2225,7 +2127,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
2225
2127
 
2226
2128
  const rows = db.query(`
2227
2129
  SELECT s.trace_id, s.span_id, s.service_name, s.operation_name, s.start_time_ms, s.duration_ms, s.status
2228
- FROM spans AS s
2130
+ FROM ${spanSource} AS s
2229
2131
  WHERE ${clauses.join(" AND ")}
2230
2132
  ORDER BY s.start_time_ms DESC
2231
2133
  LIMIT ?
@@ -2251,7 +2153,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
2251
2153
  const spanParams = rows.flatMap((r) => [r.trace_id, r.span_id])
2252
2154
  const toolCountRows = db.query(`
2253
2155
  SELECT parent_span_id, COUNT(*) AS cnt
2254
- FROM spans
2156
+ FROM ${spanSource}
2255
2157
  WHERE (trace_id, parent_span_id) IN (VALUES ${spanPlaceholders})
2256
2158
  AND operation_name LIKE 'ai.toolCall%'
2257
2159
  GROUP BY trace_id, parent_span_id
@@ -2301,7 +2203,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
2301
2203
  const getAiCall = Effect.fn("motel/TelemetryStore.getAiCall")(function* (spanId: string) {
2302
2204
  return yield* Effect.sync(() => {
2303
2205
  const row = db.query(`
2304
- SELECT * FROM spans WHERE span_id = ? AND operation_name LIKE 'ai.%' LIMIT 1
2206
+ SELECT * FROM ${spanSource} WHERE span_id = ? AND operation_name LIKE 'ai.%' LIMIT 1
2305
2207
  `).get(spanId) as SpanRow | null
2306
2208
  if (!row) return null
2307
2209
 
@@ -2320,7 +2222,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
2320
2222
  // Load tool call child spans
2321
2223
  const toolCallRows = db.query(`
2322
2224
  SELECT span_id, operation_name, duration_ms, status, attributes_json
2323
- FROM spans
2225
+ FROM ${spanSource}
2324
2226
  WHERE trace_id = ? AND parent_span_id = ? AND operation_name LIKE 'ai.toolCall%'
2325
2227
  ORDER BY start_time_ms ASC
2326
2228
  `).all(row.trace_id, row.span_id) as SpanRow[]
@@ -2337,7 +2239,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
2337
2239
 
2338
2240
  // Load correlated logs
2339
2241
  const logRows = db.query(`
2340
- SELECT * FROM logs WHERE span_id = ? ORDER BY timestamp_ms ASC
2242
+ SELECT * FROM ${logSource} WHERE span_id = ? ORDER BY timestamp_ms ASC
2341
2243
  `).all(row.span_id) as LogRow[]
2342
2244
  const logs = logRows.map(parseLogRow)
2343
2245
 
@@ -2409,7 +2311,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
2409
2311
  if (input.groupBy === "status") {
2410
2312
  const rows = db.query(`
2411
2313
  SELECT s.status AS grp, COUNT(*) AS count, AVG(s.duration_ms) AS avg_dur
2412
- FROM spans AS s
2314
+ FROM ${spanSource} AS s
2413
2315
  WHERE ${clauses.join(" AND ")}
2414
2316
  GROUP BY s.status
2415
2317
  ORDER BY count DESC
@@ -2436,7 +2338,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
2436
2338
  s.span_id,
2437
2339
  s.duration_ms,
2438
2340
  s.status
2439
- FROM spans AS s
2341
+ FROM ${spanSource} AS s
2440
2342
  LEFT JOIN span_attributes AS ga
2441
2343
  ON ga.trace_id = s.trace_id AND ga.span_id = s.span_id AND ga.key = ?
2442
2344
  WHERE ${clauses.join(" AND ")}