allframe_core/otel/
builder.rs

1//! Observability builder for easy setup of tracing and metrics
2//!
3//! This module provides a fluent builder API for configuring OpenTelemetry
4//! tracing with OTLP export, structured logging, and more.
5//!
6//! # Example
7//!
8//! ```rust,ignore
9//! use allframe_core::otel::Observability;
10//!
11//! let _guard = Observability::builder("my-service")
12//!     .service_version(env!("CARGO_PKG_VERSION"))
13//!     .environment_from_env()
14//!     .otlp_endpoint_from_env()
15//!     .json_logging()
16//!     .log_level_from_env()
17//!     .build()?;
18//!
19//! // Guard keeps the subscriber active
20//! // When dropped, flushes pending spans
21//! ```
22
23use std::env;
24
25/// Builder for configuring observability (tracing, metrics, logging)
26pub struct ObservabilityBuilder {
27    service_name: String,
28    service_version: Option<String>,
29    environment: Option<String>,
30    otlp_endpoint: Option<String>,
31    json_logging: bool,
32    log_level: String,
33}
34
35impl ObservabilityBuilder {
36    /// Create a new observability builder
37    pub fn new(service_name: impl Into<String>) -> Self {
38        Self {
39            service_name: service_name.into(),
40            service_version: None,
41            environment: None,
42            otlp_endpoint: None,
43            json_logging: false,
44            log_level: "info".to_string(),
45        }
46    }
47
48    /// Set the service version
49    pub fn service_version(mut self, version: impl Into<String>) -> Self {
50        self.service_version = Some(version.into());
51        self
52    }
53
54    /// Set the environment (e.g., "production", "staging", "development")
55    pub fn environment(mut self, env: impl Into<String>) -> Self {
56        self.environment = Some(env.into());
57        self
58    }
59
60    /// Read environment from ENVIRONMENT or ENV env var
61    pub fn environment_from_env(mut self) -> Self {
62        self.environment = env::var("ENVIRONMENT").or_else(|_| env::var("ENV")).ok();
63        self
64    }
65
66    /// Set the OTLP endpoint for exporting traces
67    pub fn otlp_endpoint(mut self, endpoint: impl Into<String>) -> Self {
68        self.otlp_endpoint = Some(endpoint.into());
69        self
70    }
71
72    /// Read OTLP endpoint from OTEL_EXPORTER_OTLP_ENDPOINT env var
73    pub fn otlp_endpoint_from_env(mut self) -> Self {
74        self.otlp_endpoint = env::var("OTEL_EXPORTER_OTLP_ENDPOINT").ok();
75        self
76    }
77
78    /// Enable JSON-formatted log output (for production)
79    pub fn json_logging(mut self) -> Self {
80        self.json_logging = true;
81        self
82    }
83
84    /// Set the log level (trace, debug, info, warn, error)
85    pub fn log_level(mut self, level: impl Into<String>) -> Self {
86        self.log_level = level.into();
87        self
88    }
89
90    /// Read log level from RUST_LOG env var
91    pub fn log_level_from_env(mut self) -> Self {
92        if let Ok(level) = env::var("RUST_LOG") {
93            self.log_level = level;
94        }
95        self
96    }
97
98    /// Build and initialize the observability stack
99    ///
100    /// Returns a guard that must be kept alive for the duration of the program.
101    /// When the guard is dropped, pending spans are flushed.
102    #[cfg(feature = "otel-otlp")]
103    pub fn build(self) -> Result<ObservabilityGuard, ObservabilityError> {
104        use opentelemetry::trace::TracerProvider as _;
105        use opentelemetry_otlp::WithExportConfig;
106        use opentelemetry_sdk::trace::TracerProvider;
107        use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
108
109        // Build resource attributes
110        let mut resource_attrs = vec![opentelemetry::KeyValue::new(
111            "service.name",
112            self.service_name.clone(),
113        )];
114
115        if let Some(version) = &self.service_version {
116            resource_attrs.push(opentelemetry::KeyValue::new(
117                "service.version",
118                version.clone(),
119            ));
120        }
121
122        if let Some(env) = &self.environment {
123            resource_attrs.push(opentelemetry::KeyValue::new(
124                "deployment.environment",
125                env.clone(),
126            ));
127        }
128
129        let resource = opentelemetry_sdk::Resource::new(resource_attrs);
130
131        // Build tracer provider
132        let tracer_provider = if let Some(endpoint) = &self.otlp_endpoint {
133            // OTLP exporter with batch processing
134            let exporter = opentelemetry_otlp::SpanExporter::builder()
135                .with_tonic()
136                .with_endpoint(endpoint)
137                .build()
138                .map_err(|e| ObservabilityError::ExporterInit(e.to_string()))?;
139
140            TracerProvider::builder()
141                .with_batch_exporter(exporter, opentelemetry_sdk::runtime::Tokio)
142                .with_resource(resource)
143                .build()
144        } else {
145            // No exporter, just create a basic provider
146            TracerProvider::builder().with_resource(resource).build()
147        };
148
149        let tracer = tracer_provider.tracer(self.service_name.clone());
150
151        // Build env filter
152        let env_filter =
153            EnvFilter::try_new(&self.log_level).unwrap_or_else(|_| EnvFilter::new("info"));
154
155        // Build subscriber based on logging format
156        // Note: telemetry layer must be added last so it sees all events
157        if self.json_logging {
158            let fmt_layer = tracing_subscriber::fmt::layer()
159                .json()
160                .with_target(true)
161                .with_thread_ids(true)
162                .with_file(true)
163                .with_line_number(true);
164
165            let telemetry_layer = tracing_opentelemetry::layer().with_tracer(tracer);
166
167            tracing_subscriber::registry()
168                .with(env_filter)
169                .with(telemetry_layer)
170                .with(fmt_layer)
171                .try_init()
172                .map_err(|e| ObservabilityError::SubscriberInit(e.to_string()))?;
173        } else {
174            let fmt_layer = tracing_subscriber::fmt::layer()
175                .with_target(true)
176                .with_thread_ids(false)
177                .with_file(false)
178                .with_line_number(false);
179
180            let telemetry_layer = tracing_opentelemetry::layer().with_tracer(tracer);
181
182            tracing_subscriber::registry()
183                .with(env_filter)
184                .with(telemetry_layer)
185                .with(fmt_layer)
186                .try_init()
187                .map_err(|e| ObservabilityError::SubscriberInit(e.to_string()))?;
188        }
189
190        Ok(ObservabilityGuard {
191            _tracer_provider: Some(tracer_provider),
192        })
193    }
194
195    /// Build without OTLP - just tracing subscriber
196    #[cfg(not(feature = "otel-otlp"))]
197    pub fn build(self) -> Result<ObservabilityGuard, ObservabilityError> {
198        // Without otel-otlp, we just set up basic tracing
199        #[cfg(feature = "otel")]
200        {
201            // Just log a warning that OTLP is not enabled
202            eprintln!(
203                "Warning: otel-otlp feature not enabled, OTLP export disabled for {}",
204                self.service_name
205            );
206        }
207
208        Ok(ObservabilityGuard {
209            #[cfg(feature = "otel-otlp")]
210            _tracer_provider: None,
211        })
212    }
213}
214
215/// Guard that keeps the observability stack active
216///
217/// When dropped, flushes any pending spans to the exporter.
218pub struct ObservabilityGuard {
219    #[cfg(feature = "otel-otlp")]
220    _tracer_provider: Option<opentelemetry_sdk::trace::TracerProvider>,
221}
222
223impl Drop for ObservabilityGuard {
224    fn drop(&mut self) {
225        #[cfg(feature = "otel-otlp")]
226        if let Some(provider) = self._tracer_provider.take() {
227            // Flush and shutdown the tracer provider
228            if let Err(e) = provider.shutdown() {
229                eprintln!("Error shutting down tracer provider: {:?}", e);
230            }
231        }
232    }
233}
234
235/// Errors that can occur during observability setup
236#[derive(Debug)]
237pub enum ObservabilityError {
238    /// Failed to initialize the exporter
239    ExporterInit(String),
240    /// Failed to initialize the subscriber
241    SubscriberInit(String),
242    /// Configuration error
243    Config(String),
244}
245
246impl std::fmt::Display for ObservabilityError {
247    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
248        match self {
249            ObservabilityError::ExporterInit(msg) => {
250                write!(f, "Failed to initialize exporter: {}", msg)
251            }
252            ObservabilityError::SubscriberInit(msg) => {
253                write!(f, "Failed to initialize subscriber: {}", msg)
254            }
255            ObservabilityError::Config(msg) => write!(f, "Configuration error: {}", msg),
256        }
257    }
258}
259
260impl std::error::Error for ObservabilityError {}
261
262/// Type alias for the builder
263pub type Observability = ObservabilityBuilder;
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn test_builder_creation() {
271        let builder = ObservabilityBuilder::new("test-service");
272        assert_eq!(builder.service_name, "test-service");
273        assert!(builder.service_version.is_none());
274        assert!(builder.environment.is_none());
275        assert!(builder.otlp_endpoint.is_none());
276        assert!(!builder.json_logging);
277        assert_eq!(builder.log_level, "info");
278    }
279
280    #[test]
281    fn test_builder_fluent_api() {
282        let builder = ObservabilityBuilder::new("test-service")
283            .service_version("1.0.0")
284            .environment("production")
285            .otlp_endpoint("http://localhost:4317")
286            .json_logging()
287            .log_level("debug");
288
289        assert_eq!(builder.service_version, Some("1.0.0".to_string()));
290        assert_eq!(builder.environment, Some("production".to_string()));
291        assert_eq!(
292            builder.otlp_endpoint,
293            Some("http://localhost:4317".to_string())
294        );
295        assert!(builder.json_logging);
296        assert_eq!(builder.log_level, "debug");
297    }
298
299    #[test]
300    fn test_observability_error_display() {
301        let err = ObservabilityError::ExporterInit("connection refused".into());
302        assert!(err.to_string().contains("connection refused"));
303    }
304}