@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.
- package/AGENTS.md +5 -2
- package/README.md +51 -0
- package/package.json +1 -1
- package/src/config.ts +7 -1
- package/src/daemon.ts +69 -35
- package/src/httpApi.ts +28 -9
- package/src/ingestReadiness.test.ts +160 -0
- package/src/ingestReadiness.ts +100 -0
- package/src/localServer.ts +29 -14
- package/src/queryHttp.test.ts +34 -0
- package/src/runtime.ts +4 -2
- package/src/services/AsyncIngest.ts +36 -16
- package/src/services/TelemetryQuery.ts +18 -31
- package/src/services/TelemetryStore.ts +166 -264
- package/src/services/ingestRpc.ts +2 -2
- package/src/services/queryRpc.ts +12 -12
- package/src/services/queryScheduler.test.ts +102 -0
- package/src/services/queryScheduler.ts +157 -0
- package/src/services/retention.test.ts +170 -0
- package/src/services/retention.ts +158 -0
- package/src/services/telemetryQueryProcess.ts +10 -0
- package/src/services/telemetryQueryWorker.ts +25 -26
- package/src/services/telemetryWorker.ts +9 -1
- package/src/storybook/fixtures/index.ts +2 -2
- package/src/telemetry.test.ts +108 -0
- package/src/ui/app/useTraceScreenData.ts +11 -17
- package/src/ui/atoms.ts +2 -2
- package/src/ui/cachedLoader.test.ts +20 -0
- package/src/ui/cachedLoader.ts +1 -2
- package/src/ui/loaders.ts +17 -6
- package/src/ui/serviceSelection.test.ts +50 -0
- package/web/dist/assets/index-CN7ClqOl.js +21 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-5mTXbtNw.js +0 -21
|
@@ -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
|
-
--
|
|
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)))
|
|
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
|
-
|
|
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
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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
|
-
|
|
951
|
-
|
|
952
|
-
if (
|
|
953
|
-
const
|
|
954
|
-
if (
|
|
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
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
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
|
-
|
|
1081
|
-
Effect.andThen(reconcileTraceSummaries, cleanupExpired()).pipe(
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
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
|
-
|
|
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
|
|
1195
|
+
SELECT service_name FROM ${spanSource} WHERE start_time_ms >= ?
|
|
1279
1196
|
UNION
|
|
1280
|
-
SELECT service_name FROM
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
1563
|
-
|
|
1564
|
-
//
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
`).all(...
|
|
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
|
|
1592
|
-
|
|
1593
|
-
|
|
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
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
const
|
|
1612
|
-
if (
|
|
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
|
-
|
|
1615
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 ")}
|