Skip to main content

codex_state/
telemetry.rs

1use std::borrow::Cow;
2use std::sync::Arc;
3use std::sync::OnceLock;
4use std::time::Duration;
5
6use crate::DB_FALLBACK_METRIC;
7use crate::DB_INIT_DURATION_METRIC;
8use crate::DB_INIT_METRIC;
9use tracing::debug;
10
11/// Low-cardinality sink for SQLite startup and fallback telemetry.
12///
13/// Implementations should absorb delivery failures locally. Database behavior
14/// must not depend on whether telemetry export succeeds.
15pub trait DbTelemetry: Send + Sync + 'static {
16    fn counter(&self, name: &str, inc: i64, tags: &[(&str, &str)]);
17    fn record_duration(&self, name: &str, duration: Duration, tags: &[(&str, &str)]);
18}
19
20pub type DbTelemetryHandle = Arc<dyn DbTelemetry>;
21
22static PROCESS_DB_TELEMETRY: OnceLock<DbTelemetryHandle> = OnceLock::new();
23
24/// Install the process-wide SQLite telemetry sink.
25///
26/// Startup owners should call this once after OTEL initialization. Low-level
27/// database paths will use the registered sink unless an explicit sink is
28/// provided. Subsequent installs are ignored and keep the first installed sink.
29pub fn install_process_db_telemetry(telemetry: DbTelemetryHandle) -> bool {
30    if PROCESS_DB_TELEMETRY.set(telemetry).is_ok() {
31        true
32    } else {
33        debug!("process SQLite telemetry sink already installed; ignoring duplicate install");
34        false
35    }
36}
37
38#[derive(Clone, Copy)]
39pub(crate) enum DbKind {
40    State,
41    Logs,
42    Goals,
43    Memories,
44    ThreadHistory,
45}
46
47impl DbKind {
48    fn as_str(self) -> &'static str {
49        match self {
50            Self::State => "state",
51            Self::Logs => "logs",
52            Self::Goals => "goals",
53            Self::Memories => "memories",
54            Self::ThreadHistory => "thread_history",
55        }
56    }
57}
58
59pub(crate) fn record_init_result<T>(
60    telemetry: Option<&dyn DbTelemetry>,
61    db: DbKind,
62    phase: &'static str,
63    duration: Duration,
64    result: &anyhow::Result<T>,
65) {
66    let outcome = DbOutcomeTags::from_result(result);
67    let tags = [
68        ("status", outcome.status),
69        ("phase", phase),
70        ("db", db.as_str()),
71        ("error", outcome.error),
72    ];
73    record_counter(telemetry, DB_INIT_METRIC, &tags);
74    record_duration(telemetry, DB_INIT_DURATION_METRIC, duration, &tags);
75}
76
77pub fn record_backfill_gate(
78    telemetry: Option<&dyn DbTelemetry>,
79    duration: Duration,
80    result: &anyhow::Result<()>,
81) {
82    record_init_result(telemetry, DbKind::State, "backfill_gate", duration, result);
83}
84
85pub fn record_fallback(
86    caller: &'static str,
87    reason: &'static str,
88    telemetry_override: Option<&dyn DbTelemetry>,
89) {
90    record_counter(
91        telemetry_override,
92        DB_FALLBACK_METRIC,
93        &[("caller", caller), ("reason", reason)],
94    );
95}
96
97fn record_counter(telemetry: Option<&dyn DbTelemetry>, name: &str, tags: &[(&str, &str)]) {
98    if let Some(telemetry) = resolve_telemetry(telemetry) {
99        telemetry.counter(name, /*inc*/ 1, tags);
100    }
101}
102
103fn record_duration(
104    telemetry: Option<&dyn DbTelemetry>,
105    name: &str,
106    duration: Duration,
107    tags: &[(&str, &str)],
108) {
109    if let Some(telemetry) = resolve_telemetry(telemetry) {
110        telemetry.record_duration(name, duration, tags);
111    }
112}
113
114fn resolve_telemetry(telemetry: Option<&dyn DbTelemetry>) -> Option<&dyn DbTelemetry> {
115    telemetry.or_else(|| PROCESS_DB_TELEMETRY.get().map(AsRef::as_ref))
116}
117
118struct DbOutcomeTags {
119    status: &'static str,
120    error: &'static str,
121}
122
123impl DbOutcomeTags {
124    fn from_result<T>(result: &anyhow::Result<T>) -> Self {
125        match result {
126            Ok(_) => Self {
127                status: "success",
128                error: "none",
129            },
130            Err(err) => Self {
131                status: "failed",
132                error: classify_error(err),
133            },
134        }
135    }
136}
137
138fn classify_error(err: &anyhow::Error) -> &'static str {
139    for cause in err.chain() {
140        if let Some(sqlx_err) = cause.downcast_ref::<sqlx::Error>() {
141            return classify_sqlx_error(sqlx_err);
142        }
143        if cause
144            .downcast_ref::<sqlx::migrate::MigrateError>()
145            .is_some()
146        {
147            return "migration";
148        }
149        if cause.downcast_ref::<serde_json::Error>().is_some() {
150            return "serde";
151        }
152        if cause.downcast_ref::<std::io::Error>().is_some() {
153            return "io";
154        }
155    }
156    "unknown"
157}
158
159fn classify_sqlx_error(err: &sqlx::Error) -> &'static str {
160    match err {
161        sqlx::Error::Database(database_error) => {
162            let code = database_error
163                .code()
164                .unwrap_or(Cow::Borrowed("none"))
165                .to_string();
166            classify_sqlite_code(code.as_str())
167        }
168        sqlx::Error::PoolTimedOut => "pool_timeout",
169        sqlx::Error::Io(_) => "io",
170        sqlx::Error::ColumnDecode { source, .. } if source.is::<serde_json::Error>() => "serde",
171        sqlx::Error::Decode(source) if source.is::<serde_json::Error>() => "serde",
172        _ => "unknown",
173    }
174}
175
176fn classify_sqlite_code(code: &str) -> &'static str {
177    // SQLite result codes are documented at https://www.sqlite.org/rescode.html.
178    // Extended codes preserve the primary code in the low byte.
179    let primary_code = code.parse::<i32>().ok().map(|code| code & 0xff);
180    match primary_code {
181        Some(5) => "busy",
182        Some(6) => "locked",
183        Some(8) => "readonly",
184        Some(10) => "io",
185        Some(11) => "corrupt",
186        Some(13) => "full",
187        Some(14) => "cantopen",
188        Some(17) => "schema",
189        Some(19) => "constraint",
190        _ => "unknown",
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use pretty_assertions::assert_eq;
198
199    #[test]
200    fn classifies_extended_sqlite_codes() {
201        assert_eq!(classify_sqlite_code("5"), "busy");
202        assert_eq!(classify_sqlite_code("6"), "locked");
203        assert_eq!(classify_sqlite_code("2067"), "constraint");
204    }
205}