Skip to main content

backbone_observability/
lib.rs

1//! Backbone Framework Observability
2//!
3//! Tracing, logging, and metrics setup for Backbone applications.
4
5#![warn(missing_docs)]
6#![warn(clippy::all)]
7
8pub mod config;
9pub mod logging;
10pub mod metrics;
11pub mod middleware;
12pub mod tracing;
13
14#[cfg(feature = "audit")]
15pub mod audit;
16
17#[cfg(test)]
18mod tests;
19
20// Re-exports for convenience
21pub use config::{MetricsConfig, MetricsExporterType, ObservabilityConfig};
22pub use config::LoggingConfig as StructuredLoggingConfig;
23pub use logging::*;
24pub use metrics::*;
25pub use middleware::{ObservabilityLayer, ObservabilityMiddleware};
26
27/// Initialize structured logging with JSON output
28///
29/// # Arguments
30/// * `config` - Logging configuration
31/// * `service_name` - Name of the service for log labels
32/// * `environment` - Environment name (dev, staging, prod)
33/// * `log_level` - Log level (trace, debug, info, warn, error)
34///
35/// # Returns
36/// Returns a log buffer handle for accessing aggregated logs
37pub fn init_structured_logging(
38    config: &StructuredLoggingConfig,
39    service_name: &str,
40    environment: &str,
41    log_level: &str,
42) -> Result<LogBuffer, anyhow::Error> {
43    // Import the logging LogBuffer and LoggingConfig explicitly
44    use crate::logging::{LogBuffer, LoggingConfig};
45
46    // Convert the aliased config to the logging module's config
47    let logging_config = LoggingConfig {
48        json_format: config.json_format,
49        include_timestamp: config.include_timestamp,
50        include_hostname: config.include_hostname,
51        include_spans: config.include_spans,
52        extract_fields: config.extract_fields.clone(),
53        buffer_size: config.buffer_size,
54    };
55
56    let mut buffer_config = logging_config.clone();
57    buffer_config.extract_fields.push("service".to_string());
58    buffer_config.extract_fields.push("environment".to_string());
59
60    let buffer = LogBuffer::new(buffer_config);
61
62    // Initialize the actual tracing subscriber with the provided log level
63    crate::logging::init_structured_logging(
64        &logging_config,
65        service_name,
66        environment,
67        log_level,
68    )?;
69
70    Ok(buffer)
71}
72
73/// Get aggregated logs from a buffer
74///
75/// Convenience function for accessing logs from a buffer handle.
76pub fn get_aggregated_logs(buffer: &LogBuffer) -> Vec<LogEntry> {
77    buffer.drain()
78}
79
80// ---------------------------------------------------------------------------
81// Unified observability initialization (fixes double-subscriber bug B-12)
82// ---------------------------------------------------------------------------
83
84/// Opaque guard that flushes & shuts down the OTel tracer provider on drop.
85/// When `otel-tracing` is disabled this is a no-op.
86pub enum OtelShutdownGuard {
87    /// OTel tracing is active — provider will be shut down on drop.
88    #[cfg(feature = "otel-tracing")]
89    Active(opentelemetry_sdk::trace::TracerProvider),
90    /// OTel tracing not active (feature disabled or no config).
91    Inactive,
92}
93
94impl Drop for OtelShutdownGuard {
95    fn drop(&mut self) {
96        match self {
97            #[cfg(feature = "otel-tracing")]
98            Self::Active(_provider) => {
99                ::tracing::debug!("OtelShutdownGuard dropped — tracer provider shutdown");
100            }
101            Self::Inactive => {}
102        }
103    }
104}
105
106/// Unified observability initialization.
107///
108/// Combines structured logging (fmt layer) and optional OpenTelemetry tracing
109/// into a **single** global subscriber. This replaces the old pattern of calling
110/// `init_structured_logging()` + `init_tracing_with_otel()` separately, which
111/// caused a double-subscriber bug where the second `set_global_default` call
112/// silently failed and the OTel layer was never active.
113///
114/// # Arguments
115/// * `logging_config` — Structured logging settings (JSON/pretty, fields, buffer)
116/// * `otel_config` — Optional OTel configuration. When `Some` and `tracing_enabled`,
117///   an OTel layer is added (with optional OTLP exporter).
118/// * `service_name` — Identifies the service in logs and traces.
119/// * `environment` — e.g. "development", "production".
120/// * `log_level` — Filter directive such as "info" or "backbone=debug".
121pub fn init_observability(
122    logging_config: &StructuredLoggingConfig,
123    otel_config: Option<&ObservabilityConfig>,
124    service_name: &str,
125    environment: &str,
126    log_level: &str,
127) -> Result<(LogBuffer, OtelShutdownGuard), anyhow::Error> {
128    use ::tracing_subscriber::{Registry, EnvFilter, layer::SubscriberExt};
129
130    let filter = EnvFilter::try_from_default_env()
131        .unwrap_or_else(|_| EnvFilter::new(log_level));
132
133    // Build OTel tracer + provider when the feature is enabled and config is active.
134    // We prepare the tracer here so the OTel layer's generic `S` type parameter is
135    // inferred at subscriber-composition time (avoids the Box<dyn Layer<Registry>> mismatch).
136    #[cfg(feature = "otel-tracing")]
137    let (otel_tracer, guard) = match otel_config {
138        Some(config) if config.tracing_enabled => {
139            let (tracer, provider) = crate::tracing::build_otel_tracer(config)?;
140            (Some(tracer), OtelShutdownGuard::Active(provider))
141        }
142        _ => (None, OtelShutdownGuard::Inactive),
143    };
144    #[cfg(not(feature = "otel-tracing"))]
145    let (otel_tracer, guard): (Option<()>, _) = { let _ = otel_config; (None, OtelShutdownGuard::Inactive) };
146
147    // Compose subscriber: filter + fmt + optional OTel.
148    // The OTel layer is created inside each branch so its generic `S` type
149    // parameter is inferred from the specific fmt layer type in that branch.
150    macro_rules! set_subscriber {
151        ($filter:expr, $fmt:expr, $otel_tracer:expr) => {{
152            #[cfg(feature = "otel-tracing")]
153            {
154                let otel = $otel_tracer
155                    .map(|t| tracing_opentelemetry::layer().with_tracer(t));
156                let subscriber = Registry::default().with($filter).with($fmt).with(otel);
157                ::tracing::subscriber::set_global_default(subscriber)
158                    .map_err(|e| anyhow::anyhow!("Failed to set global subscriber: {e}"))?;
159            }
160            #[cfg(not(feature = "otel-tracing"))]
161            {
162                let _ = $otel_tracer;
163                let subscriber = Registry::default().with($filter).with($fmt);
164                ::tracing::subscriber::set_global_default(subscriber)
165                    .map_err(|e| anyhow::anyhow!("Failed to set global subscriber: {e}"))?;
166            }
167        }};
168    }
169
170    if logging_config.json_format {
171        let json = ::tracing_subscriber::fmt::layer()
172            .json()
173            .with_target(true)
174            .with_thread_ids(false)
175            .with_file(false)
176            .with_line_number(false)
177            .with_span_events(::tracing_subscriber::fmt::format::FmtSpan::CLOSE);
178        set_subscriber!(filter, json, otel_tracer);
179    } else {
180        let pretty = ::tracing_subscriber::fmt::layer()
181            .with_target(true)
182            .with_thread_ids(false)
183            .with_span_events(::tracing_subscriber::fmt::format::FmtSpan::CLOSE);
184        set_subscriber!(filter, pretty, otel_tracer);
185    }
186
187    // Build log buffer
188    let buffer_config = crate::logging::LoggingConfig {
189        json_format: logging_config.json_format,
190        include_timestamp: logging_config.include_timestamp,
191        include_hostname: logging_config.include_hostname,
192        include_spans: logging_config.include_spans,
193        extract_fields: logging_config.extract_fields.clone(),
194        buffer_size: logging_config.buffer_size,
195    };
196    let buffer = LogBuffer::new(buffer_config);
197
198    ::tracing::info!(
199        service = service_name,
200        environment = environment,
201        format = if logging_config.json_format { "json" } else { "pretty" },
202        otel = if matches!(guard, OtelShutdownGuard::Inactive) { "disabled" } else { "enabled" },
203        "Observability initialized (single subscriber)"
204    );
205
206    Ok((buffer, guard))
207}