cqlite_core/observability/mod.rs
1//! Observability foundation (epic #1031, issues #1032 + #1038).
2//!
3//! This module is the single shared foundation every other observability issue
4//! builds on. It owns OpenTelemetry initialisation, the `CQLITE_OTEL_*` config,
5//! the metric-naming [`catalog`], the error-rate schema ([`ErrorCategory`] +
6//! [`record_error`]), and graceful shutdown — and it is designed so that
7//! **instrumentation call sites are identical whether or not the
8//! `observability` feature is enabled.** When the feature is off, every helper
9//! here compiles to a no-op and `cargo tree -p cqlite-core` links no
10//! OpenTelemetry crates.
11//!
12//! # The contract for downstream issues (#1033–#1043)
13//!
14//! Downstream code MUST go through this module rather than touching
15//! `opentelemetry`/`tracing-opentelemetry` directly, so telemetry stays
16//! consistent and zero-cost-when-off:
17//!
18//! **Initialisation (CLI #1033, Flight #1041, bindings #1039/#1040):**
19//! ```ignore
20//! let cfg = observability::ObservabilityConfig::from_env();
21//! let _guard = observability::init(cfg)?; // RAII; flushes on drop
22//! // compose the tracing layer into your own subscriber (feature-gated):
23//! # #[cfg(feature = "observability")]
24//! tracing_subscriber::registry()
25//! .with(tracing_subscriber::fmt::layer())
26//! .with(observability::tracing_layer())
27//! .init();
28//! ```
29//!
30//! **Spans (read/query/write/compaction #1034–#1037):** use the ordinary
31//! `tracing` macros (`#[tracing::instrument]`, `tracing::info_span!`) — they are
32//! always available and the OTel layer bridges them when active. Do NOT create
33//! per-cell spans on hot paths; aggregate with metrics instead.
34//!
35//! **Metrics:** call [`add_counter`], [`record_histogram`], [`record_gauge`]
36//! with a name from [`catalog`] and bounded [`Attr`] attributes whose keys come
37//! from [`catalog::attr`]:
38//! ```ignore
39//! use cqlite_core::observability::{self as obs, catalog, AttrValue};
40//! obs::add_counter(catalog::READ_ROWS, n, &[(catalog::attr::SSTABLE_FORMAT, "bti".into())]);
41//! obs::record_histogram(catalog::QUERY_DURATION, elapsed.as_secs_f64(), &[]);
42//! ```
43//!
44//! **Errors (#1038):** at every boundary instrumented by #1034–#1037, call
45//! [`record_error`] when an error escapes — it increments
46//! [`catalog::ERRORS_TOTAL`] keyed by the bounded `{category, subsystem}` label
47//! set and marks the active span errored. Never attach the raw error message.
48//!
49//! # Always-compiled vs feature-gated
50//!
51//! [`catalog`], [`config`], the [`ErrorCategory`] taxonomy, and the helper
52//! signatures here are ALWAYS compiled (they pull in no OTel types), so call
53//! sites and tests build in any configuration. Only the exporter/runtime wiring
54//! ([`otel`]) is gated behind `observability`.
55
56pub mod catalog;
57pub mod config;
58mod error_schema;
59
60pub use config::{ObservabilityConfig, ObservabilityConfigBuilder, OtelProtocol};
61pub use error_schema::ErrorCategory;
62
63use crate::error::{Error, Result};
64
65#[cfg(feature = "observability")]
66mod otel;
67
68#[cfg(feature = "observability")]
69pub use otel::{init, tracing_layer, ObservabilityGuard};
70
71/// Shared in-memory OTLP capture harness for observability tests (issue #1043).
72///
73/// Gated behind the `observability-testing` feature, which pulls in the OTel
74/// SDK's in-memory exporters (`InMemorySpanExporter` / `InMemoryMetricExporter`)
75/// via `opentelemetry_sdk/testing`. Public so integration tests and future child
76/// issues can reuse the same fixture API to assert span trees and metric
77/// names/units/attributes. Production `observability` builds never compile this,
78/// so they never link the SDK's testing surface.
79#[cfg(feature = "observability-testing")]
80pub mod testing;
81
82/// A bounded attribute value for catalog metrics.
83///
84/// Mirrors the small set of types OpenTelemetry attributes accept, but is always
85/// available regardless of the `observability` feature so instrumentation call
86/// sites compile identically when telemetry is off. Keep values bounded
87/// (prefer [`AttrValue::StaticStr`] / numbers); never feed unbounded data such
88/// as raw error messages, partition keys, or full query text.
89#[derive(Debug, Clone)]
90pub enum AttrValue {
91 /// An owned string. Use only for genuinely bounded values.
92 Str(String),
93 /// A `&'static str` — the preferred, allocation-free form.
94 StaticStr(&'static str),
95 /// A signed integer.
96 Int(i64),
97 /// A floating-point value.
98 Float(f64),
99 /// A boolean.
100 Bool(bool),
101}
102
103impl From<&'static str> for AttrValue {
104 fn from(v: &'static str) -> Self {
105 AttrValue::StaticStr(v)
106 }
107}
108impl From<String> for AttrValue {
109 fn from(v: String) -> Self {
110 AttrValue::Str(v)
111 }
112}
113impl From<i64> for AttrValue {
114 fn from(v: i64) -> Self {
115 AttrValue::Int(v)
116 }
117}
118impl From<u64> for AttrValue {
119 fn from(v: u64) -> Self {
120 AttrValue::Int(v as i64)
121 }
122}
123impl From<f64> for AttrValue {
124 fn from(v: f64) -> Self {
125 AttrValue::Float(v)
126 }
127}
128impl From<bool> for AttrValue {
129 fn from(v: bool) -> Self {
130 AttrValue::Bool(v)
131 }
132}
133
134/// A single bounded metric attribute: a `&'static str` key (always from
135/// [`catalog::attr`]) paired with a bounded [`AttrValue`].
136pub type Attr = (&'static str, AttrValue);
137
138#[cfg(feature = "observability")]
139fn to_key_values(attrs: &[Attr]) -> Vec<opentelemetry::KeyValue> {
140 use opentelemetry::{KeyValue, Value};
141 attrs
142 .iter()
143 .map(|(k, v)| {
144 let value = match v {
145 AttrValue::Str(s) => Value::from(s.clone()),
146 AttrValue::StaticStr(s) => Value::from(*s),
147 AttrValue::Int(i) => Value::from(*i),
148 AttrValue::Float(f) => Value::from(*f),
149 AttrValue::Bool(b) => Value::from(*b),
150 };
151 KeyValue::new(*k, value)
152 })
153 .collect()
154}
155
156/// Add `value` to the monotonic counter identified by a [`catalog`] name, with
157/// bounded [`Attr`] attributes. No-op (and zero-cost) when the `observability`
158/// feature is off.
159#[inline]
160pub fn add_counter(name: &'static str, value: u64, attrs: &[Attr]) {
161 #[cfg(feature = "observability")]
162 {
163 if otel::metrics_active() {
164 otel::add_counter(name, value, &to_key_values(attrs));
165 } else {
166 let _ = (name, value, attrs);
167 }
168 }
169 #[cfg(not(feature = "observability"))]
170 {
171 let _ = (name, value, attrs);
172 }
173}
174
175/// Record `value` into the histogram identified by a [`catalog`] name (durations
176/// in seconds, sizes in bytes — see the catalog docs). No-op when off.
177#[inline]
178pub fn record_histogram(name: &'static str, value: f64, attrs: &[Attr]) {
179 #[cfg(feature = "observability")]
180 {
181 if otel::metrics_active() {
182 otel::record_histogram(name, value, &to_key_values(attrs));
183 } else {
184 let _ = (name, value, attrs);
185 }
186 }
187 #[cfg(not(feature = "observability"))]
188 {
189 let _ = (name, value, attrs);
190 }
191}
192
193/// Record `value` for the gauge identified by a [`catalog`] name (a current
194/// value such as in-flight count or open SSTables). No-op when off.
195#[inline]
196pub fn record_gauge(name: &'static str, value: i64, attrs: &[Attr]) {
197 #[cfg(feature = "observability")]
198 {
199 if otel::metrics_active() {
200 otel::record_gauge(name, value, &to_key_values(attrs));
201 } else {
202 let _ = (name, value, attrs);
203 }
204 }
205 #[cfg(not(feature = "observability"))]
206 {
207 let _ = (name, value, attrs);
208 }
209}
210
211/// Record an error that escaped a critical section (issue #1038).
212///
213/// Increments [`catalog::ERRORS_TOTAL`] keyed by the bounded
214/// `{cqlite.error.category, cqlite.subsystem}` label set and marks the active
215/// span as errored. `subsystem` is a `&'static str` (e.g. `"reader"`,
216/// `"query"`, `"write"`, `"compaction"`) so its value space stays bounded. The
217/// raw error message is never recorded. No-op when the `observability` feature
218/// is off.
219#[inline]
220pub fn record_error(err: &Error, subsystem: &'static str) {
221 #[cfg(feature = "observability")]
222 {
223 if otel::metrics_active() {
224 let attrs = [
225 opentelemetry::KeyValue::new(
226 catalog::attr::ERROR_CATEGORY,
227 err.obs_category().as_str(),
228 ),
229 opentelemetry::KeyValue::new(catalog::attr::SUBSYSTEM, subsystem),
230 ];
231 otel::add_counter(catalog::ERRORS_TOTAL, 1, &attrs);
232 otel::mark_span_error(err.obs_category());
233 } else {
234 let _ = (err, subsystem);
235 }
236 }
237 #[cfg(not(feature = "observability"))]
238 {
239 let _ = (err, subsystem);
240 }
241}
242
243/// Convenience: run a `Result`-returning closure and [`record_error`] on the
244/// `Err` path, returning the result unchanged. Lets call sites instrument an
245/// operation without restructuring their error handling.
246#[inline]
247pub fn record_result<T>(subsystem: &'static str, result: Result<T>) -> Result<T> {
248 if let Err(e) = &result {
249 record_error(e, subsystem);
250 }
251 result
252}
253
254/// Parent a `tracing` span under a remote trace described by a W3C
255/// [`traceparent`](https://www.w3.org/TR/trace-context/#traceparent-header)
256/// header string (issues #1039/#1040/#1041).
257///
258/// Hosts (the Python/Node bindings, the Flight server) frequently receive an
259/// incoming `traceparent` from a caller's tracer and want the per-call CQLite
260/// span to attach to that remote trace. This helper extracts the W3C trace
261/// context with the standard [`TraceContextPropagator`] and sets it as the
262/// parent of `span` via `tracing-opentelemetry`'s `set_parent`.
263///
264/// It is always callable: when the `observability` feature is off — or when
265/// `traceparent` is `None`/empty/unparseable — it is a no-op, so call sites
266/// stay identical across builds. Pass the per-open or per-call traceparent
267/// straight from the binding boundary; never synthesise one.
268#[inline]
269pub fn set_span_parent_from_traceparent(span: &tracing::Span, traceparent: Option<&str>) {
270 #[cfg(feature = "observability")]
271 otel::set_span_parent_from_traceparent(span, traceparent);
272 #[cfg(not(feature = "observability"))]
273 {
274 let _ = (span, traceparent);
275 }
276}
277
278/// Inert observability guard returned by [`init`] when the `observability`
279/// feature is disabled. Flushes and installs nothing.
280#[cfg(not(feature = "observability"))]
281#[derive(Debug)]
282pub struct ObservabilityGuard {
283 _private: (),
284}
285
286#[cfg(not(feature = "observability"))]
287impl ObservabilityGuard {
288 /// Always `false` for the inert guard.
289 pub fn is_active(&self) -> bool {
290 false
291 }
292 /// No-op flush.
293 pub fn force_flush(&self) {}
294}
295
296/// Initialise observability from `cfg`. When the `observability` feature is
297/// disabled this is a no-op that returns an inert [`ObservabilityGuard`], so
298/// callers (CLI, Flight, bindings) can call it unconditionally.
299#[cfg(not(feature = "observability"))]
300#[inline]
301pub fn init(cfg: ObservabilityConfig) -> Result<ObservabilityGuard> {
302 let _ = cfg;
303 Ok(ObservabilityGuard { _private: () })
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309
310 #[test]
311 fn helpers_are_callable_in_any_build() {
312 // These must compile and not panic regardless of feature state.
313 add_counter(
314 catalog::READ_ROWS,
315 3,
316 &[(catalog::attr::SSTABLE_FORMAT, "bti".into())],
317 );
318 record_histogram(catalog::QUERY_DURATION, 0.001, &[]);
319 record_gauge(catalog::SSTABLES_OPEN, 2, &[]);
320 let err = Error::corruption("x");
321 record_error(&err, "reader");
322 let ok: Result<u8> = record_result("reader", Ok(1));
323 assert_eq!(ok.ok(), Some(1));
324 }
325
326 #[test]
327 fn init_returns_guard() {
328 // With the feature off this exercises the inert guard; with it on it
329 // exercises the disabled-config inert path in otel::init.
330 let cfg = ObservabilityConfig::builder().enabled(false).build();
331 let guard = init(cfg).expect("init never fails for a disabled config");
332 assert!(!guard.is_active());
333 guard.force_flush();
334 }
335
336 #[test]
337 fn attr_value_conversions() {
338 assert!(matches!(AttrValue::from("s"), AttrValue::StaticStr("s")));
339 assert!(matches!(AttrValue::from(1i64), AttrValue::Int(1)));
340 assert!(matches!(AttrValue::from(2u64), AttrValue::Int(2)));
341 assert!(matches!(AttrValue::from(true), AttrValue::Bool(true)));
342 }
343}