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