opentelemetry_configuration/
builder.rs

1//! Builder for OpenTelemetry SDK configuration.
2//!
3//! The builder supports layered configuration from multiple sources:
4//! 1. Compiled defaults (protocol-specific endpoints)
5//! 2. Configuration files (TOML, JSON, YAML)
6//! 3. Environment variables
7//! 4. Programmatic overrides
8//!
9//! Sources are merged in order, with later sources taking precedence.
10
11use crate::SdkError;
12use crate::config::{OtelSdkConfig, Protocol, ResourceConfig};
13use crate::fallback::ExportFallback;
14use crate::guard::OtelGuard;
15use figment::Figment;
16use figment::providers::{Env, Format, Serialized, Toml};
17use opentelemetry_sdk::Resource;
18use std::path::Path;
19
20/// Builder for configuring and initialising the OpenTelemetry SDK.
21///
22/// # Example
23///
24/// ```no_run
25/// use opentelemetry_configuration::{OtelSdkBuilder, SdkError};
26///
27/// fn main() -> Result<(), SdkError> {
28///     // Simple case - uses defaults (localhost:4318 for HTTP)
29///     let _guard = OtelSdkBuilder::new().build()?;
30///
31///     // With environment variables
32///     let _guard = OtelSdkBuilder::new()
33///         .with_env("OTEL_")
34///         .build()?;
35///
36///     // Full configuration
37///     let _guard = OtelSdkBuilder::new()
38///         .with_file("/var/task/otel-config.toml")
39///         .with_env("OTEL_")
40///         .endpoint("http://collector:4318")
41///         .service_name("my-lambda")
42///         .build()?;
43///
44///     Ok(())
45/// }
46/// ```
47#[must_use = "builders do nothing unless .build() is called"]
48pub struct OtelSdkBuilder {
49    figment: Figment,
50    fallback: ExportFallback,
51    custom_resource: Option<Resource>,
52    resource_attributes: std::collections::HashMap<String, String>,
53}
54
55impl OtelSdkBuilder {
56    /// Creates a new builder with default configuration.
57    ///
58    /// Defaults include:
59    /// - Protocol: HTTP with protobuf encoding
60    /// - Endpoint: `http://localhost:4318` (or 4317 for gRPC)
61    /// - All signals enabled (traces, metrics, logs)
62    /// - Tracing subscriber initialisation enabled
63    /// - Lambda resource detection enabled
64    pub fn new() -> Self {
65        Self {
66            figment: Figment::from(Serialized::defaults(OtelSdkConfig::default())),
67            fallback: ExportFallback::default(),
68            custom_resource: None,
69            resource_attributes: std::collections::HashMap::new(),
70        }
71    }
72
73    /// Creates a builder from an existing figment.
74    ///
75    /// This allows power users to construct complex configuration chains
76    /// before passing them to the SDK builder.
77    ///
78    /// # Example
79    ///
80    /// ```no_run
81    /// use figment::{Figment, providers::{Env, Format, Toml}};
82    /// use opentelemetry_configuration::{OtelSdkBuilder, SdkError};
83    ///
84    /// let figment = Figment::new()
85    ///     .merge(Toml::file("/etc/otel-defaults.toml"))
86    ///     .merge(Toml::file("/var/task/otel-config.toml"))
87    ///     .merge(Env::prefixed("OTEL_").split("_"));
88    ///
89    /// let _guard = OtelSdkBuilder::from_figment(figment)
90    ///     .service_name("my-lambda")
91    ///     .build()?;
92    /// # Ok::<(), SdkError>(())
93    /// ```
94    pub fn from_figment(figment: Figment) -> Self {
95        Self {
96            figment,
97            fallback: ExportFallback::default(),
98            custom_resource: None,
99            resource_attributes: std::collections::HashMap::new(),
100        }
101    }
102
103    /// Merges configuration from a TOML file.
104    ///
105    /// If the file doesn't exist, it's silently skipped.
106    /// This allows optional configuration files that may or may not be present.
107    ///
108    /// # Example
109    ///
110    /// ```no_run
111    /// use opentelemetry_configuration::{OtelSdkBuilder, SdkError};
112    ///
113    /// let _guard = OtelSdkBuilder::new()
114    ///     .with_file("/var/task/otel-config.toml")  // Optional
115    ///     .with_file("./otel-local.toml")           // For development
116    ///     .build()?;
117    /// # Ok::<(), SdkError>(())
118    /// ```
119    pub fn with_file<P: AsRef<Path>>(mut self, path: P) -> Self {
120        let path = path.as_ref();
121        if path.exists() {
122            self.figment = self.figment.merge(Toml::file(path));
123        }
124        self
125    }
126
127    /// Merges configuration from environment variables with the given prefix.
128    ///
129    /// Environment variables are split on underscores to match nested config.
130    /// For example, with prefix `OTEL_`:
131    /// - `OTEL_ENDPOINT_URL` → `endpoint.url`
132    /// - `OTEL_ENDPOINT_PROTOCOL` → `endpoint.protocol`
133    /// - `OTEL_TRACES_ENABLED` → `traces.enabled`
134    /// - `OTEL_RESOURCE_SERVICE_NAME` → `resource.service_name`
135    ///
136    /// # Example
137    ///
138    /// ```bash
139    /// export OTEL_ENDPOINT_URL=http://collector:4318
140    /// export OTEL_ENDPOINT_PROTOCOL=grpc
141    /// export OTEL_TRACES_ENABLED=true
142    /// export OTEL_RESOURCE_SERVICE_NAME=my-lambda
143    /// ```
144    ///
145    /// ```no_run
146    /// use opentelemetry_configuration::{OtelSdkBuilder, SdkError};
147    ///
148    /// let _guard = OtelSdkBuilder::new()
149    ///     .with_env("OTEL_")
150    ///     .build()?;
151    /// # Ok::<(), SdkError>(())
152    /// ```
153    pub fn with_env(mut self, prefix: &str) -> Self {
154        self.figment = self.figment.merge(Env::prefixed(prefix).split("_"));
155        self
156    }
157
158    /// Merges configuration from standard OpenTelemetry environment variables.
159    ///
160    /// This reads the standard `OTEL_*` environment variables as defined by
161    /// the OpenTelemetry specification:
162    /// - `OTEL_EXPORTER_OTLP_ENDPOINT` → endpoint URL
163    /// - `OTEL_EXPORTER_OTLP_PROTOCOL` → protocol (grpc, http/protobuf, http/json)
164    /// - `OTEL_SERVICE_NAME` → service name
165    /// - `OTEL_TRACES_EXPORTER` → traces exporter (otlp, none)
166    /// - `OTEL_METRICS_EXPORTER` → metrics exporter (otlp, none)
167    /// - `OTEL_LOGS_EXPORTER` → logs exporter (otlp, none)
168    pub fn with_standard_env(mut self) -> Self {
169        // Map standard OTEL env vars to our config structure
170        if let Ok(endpoint) = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT") {
171            self.figment = self
172                .figment
173                .merge(Serialized::default("endpoint.url", endpoint));
174        }
175
176        if let Ok(protocol) = std::env::var("OTEL_EXPORTER_OTLP_PROTOCOL") {
177            let protocol = match protocol.as_str() {
178                "grpc" => "grpc",
179                "http/protobuf" => "httpbinary",
180                "http/json" => "httpjson",
181                _ => "httpbinary",
182            };
183            self.figment = self
184                .figment
185                .merge(Serialized::default("endpoint.protocol", protocol));
186        }
187
188        if let Ok(service_name) = std::env::var("OTEL_SERVICE_NAME") {
189            self.figment = self
190                .figment
191                .merge(Serialized::default("resource.service_name", service_name));
192        }
193
194        if let Ok(exporter) = std::env::var("OTEL_TRACES_EXPORTER") {
195            let enabled = exporter != "none";
196            self.figment = self
197                .figment
198                .merge(Serialized::default("traces.enabled", enabled));
199        }
200
201        if let Ok(exporter) = std::env::var("OTEL_METRICS_EXPORTER") {
202            let enabled = exporter != "none";
203            self.figment = self
204                .figment
205                .merge(Serialized::default("metrics.enabled", enabled));
206        }
207
208        if let Ok(exporter) = std::env::var("OTEL_LOGS_EXPORTER") {
209            let enabled = exporter != "none";
210            self.figment = self
211                .figment
212                .merge(Serialized::default("logs.enabled", enabled));
213        }
214
215        self
216    }
217
218    /// Sets the OTLP endpoint URL explicitly.
219    ///
220    /// This overrides any configuration from files or environment variables.
221    ///
222    /// For HTTP protocols, signal-specific paths (`/v1/traces`, `/v1/metrics`,
223    /// `/v1/logs`) are appended automatically.
224    ///
225    /// # Example
226    ///
227    /// ```no_run
228    /// use opentelemetry_configuration::{OtelSdkBuilder, SdkError};
229    ///
230    /// let _guard = OtelSdkBuilder::new()
231    ///     .endpoint("http://collector.internal:4318")
232    ///     .build()?;
233    /// # Ok::<(), SdkError>(())
234    /// ```
235    pub fn endpoint(mut self, url: impl Into<String>) -> Self {
236        self.figment = self
237            .figment
238            .merge(Serialized::default("endpoint.url", url.into()));
239        self
240    }
241
242    /// Sets the export protocol.
243    ///
244    /// This overrides any configuration from files or environment variables.
245    ///
246    /// The default endpoint changes based on protocol:
247    /// - `Protocol::Grpc` → `http://localhost:4317`
248    /// - `Protocol::HttpBinary` → `http://localhost:4318`
249    /// - `Protocol::HttpJson` → `http://localhost:4318`
250    pub fn protocol(mut self, protocol: Protocol) -> Self {
251        let protocol_str = match protocol {
252            Protocol::Grpc => "grpc",
253            Protocol::HttpBinary => "httpbinary",
254            Protocol::HttpJson => "httpjson",
255        };
256        self.figment = self
257            .figment
258            .merge(Serialized::default("endpoint.protocol", protocol_str));
259        self
260    }
261
262    /// Sets the service name resource attribute.
263    ///
264    /// This is the most commonly configured resource attribute and identifies
265    /// your service in the telemetry backend.
266    pub fn service_name(mut self, name: impl Into<String>) -> Self {
267        self.figment = self
268            .figment
269            .merge(Serialized::default("resource.service_name", name.into()));
270        self
271    }
272
273    /// Sets the service version resource attribute.
274    pub fn service_version(mut self, version: impl Into<String>) -> Self {
275        self.figment = self.figment.merge(Serialized::default(
276            "resource.service_version",
277            version.into(),
278        ));
279        self
280    }
281
282    /// Sets the deployment environment resource attribute.
283    pub fn deployment_environment(mut self, env: impl Into<String>) -> Self {
284        self.figment = self.figment.merge(Serialized::default(
285            "resource.deployment_environment",
286            env.into(),
287        ));
288        self
289    }
290
291    /// Adds a resource attribute.
292    pub fn resource_attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
293        self.resource_attributes.insert(key.into(), value.into());
294        self
295    }
296
297    /// Provides a pre-built OpenTelemetry Resource.
298    ///
299    /// This takes precedence over individual resource configuration.
300    /// Use this when you need fine-grained control over resource construction.
301    pub fn with_resource(mut self, resource: Resource) -> Self {
302        self.custom_resource = Some(resource);
303        self
304    }
305
306    /// Configures the resource using a builder function.
307    ///
308    /// # Example
309    ///
310    /// ```no_run
311    /// use opentelemetry_configuration::{OtelSdkBuilder, SdkError};
312    ///
313    /// let _guard = OtelSdkBuilder::new()
314    ///     .resource(|r| r
315    ///         .service_name("my-lambda")
316    ///         .service_version(env!("CARGO_PKG_VERSION"))
317    ///         .deployment_environment("production"))
318    ///     .build()?;
319    /// # Ok::<(), SdkError>(())
320    /// ```
321    pub fn resource<F>(mut self, f: F) -> Self
322    where
323        F: FnOnce(ResourceConfigBuilder) -> ResourceConfigBuilder,
324    {
325        let builder = f(ResourceConfigBuilder::new());
326        let config = builder.build();
327
328        if let Some(name) = &config.service_name {
329            self.figment = self
330                .figment
331                .merge(Serialized::default("resource.service_name", name.clone()));
332        }
333        if let Some(version) = &config.service_version {
334            self.figment = self.figment.merge(Serialized::default(
335                "resource.service_version",
336                version.clone(),
337            ));
338        }
339        if let Some(env) = &config.deployment_environment {
340            self.figment = self.figment.merge(Serialized::default(
341                "resource.deployment_environment",
342                env.clone(),
343            ));
344        }
345        for (key, value) in config.attributes {
346            self.resource_attributes.insert(key, value);
347        }
348
349        self
350    }
351
352    /// Enables or disables trace collection.
353    ///
354    /// Default: enabled
355    pub fn traces(mut self, enabled: bool) -> Self {
356        self.figment = self
357            .figment
358            .merge(Serialized::default("traces.enabled", enabled));
359        self
360    }
361
362    /// Enables or disables metrics collection.
363    ///
364    /// Default: enabled
365    pub fn metrics(mut self, enabled: bool) -> Self {
366        self.figment = self
367            .figment
368            .merge(Serialized::default("metrics.enabled", enabled));
369        self
370    }
371
372    /// Enables or disables log collection.
373    ///
374    /// Default: enabled
375    pub fn logs(mut self, enabled: bool) -> Self {
376        self.figment = self
377            .figment
378            .merge(Serialized::default("logs.enabled", enabled));
379        self
380    }
381
382    /// Disables automatic tracing subscriber initialisation.
383    ///
384    /// By default, the SDK sets up a `tracing-subscriber` with
385    /// `tracing-opentelemetry` and `opentelemetry-appender-tracing` integration.
386    /// Disable this if you want to configure the subscriber yourself.
387    pub fn without_tracing_subscriber(mut self) -> Self {
388        self.figment = self
389            .figment
390            .merge(Serialized::default("init_tracing_subscriber", false));
391        self
392    }
393
394    /// Sets the fallback strategy for failed exports (planned feature).
395    ///
396    /// **Note:** The fallback API is defined but not yet wired into the export
397    /// pipeline. The handler will be stored but not invoked on export failures.
398    /// Full implementation is planned for a future release.
399    ///
400    /// When implemented, the fallback handler will be called when an export
401    /// fails after all retry attempts have been exhausted. It will receive the
402    /// original OTLP request payload, which can be preserved via alternative
403    /// transport.
404    ///
405    /// # Example
406    ///
407    /// ```no_run
408    /// use opentelemetry_configuration::{OtelSdkBuilder, ExportFallback, SdkError};
409    ///
410    /// let _guard = OtelSdkBuilder::new()
411    ///     .fallback(ExportFallback::Stdout)
412    ///     .build()?;
413    /// # Ok::<(), SdkError>(())
414    /// ```
415    pub fn fallback(mut self, fallback: ExportFallback) -> Self {
416        self.fallback = fallback;
417        self
418    }
419
420    /// Sets a custom fallback handler using a closure (planned feature).
421    ///
422    /// **Note:** The fallback API is defined but not yet wired into the export
423    /// pipeline. The handler will be stored but not invoked on export failures.
424    /// Full implementation is planned for a future release.
425    ///
426    /// When implemented, the closure will receive the full
427    /// [`ExportFailure`](super::ExportFailure) including the original OTLP
428    /// request payload.
429    ///
430    /// # Example
431    ///
432    /// ```no_run
433    /// use opentelemetry_configuration::{OtelSdkBuilder, SdkError};
434    ///
435    /// let _guard = OtelSdkBuilder::new()
436    ///     .with_fallback(|failure| {
437    ///         // Write the protobuf payload to S3, a queue, etc.
438    ///         let bytes = failure.request.to_protobuf();
439    ///         eprintln!(
440    ///             "Failed to export {} ({} bytes): {}",
441    ///             failure.request.signal_type(),
442    ///             bytes.len(),
443    ///             failure.error
444    ///         );
445    ///         Ok(())
446    ///     })
447    ///     .build()?;
448    /// # Ok::<(), SdkError>(())
449    /// ```
450    pub fn with_fallback<F>(mut self, f: F) -> Self
451    where
452        F: Fn(super::ExportFailure) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
453            + Send
454            + Sync
455            + 'static,
456    {
457        self.fallback = ExportFallback::custom(f);
458        self
459    }
460
461    /// Adds an HTTP header to all export requests.
462    ///
463    /// Useful for authentication or custom routing.
464    pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
465        let header_key = format!("endpoint.headers.{}", key.into());
466        self.figment = self
467            .figment
468            .merge(Serialized::default(&header_key, value.into()));
469        self
470    }
471
472    /// Extracts the configuration for inspection or debugging.
473    ///
474    /// # Errors
475    ///
476    /// Returns an error if configuration extraction fails.
477    pub fn extract_config(&self) -> Result<OtelSdkConfig, SdkError> {
478        let mut config: OtelSdkConfig = self
479            .figment
480            .extract()
481            .map_err(|e| SdkError::Config(Box::new(e)))?;
482
483        // Merge resource attributes that couldn't go through figment
484        config
485            .resource
486            .attributes
487            .extend(self.resource_attributes.clone());
488
489        Ok(config)
490    }
491
492    /// Builds and initialises the OpenTelemetry SDK.
493    ///
494    /// Returns an [`OtelGuard`] that manages provider lifecycle. When the
495    /// guard is dropped, all providers are flushed and shut down.
496    ///
497    /// # Errors
498    ///
499    /// Returns an error if:
500    /// - Configuration extraction fails
501    /// - Provider initialisation fails
502    /// - Tracing subscriber initialisation fails
503    ///
504    /// # Example
505    ///
506    /// ```no_run
507    /// use opentelemetry_configuration::{OtelSdkBuilder, SdkError};
508    ///
509    /// fn main() -> Result<(), SdkError> {
510    ///     let _guard = OtelSdkBuilder::new()
511    ///         .with_env("OTEL_")
512    ///         .service_name("my-lambda")
513    ///         .build()?;
514    ///
515    ///     tracing::info!("Application started");
516    ///
517    ///     // Guard automatically shuts down providers on drop
518    ///     Ok(())
519    /// }
520    /// ```
521    pub fn build(self) -> Result<OtelGuard, SdkError> {
522        let mut config: OtelSdkConfig = self
523            .figment
524            .extract()
525            .map_err(|e| SdkError::Config(Box::new(e)))?;
526
527        // Merge resource attributes that couldn't go through figment
528        config.resource.attributes.extend(self.resource_attributes);
529
530        // Detect Lambda resource attributes from environment
531        config.resource.detect_from_environment();
532
533        OtelGuard::from_config(config, self.fallback, self.custom_resource)
534    }
535}
536
537impl Default for OtelSdkBuilder {
538    fn default() -> Self {
539        Self::new()
540    }
541}
542
543/// Builder for resource configuration.
544///
545/// Used with [`OtelSdkBuilder::resource`] for fluent configuration.
546#[derive(Default)]
547#[must_use = "builders do nothing unless .build() is called"]
548pub struct ResourceConfigBuilder {
549    config: ResourceConfig,
550}
551
552impl ResourceConfigBuilder {
553    /// Creates a new resource config builder.
554    pub fn new() -> Self {
555        Self::default()
556    }
557
558    /// Sets the service name.
559    pub fn service_name(mut self, name: impl Into<String>) -> Self {
560        self.config.service_name = Some(name.into());
561        self
562    }
563
564    /// Sets the service version.
565    pub fn service_version(mut self, version: impl Into<String>) -> Self {
566        self.config.service_version = Some(version.into());
567        self
568    }
569
570    /// Sets the deployment environment.
571    pub fn deployment_environment(mut self, env: impl Into<String>) -> Self {
572        self.config.deployment_environment = Some(env.into());
573        self
574    }
575
576    /// Adds a resource attribute.
577    pub fn attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
578        self.config.attributes.insert(key.into(), value.into());
579        self
580    }
581
582    /// Disables automatic Lambda resource detection.
583    pub fn without_lambda_detection(mut self) -> Self {
584        self.config.detect_lambda = false;
585        self
586    }
587
588    /// Builds the resource configuration.
589    pub fn build(self) -> ResourceConfig {
590        self.config
591    }
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597
598    #[test]
599    fn test_builder_default() {
600        let builder = OtelSdkBuilder::new();
601        let config = builder.extract_config().unwrap();
602
603        assert!(config.traces.enabled);
604        assert!(config.metrics.enabled);
605        assert!(config.logs.enabled);
606        assert!(config.init_tracing_subscriber);
607        assert_eq!(config.endpoint.protocol, Protocol::HttpBinary);
608    }
609
610    #[test]
611    fn test_builder_endpoint() {
612        let builder = OtelSdkBuilder::new().endpoint("http://collector:4318");
613        let config = builder.extract_config().unwrap();
614
615        assert_eq!(
616            config.endpoint.url,
617            Some("http://collector:4318".to_string())
618        );
619    }
620
621    #[test]
622    fn test_builder_protocol() {
623        let builder = OtelSdkBuilder::new().protocol(Protocol::Grpc);
624        let config = builder.extract_config().unwrap();
625
626        assert_eq!(config.endpoint.protocol, Protocol::Grpc);
627    }
628
629    #[test]
630    fn test_builder_service_name() {
631        let builder = OtelSdkBuilder::new().service_name("my-service");
632        let config = builder.extract_config().unwrap();
633
634        assert_eq!(config.resource.service_name, Some("my-service".to_string()));
635    }
636
637    #[test]
638    fn test_builder_disable_signals() {
639        let builder = OtelSdkBuilder::new()
640            .traces(false)
641            .metrics(false)
642            .logs(false);
643        let config = builder.extract_config().unwrap();
644
645        assert!(!config.traces.enabled);
646        assert!(!config.metrics.enabled);
647        assert!(!config.logs.enabled);
648    }
649
650    #[test]
651    fn test_builder_resource_fluent() {
652        let builder = OtelSdkBuilder::new().resource(|r| {
653            r.service_name("my-service")
654                .service_version("1.0.0")
655                .deployment_environment("production")
656                .attribute("custom.key", "custom.value")
657        });
658        let config = builder.extract_config().unwrap();
659
660        assert_eq!(config.resource.service_name, Some("my-service".to_string()));
661        assert_eq!(config.resource.service_version, Some("1.0.0".to_string()));
662        assert_eq!(
663            config.resource.deployment_environment,
664            Some("production".to_string())
665        );
666        assert_eq!(
667            config.resource.attributes.get("custom.key"),
668            Some(&"custom.value".to_string())
669        );
670    }
671
672    #[test]
673    fn test_builder_without_tracing_subscriber() {
674        let builder = OtelSdkBuilder::new().without_tracing_subscriber();
675        let config = builder.extract_config().unwrap();
676
677        assert!(!config.init_tracing_subscriber);
678    }
679
680    #[test]
681    fn test_builder_header() {
682        let builder = OtelSdkBuilder::new().header("Authorization", "Bearer token123");
683        let config = builder.extract_config().unwrap();
684
685        assert_eq!(
686            config.endpoint.headers.get("Authorization"),
687            Some(&"Bearer token123".to_string())
688        );
689    }
690
691    #[test]
692    fn test_builder_fallback() {
693        let builder = OtelSdkBuilder::new().fallback(ExportFallback::Stdout);
694        assert!(matches!(builder.fallback, ExportFallback::Stdout));
695    }
696
697    #[test]
698    fn test_builder_custom_fallback() {
699        let builder = OtelSdkBuilder::new().with_fallback(|_failure| Ok(()));
700        assert!(matches!(builder.fallback, ExportFallback::Custom(_)));
701    }
702}