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;
59pub mod operator_docs;
60mod operator_docs_annotations;
61
62pub use config::{ObservabilityConfig, ObservabilityConfigBuilder, OtelProtocol};
63pub use error_schema::ErrorCategory;
64
65use crate::error::{Error, Result};
66
67#[cfg(feature = "observability")]
68mod otel;
69
70#[cfg(feature = "observability")]
71pub use otel::{init, tracing_layer, ObservabilityGuard};
72
73/// Shared in-memory OTLP capture harness for observability tests (issue #1043).
74///
75/// Gated behind the `observability-testing` feature, which pulls in the OTel
76/// SDK's in-memory exporters (`InMemorySpanExporter` / `InMemoryMetricExporter`)
77/// via `opentelemetry_sdk/testing`. Public so integration tests and future child
78/// issues can reuse the same fixture API to assert span trees and metric
79/// names/units/attributes. Production `observability` builds never compile this,
80/// so they never link the SDK's testing surface.
81#[cfg(feature = "observability-testing")]
82pub mod testing;
83
84/// A bounded attribute value for catalog metrics.
85///
86/// Mirrors the small set of types OpenTelemetry attributes accept, but is always
87/// available regardless of the `observability` feature so instrumentation call
88/// sites compile identically when telemetry is off. Keep values bounded
89/// (prefer [`AttrValue::StaticStr`] / numbers); never feed unbounded data such
90/// as raw error messages, partition keys, or full query text.
91#[derive(Debug, Clone)]
92pub enum AttrValue {
93    /// An owned string. Use only for genuinely bounded values.
94    Str(String),
95    /// A `&'static str` — the preferred, allocation-free form.
96    StaticStr(&'static str),
97    /// A signed integer.
98    Int(i64),
99    /// A floating-point value.
100    Float(f64),
101    /// A boolean.
102    Bool(bool),
103}
104
105impl From<&'static str> for AttrValue {
106    fn from(v: &'static str) -> Self {
107        AttrValue::StaticStr(v)
108    }
109}
110impl From<String> for AttrValue {
111    fn from(v: String) -> Self {
112        AttrValue::Str(v)
113    }
114}
115impl From<i64> for AttrValue {
116    fn from(v: i64) -> Self {
117        AttrValue::Int(v)
118    }
119}
120impl From<u64> for AttrValue {
121    fn from(v: u64) -> Self {
122        AttrValue::Int(v as i64)
123    }
124}
125impl From<f64> for AttrValue {
126    fn from(v: f64) -> Self {
127        AttrValue::Float(v)
128    }
129}
130impl From<bool> for AttrValue {
131    fn from(v: bool) -> Self {
132        AttrValue::Bool(v)
133    }
134}
135
136/// A single bounded metric attribute: a `&'static str` key (always from
137/// [`catalog::attr`]) paired with a bounded [`AttrValue`].
138pub type Attr = (&'static str, AttrValue);
139
140#[cfg(feature = "observability")]
141fn to_key_values(attrs: &[Attr]) -> Vec<opentelemetry::KeyValue> {
142    use opentelemetry::{KeyValue, Value};
143    attrs
144        .iter()
145        .map(|(k, v)| {
146            let value = match v {
147                AttrValue::Str(s) => Value::from(s.clone()),
148                AttrValue::StaticStr(s) => Value::from(*s),
149                AttrValue::Int(i) => Value::from(*i),
150                AttrValue::Float(f) => Value::from(*f),
151                AttrValue::Bool(b) => Value::from(*b),
152            };
153            KeyValue::new(*k, value)
154        })
155        .collect()
156}
157
158/// Add `value` to the monotonic counter identified by a [`catalog`] name, with
159/// bounded [`Attr`] attributes. No-op (and zero-cost) when the `observability`
160/// feature is off.
161#[inline]
162pub fn add_counter(name: &'static str, value: u64, attrs: &[Attr]) {
163    #[cfg(feature = "observability")]
164    {
165        if otel::metrics_active() {
166            otel::add_counter(name, value, &to_key_values(attrs));
167        } else {
168            let _ = (name, value, attrs);
169        }
170    }
171    #[cfg(not(feature = "observability"))]
172    {
173        let _ = (name, value, attrs);
174    }
175}
176
177/// Record `value` into the histogram identified by a [`catalog`] name (durations
178/// in seconds, sizes in bytes — see the catalog docs). No-op when off.
179#[inline]
180pub fn record_histogram(name: &'static str, value: f64, attrs: &[Attr]) {
181    #[cfg(feature = "observability")]
182    {
183        if otel::metrics_active() {
184            otel::record_histogram(name, value, &to_key_values(attrs));
185        } else {
186            let _ = (name, value, attrs);
187        }
188    }
189    #[cfg(not(feature = "observability"))]
190    {
191        let _ = (name, value, attrs);
192    }
193}
194
195/// Record `value` for the gauge identified by a [`catalog`] name (a current
196/// value such as in-flight count or open SSTables). No-op when off.
197#[inline]
198pub fn record_gauge(name: &'static str, value: i64, attrs: &[Attr]) {
199    #[cfg(feature = "observability")]
200    {
201        if otel::metrics_active() {
202            otel::record_gauge(name, value, &to_key_values(attrs));
203        } else {
204            let _ = (name, value, attrs);
205        }
206    }
207    #[cfg(not(feature = "observability"))]
208    {
209        let _ = (name, value, attrs);
210    }
211}
212
213/// Record an error that escaped a critical section (issue #1038).
214///
215/// Increments [`catalog::ERRORS_TOTAL`] keyed by the bounded
216/// `{cqlite.error.category, cqlite.subsystem}` label set and marks the active
217/// span as errored. `subsystem` is a `&'static str` (e.g. `"reader"`,
218/// `"query"`, `"write"`, `"compaction"`) so its value space stays bounded. The
219/// raw error message is never recorded. No-op when the `observability` feature
220/// is off.
221#[inline]
222pub fn record_error(err: &Error, subsystem: &'static str) {
223    #[cfg(feature = "observability")]
224    {
225        if otel::metrics_active() {
226            let attrs = [
227                opentelemetry::KeyValue::new(
228                    catalog::attr::ERROR_CATEGORY,
229                    err.obs_category().as_str(),
230                ),
231                opentelemetry::KeyValue::new(catalog::attr::SUBSYSTEM, subsystem),
232            ];
233            otel::add_counter(catalog::ERRORS_TOTAL, 1, &attrs);
234            otel::mark_span_error(err.obs_category());
235        } else {
236            let _ = (err, subsystem);
237        }
238    }
239    #[cfg(not(feature = "observability"))]
240    {
241        let _ = (err, subsystem);
242    }
243}
244
245/// Convenience: run a `Result`-returning closure and [`record_error`] on the
246/// `Err` path, returning the result unchanged. Lets call sites instrument an
247/// operation without restructuring their error handling.
248#[inline]
249pub fn record_result<T>(subsystem: &'static str, result: Result<T>) -> Result<T> {
250    if let Err(e) = &result {
251        record_error(e, subsystem);
252    }
253    result
254}
255
256/// Parent a `tracing` span under a remote trace described by a W3C
257/// [`traceparent`](https://www.w3.org/TR/trace-context/#traceparent-header)
258/// header string (issues #1039/#1040/#1041).
259///
260/// Hosts (the Python/Node bindings, the Flight server) frequently receive an
261/// incoming `traceparent` from a caller's tracer and want the per-call CQLite
262/// span to attach to that remote trace. This helper extracts the W3C trace
263/// context with the standard [`TraceContextPropagator`] and sets it as the
264/// parent of `span` via `tracing-opentelemetry`'s `set_parent`.
265///
266/// It is always callable: when the `observability` feature is off — or when
267/// `traceparent` is `None`/empty/unparseable — it is a no-op, so call sites
268/// stay identical across builds. Pass the per-open or per-call traceparent
269/// straight from the binding boundary; never synthesise one.
270#[inline]
271pub fn set_span_parent_from_traceparent(span: &tracing::Span, traceparent: Option<&str>) {
272    #[cfg(feature = "observability")]
273    otel::set_span_parent_from_traceparent(span, traceparent);
274    #[cfg(not(feature = "observability"))]
275    {
276        let _ = (span, traceparent);
277    }
278}
279
280/// Inert observability guard returned by [`init`] when the `observability`
281/// feature is disabled. Flushes and installs nothing.
282#[cfg(not(feature = "observability"))]
283#[derive(Debug)]
284pub struct ObservabilityGuard {
285    _private: (),
286}
287
288#[cfg(not(feature = "observability"))]
289impl ObservabilityGuard {
290    /// Always `false` for the inert guard.
291    pub fn is_active(&self) -> bool {
292        false
293    }
294    /// No-op flush.
295    pub fn force_flush(&self) {}
296}
297
298/// Initialise observability from `cfg`. When the `observability` feature is
299/// disabled this is a no-op (no OTel wiring) that returns an inert
300/// [`ObservabilityGuard`], so callers (CLI, Flight, bindings) can call it
301/// unconditionally.
302///
303/// It STILL plumbs `cfg.verify_presence_oracle` into the presence-oracle
304/// false-negative verification switch (issue #2163, roborev r4): that switch is
305/// an always-compiled storage-layer knob, independent of whether the OTel export
306/// stack is linked, so a config-only build without the `observability` feature
307/// can still enable the confirmation-scan correctness check (its counter emit is
308/// simply a no-op in that build, per the module's zero-cost-when-off contract).
309#[cfg(not(feature = "observability"))]
310#[inline]
311pub fn init(cfg: ObservabilityConfig) -> Result<ObservabilityGuard> {
312    crate::storage::sstable::reader::presence_verification::apply_config(
313        cfg.verify_presence_oracle,
314    );
315    Ok(ObservabilityGuard { _private: () })
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn helpers_are_callable_in_any_build() {
324        // These must compile and not panic regardless of feature state.
325        add_counter(
326            catalog::READ_ROWS,
327            3,
328            &[(catalog::attr::SSTABLE_FORMAT, "bti".into())],
329        );
330        record_histogram(catalog::QUERY_DURATION, 0.001, &[]);
331        record_gauge(catalog::SSTABLES_OPEN, 2, &[]);
332        let err = Error::corruption("x");
333        record_error(&err, "reader");
334        let ok: Result<u8> = record_result("reader", Ok(1));
335        assert_eq!(ok.ok(), Some(1));
336    }
337
338    #[test]
339    fn init_returns_guard() {
340        // With the feature off this exercises the inert guard; with it on it
341        // exercises the disabled-config inert path in otel::init.
342        let cfg = ObservabilityConfig::builder().enabled(false).build();
343        let guard = init(cfg).expect("init never fails for a disabled config");
344        assert!(!guard.is_active());
345        guard.force_flush();
346    }
347
348    #[test]
349    fn attr_value_conversions() {
350        assert!(matches!(AttrValue::from("s"), AttrValue::StaticStr("s")));
351        assert!(matches!(AttrValue::from(1i64), AttrValue::Int(1)));
352        assert!(matches!(AttrValue::from(2u64), AttrValue::Int(2)));
353        assert!(matches!(AttrValue::from(true), AttrValue::Bool(true)));
354    }
355}