Skip to main content

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}