Skip to main content

cqlite_core/observability/
error_schema.rs

1//! Low-cardinality error taxonomy for observability (issue #1038).
2//!
3//! This taxonomy is intentionally distinct from [`crate::error::ErrorCategory`]:
4//! that one is a 14-variant developer-facing grouping consumed by the CLI and
5//! existing tests, and changing it would break public API. The taxonomy here is
6//! the *telemetry* taxonomy — a small, stable, bounded set of `&'static str`
7//! labels safe to use as a metric/span attribute value (see
8//! [`crate::observability::catalog::attr::ERROR_CATEGORY`]).
9//!
10//! # Taxonomy
11//!
12//! | Variant        | `as_str()`       | Maps from `cqlite_core::Error` …                                  |
13//! |----------------|------------------|-------------------------------------------------------------------|
14//! | `Io`           | `io`             | `Io`, `InvalidPath`, `Timeout`                                     |
15//! | `Serialization`| `serialization`  | `Serialization`, `TypeConversion`                                 |
16//! | `Corruption`   | `corruption`     | `Corruption`                                                       |
17//! | `Schema`       | `schema`         | `Schema`, `Table`                                                  |
18//! | `Parsing`      | `parsing`        | `Parse`, `CqlParse`, `InvalidFormat`, `UnsupportedFormat`         |
19//! | `Storage`      | `storage`        | `Storage`, `Memory`, `Index`, `Compaction`, `WriteDirLocked`     |
20//! | `Concurrency`  | `concurrency`    | `Concurrency`, `Transaction`                                       |
21//! | `Constraints`  | `constraints`    | `ConstraintViolation`, `AlreadyExists`                            |
22//! | `Query`        | `query`          | `QueryExecution`, `UnsupportedQuery`, `InvalidInput`             |
23//! | `Cancelled`    | `cancelled`      | `Cancelled` (issue #2264 — a cooperative abort, never `Io`)       |
24//! | `Other`        | `other`          | `Configuration`, `InvalidState`, `InvalidOperation`, `NotFound`,  |
25//! |                |                  | `Internal`, `Wasm`, and any future variant (catch-all)            |
26//!
27//! # Relation to spans and CLI exit codes
28//!
29//! - **Spans**: [`crate::observability::record_error`] attaches the
30//!   `as_str()` value as the `cqlite.error.category` attribute on the
31//!   `cqlite.errors.total` counter and as a span event field, and marks the
32//!   current span `otel.status_code = ERROR`.
33//! - **CLI exit codes** (`cqlite-cli/src/error.rs`): the CLI maps the *raw*
34//!   `Error` variants to numeric exit codes (2/3/4/5/6). This taxonomy is a
35//!   coarser, monitoring-oriented view; the rough correspondence is:
36//!   `Schema → exit 3`, `Io`/`Storage` → exit 4, `Query`/`Parsing` → exit 5.
37//!   The two are deliberately decoupled: exit codes are an operator contract,
38//!   the telemetry taxonomy is tuned for dashboards/alerts.
39
40use crate::error::Error;
41
42/// Bounded, telemetry-safe error categories. The total count is small and
43/// fixed, making `as_str()` values safe as metric/span attribute values.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
45pub enum ErrorCategory {
46    /// Filesystem / OS I/O, paths, timeouts.
47    Io,
48    /// (De)serialization and type-conversion failures.
49    Serialization,
50    /// On-disk data corruption / checksum failures.
51    Corruption,
52    /// Schema / catalog problems.
53    Schema,
54    /// Binary-format / CQL text parsing failures.
55    Parsing,
56    /// Storage-engine, memory, index, compaction, locking.
57    Storage,
58    /// Concurrency / transaction failures.
59    Concurrency,
60    /// Constraint violations and conflicts.
61    Constraints,
62    /// Query execution / unsupported queries / bad input.
63    Query,
64    /// A cooperative cancellation / abort (issue #2264). Kept distinct from
65    /// `Other` (and never `Io`) so dashboards can see cancellation rate
66    /// separately from genuine errors — a cancelled `do_get` is an expected
67    /// outcome, not a fault.
68    Cancelled,
69    /// Everything else (configuration, internal, platform, catch-all).
70    Other,
71}
72
73impl ErrorCategory {
74    /// Stable, low-cardinality label. Safe to use as a metric/span attribute
75    /// value — these strings never change for a given variant.
76    pub fn as_str(self) -> &'static str {
77        match self {
78            ErrorCategory::Io => "io",
79            ErrorCategory::Serialization => "serialization",
80            ErrorCategory::Corruption => "corruption",
81            ErrorCategory::Schema => "schema",
82            ErrorCategory::Parsing => "parsing",
83            ErrorCategory::Storage => "storage",
84            ErrorCategory::Concurrency => "concurrency",
85            ErrorCategory::Constraints => "constraints",
86            ErrorCategory::Query => "query",
87            ErrorCategory::Cancelled => "cancelled",
88            ErrorCategory::Other => "other",
89        }
90    }
91
92    /// All variants, for tests and exhaustiveness checks.
93    pub const ALL: &'static [ErrorCategory] = &[
94        ErrorCategory::Io,
95        ErrorCategory::Serialization,
96        ErrorCategory::Corruption,
97        ErrorCategory::Schema,
98        ErrorCategory::Parsing,
99        ErrorCategory::Storage,
100        ErrorCategory::Concurrency,
101        ErrorCategory::Constraints,
102        ErrorCategory::Query,
103        ErrorCategory::Cancelled,
104        ErrorCategory::Other,
105    ];
106}
107
108impl std::fmt::Display for ErrorCategory {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        f.write_str(self.as_str())
111    }
112}
113
114/// Map a [`cqlite_core::Error`](crate::error::Error) to its telemetry
115/// [`ErrorCategory`]. This is the single classification point; both
116/// `Error::obs_category` and `record_error` route through it.
117pub(crate) fn classify(err: &Error) -> ErrorCategory {
118    match err {
119        Error::Io(_) | Error::InvalidPath(_) | Error::Timeout(_) => ErrorCategory::Io,
120
121        Error::Serialization { .. } | Error::TypeConversion(_) => ErrorCategory::Serialization,
122
123        Error::Corruption(_) => ErrorCategory::Corruption,
124
125        Error::Schema(_) | Error::Table(_) => ErrorCategory::Schema,
126
127        Error::Parse(_)
128        | Error::CqlParse(_)
129        | Error::InvalidFormat(_)
130        | Error::UnsupportedFormat(_)
131        | Error::UnsupportedVersion { .. } => ErrorCategory::Parsing,
132
133        Error::Storage(_)
134        | Error::Memory(_)
135        | Error::Index(_)
136        | Error::Compaction(_)
137        | Error::WriteDirLocked { .. } => ErrorCategory::Storage,
138
139        Error::Concurrency(_) | Error::Transaction(_) => ErrorCategory::Concurrency,
140
141        Error::ConstraintViolation(_) | Error::AlreadyExists(_) => ErrorCategory::Constraints,
142
143        Error::QueryExecution(_)
144        | Error::ResultTooLarge { .. }
145        | Error::UnsupportedQuery(_)
146        | Error::InvalidInput(_) => ErrorCategory::Query,
147
148        // Issue #2264: a cooperative cancellation is an expected outcome, not a
149        // fault — kept out of both `Io` and the `Other` catch-all.
150        Error::Cancelled => ErrorCategory::Cancelled,
151
152        // Catch-all for the remaining variants and any future additions. Listed
153        // explicitly (no wildcard arm besides wasm) so that adding a new Error
154        // variant forces a compile decision here.
155        Error::Configuration(_)
156        | Error::InvalidState(_)
157        | Error::InvalidOperation(_)
158        | Error::NotFound(_)
159        | Error::Internal(_) => ErrorCategory::Other,
160
161        #[cfg(target_arch = "wasm32")]
162        Error::Wasm(_) => ErrorCategory::Other,
163    }
164}
165
166impl Error {
167    /// Telemetry error category for this error (issue #1038).
168    ///
169    /// Distinct from [`Error::category`](crate::error::Error::category), which
170    /// returns the developer-facing [`crate::error::ErrorCategory`]. This one
171    /// returns the bounded, monitoring-oriented
172    /// [`crate::observability::ErrorCategory`].
173    pub fn obs_category(&self) -> ErrorCategory {
174        classify(self)
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::error::Error;
182
183    #[test]
184    fn as_str_is_lowercase_and_unique() {
185        let mut seen = std::collections::HashSet::new();
186        for c in ErrorCategory::ALL {
187            let s = c.as_str();
188            assert_eq!(s, s.to_ascii_lowercase());
189            assert!(seen.insert(s), "duplicate category label {s}");
190        }
191        assert_eq!(seen.len(), ErrorCategory::ALL.len());
192    }
193
194    #[test]
195    fn display_matches_as_str() {
196        for c in ErrorCategory::ALL {
197            assert_eq!(c.to_string(), c.as_str());
198        }
199    }
200
201    // Exhaustively cover every Error constructor / variant -> expected category.
202    #[test]
203    fn classify_every_error_variant() {
204        use ErrorCategory::*;
205
206        let io = std::io::Error::other("x");
207        assert_eq!(Error::from(io).obs_category(), Io);
208        assert_eq!(Error::invalid_path("p").obs_category(), Io);
209        assert_eq!(Error::Timeout("t".into()).obs_category(), Io);
210
211        assert_eq!(Error::serialization("s").obs_category(), Serialization);
212        assert_eq!(Error::type_conversion("t").obs_category(), Serialization);
213
214        assert_eq!(Error::corruption("c").obs_category(), Corruption);
215
216        assert_eq!(Error::schema("s").obs_category(), Schema);
217        assert_eq!(Error::Table("t".into()).obs_category(), Schema);
218
219        assert_eq!(Error::parse("p").obs_category(), Parsing);
220        assert_eq!(Error::cql_parse("p").obs_category(), Parsing);
221        assert_eq!(Error::invalid_format("f").obs_category(), Parsing);
222        assert_eq!(Error::unsupported_format("f").obs_category(), Parsing);
223
224        assert_eq!(Error::storage("s").obs_category(), Storage);
225        assert_eq!(Error::memory("m").obs_category(), Storage);
226        assert_eq!(Error::index("i").obs_category(), Storage);
227        assert_eq!(Error::compaction("c").obs_category(), Storage);
228        assert_eq!(Error::write_dir_locked("/d").obs_category(), Storage);
229
230        assert_eq!(Error::concurrency("c").obs_category(), Concurrency);
231        assert_eq!(Error::transaction("t").obs_category(), Concurrency);
232
233        assert_eq!(Error::constraint_violation("c").obs_category(), Constraints);
234        assert_eq!(Error::already_exists("a").obs_category(), Constraints);
235
236        assert_eq!(Error::query_execution("q").obs_category(), Query);
237        assert_eq!(Error::unsupported_query("q").obs_category(), Query);
238        assert_eq!(Error::invalid_input("i").obs_category(), Query);
239
240        assert_eq!(Error::configuration("c").obs_category(), Other);
241        assert_eq!(Error::invalid_state("s").obs_category(), Other);
242        assert_eq!(Error::invalid_operation("o").obs_category(), Other);
243        assert_eq!(Error::not_found("n").obs_category(), Other);
244        assert_eq!(Error::internal("i").obs_category(), Other);
245
246        // Issue #2264: a cooperative cancellation must be its OWN bucket, not
247        // `Io` (misleading — it is not a transport failure) and not lumped into
248        // the generic `Other` catch-all (would hide cancellation rate).
249        assert_eq!(Error::Cancelled.obs_category(), Cancelled);
250    }
251}