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}