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}