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