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(_) | Error::CorruptCommitLogFrame(_) => 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 { .. }
132        | Error::UnsupportedCommitLogVersion { .. } => ErrorCategory::Parsing,
133
134        Error::Storage(_)
135        | Error::Memory(_)
136        | Error::Index(_)
137        | Error::Compaction(_)
138        | Error::WriteDirLocked { .. } => ErrorCategory::Storage,
139
140        Error::Concurrency(_) | Error::Transaction(_) => ErrorCategory::Concurrency,
141
142        Error::ConstraintViolation(_) | Error::AlreadyExists(_) => ErrorCategory::Constraints,
143
144        Error::QueryExecution(_)
145        | Error::ResultTooLarge { .. }
146        | Error::UnsupportedQuery(_)
147        // Issue #1918: the read-path forcing knob failing closed is a query-time
148        // outcome (`point` unavailable / invalid knob value).
149        | Error::ForcedReadPathUnavailable { .. }
150        | Error::InvalidReadPath { .. }
151        | Error::InvalidInput(_) => ErrorCategory::Query,
152
153        // Issue #2264: a cooperative cancellation is an expected outcome, not a
154        // fault — kept out of both `Io` and the `Other` catch-all.
155        Error::Cancelled => ErrorCategory::Cancelled,
156
157        // Catch-all for the remaining variants and any future additions. Listed
158        // explicitly (no wildcard arm besides wasm) so that adding a new Error
159        // variant forces a compile decision here.
160        Error::Configuration(_)
161        | Error::InvalidState(_)
162        | Error::InvalidOperation(_)
163        | Error::NotFound(_)
164        | Error::Internal(_) => ErrorCategory::Other,
165
166        #[cfg(target_arch = "wasm32")]
167        Error::Wasm(_) => ErrorCategory::Other,
168    }
169}
170
171impl Error {
172    /// Telemetry error category for this error (issue #1038).
173    ///
174    /// Distinct from [`Error::category`](crate::error::Error::category), which
175    /// returns the developer-facing [`crate::error::ErrorCategory`]. This one
176    /// returns the bounded, monitoring-oriented
177    /// [`crate::observability::ErrorCategory`].
178    pub fn obs_category(&self) -> ErrorCategory {
179        classify(self)
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::error::Error;
187
188    #[test]
189    fn as_str_is_lowercase_and_unique() {
190        let mut seen = std::collections::HashSet::new();
191        for c in ErrorCategory::ALL {
192            let s = c.as_str();
193            assert_eq!(s, s.to_ascii_lowercase());
194            assert!(seen.insert(s), "duplicate category label {s}");
195        }
196        assert_eq!(seen.len(), ErrorCategory::ALL.len());
197    }
198
199    #[test]
200    fn display_matches_as_str() {
201        for c in ErrorCategory::ALL {
202            assert_eq!(c.to_string(), c.as_str());
203        }
204    }
205
206    // Exhaustively cover every Error constructor / variant -> expected category.
207    #[test]
208    fn classify_every_error_variant() {
209        use ErrorCategory::*;
210
211        let io = std::io::Error::other("x");
212        assert_eq!(Error::from(io).obs_category(), Io);
213        assert_eq!(Error::invalid_path("p").obs_category(), Io);
214        assert_eq!(Error::Timeout("t".into()).obs_category(), Io);
215
216        assert_eq!(Error::serialization("s").obs_category(), Serialization);
217        assert_eq!(Error::type_conversion("t").obs_category(), Serialization);
218
219        assert_eq!(Error::corruption("c").obs_category(), Corruption);
220
221        assert_eq!(Error::schema("s").obs_category(), Schema);
222        assert_eq!(Error::Table("t".into()).obs_category(), Schema);
223
224        assert_eq!(Error::parse("p").obs_category(), Parsing);
225        assert_eq!(Error::cql_parse("p").obs_category(), Parsing);
226        assert_eq!(Error::invalid_format("f").obs_category(), Parsing);
227        assert_eq!(Error::unsupported_format("f").obs_category(), Parsing);
228
229        assert_eq!(Error::storage("s").obs_category(), Storage);
230        assert_eq!(Error::memory("m").obs_category(), Storage);
231        assert_eq!(Error::index("i").obs_category(), Storage);
232        assert_eq!(Error::compaction("c").obs_category(), Storage);
233        assert_eq!(Error::write_dir_locked("/d").obs_category(), Storage);
234
235        assert_eq!(Error::concurrency("c").obs_category(), Concurrency);
236        assert_eq!(Error::transaction("t").obs_category(), Concurrency);
237
238        assert_eq!(Error::constraint_violation("c").obs_category(), Constraints);
239        assert_eq!(Error::already_exists("a").obs_category(), Constraints);
240
241        assert_eq!(Error::query_execution("q").obs_category(), Query);
242        assert_eq!(Error::unsupported_query("q").obs_category(), Query);
243        assert_eq!(Error::invalid_input("i").obs_category(), Query);
244
245        assert_eq!(Error::configuration("c").obs_category(), Other);
246        assert_eq!(Error::invalid_state("s").obs_category(), Other);
247        assert_eq!(Error::invalid_operation("o").obs_category(), Other);
248        assert_eq!(Error::not_found("n").obs_category(), Other);
249        assert_eq!(Error::internal("i").obs_category(), Other);
250
251        // Issue #2264: a cooperative cancellation must be its OWN bucket, not
252        // `Io` (misleading — it is not a transport failure) and not lumped into
253        // the generic `Other` catch-all (would hide cancellation rate).
254        assert_eq!(Error::Cancelled.obs_category(), Cancelled);
255    }
256}