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
11pub 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
24pub 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, 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 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}