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