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 or if the endpoint
477    /// URL is invalid.
478    pub fn extract_config(&self) -> Result<OtelSdkConfig, SdkError> {
479        let mut config: OtelSdkConfig = self
480            .figment
481            .extract()
482            .map_err(|e| SdkError::Config(Box::new(e)))?;
483
484        // Merge resource attributes that couldn't go through figment
485        config
486            .resource
487            .attributes
488            .extend(self.resource_attributes.clone());
489
490        if let Some(ref url) = config.endpoint.url
491            && !url.starts_with("http://")
492            && !url.starts_with("https://")
493        {
494            return Err(SdkError::InvalidEndpoint { url: url.clone() });
495        }
496
497        Ok(config)
498    }
499
500    /// Builds and initialises the OpenTelemetry SDK.
501    ///
502    /// Returns an [`OtelGuard`] that manages provider lifecycle. When the
503    /// guard is dropped, all providers are flushed and shut down.
504    ///
505    /// # Errors
506    ///
507    /// Returns an error if:
508    /// - Configuration extraction fails
509    /// - Provider initialisation fails
510    /// - Tracing subscriber initialisation fails
511    ///
512    /// # Example
513    ///
514    /// ```no_run
515    /// use opentelemetry_configuration::{OtelSdkBuilder, SdkError};
516    ///
517    /// fn main() -> Result<(), SdkError> {
518    ///     let _guard = OtelSdkBuilder::new()
519    ///         .with_env("OTEL_")
520    ///         .service_name("my-lambda")
521    ///         .build()?;
522    ///
523    ///     tracing::info!("Application started");
524    ///
525    ///     // Guard automatically shuts down providers on drop
526    ///     Ok(())
527    /// }
528    /// ```
529    pub fn build(self) -> Result<OtelGuard, SdkError> {
530        let mut config: OtelSdkConfig = self
531            .figment
532            .extract()
533            .map_err(|e| SdkError::Config(Box::new(e)))?;
534
535        // Merge resource attributes that couldn't go through figment
536        config.resource.attributes.extend(self.resource_attributes);
537
538        if let Some(ref url) = config.endpoint.url
539            && !url.starts_with("http://")
540            && !url.starts_with("https://")
541        {
542            return Err(SdkError::InvalidEndpoint { url: url.clone() });
543        }
544
545        // Detect Lambda resource attributes from environment
546        config.resource.detect_from_environment();
547
548        OtelGuard::from_config(config, self.fallback, self.custom_resource)
549    }
550}
551
552impl Default for OtelSdkBuilder {
553    fn default() -> Self {
554        Self::new()
555    }
556}
557
558/// Builder for resource configuration.
559///
560/// Used with [`OtelSdkBuilder::resource`] for fluent configuration.
561#[derive(Default)]
562#[must_use = "builders do nothing unless .build() is called"]
563pub struct ResourceConfigBuilder {
564    config: ResourceConfig,
565}
566
567impl ResourceConfigBuilder {
568    /// Creates a new resource config builder.
569    pub fn new() -> Self {
570        Self::default()
571    }
572
573    /// Sets the service name.
574    pub fn service_name(mut self, name: impl Into<String>) -> Self {
575        self.config.service_name = Some(name.into());
576        self
577    }
578
579    /// Sets the service version.
580    pub fn service_version(mut self, version: impl Into<String>) -> Self {
581        self.config.service_version = Some(version.into());
582        self
583    }
584
585    /// Sets the deployment environment.
586    pub fn deployment_environment(mut self, env: impl Into<String>) -> Self {
587        self.config.deployment_environment = Some(env.into());
588        self
589    }
590
591    /// Adds a resource attribute.
592    pub fn attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
593        self.config.attributes.insert(key.into(), value.into());
594        self
595    }
596
597    /// Disables automatic Lambda resource detection.
598    pub fn without_lambda_detection(mut self) -> Self {
599        self.config.detect_lambda = false;
600        self
601    }
602
603    /// Builds the resource configuration.
604    pub fn build(self) -> ResourceConfig {
605        self.config
606    }
607}
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612
613    #[test]
614    fn test_builder_default() {
615        let builder = OtelSdkBuilder::new();
616        let config = builder.extract_config().unwrap();
617
618        assert!(config.traces.enabled);
619        assert!(config.metrics.enabled);
620        assert!(config.logs.enabled);
621        assert!(config.init_tracing_subscriber);
622        assert_eq!(config.endpoint.protocol, Protocol::HttpBinary);
623    }
624
625    #[test]
626    fn test_builder_endpoint() {
627        let builder = OtelSdkBuilder::new().endpoint("http://collector:4318");
628        let config = builder.extract_config().unwrap();
629
630        assert_eq!(
631            config.endpoint.url,
632            Some("http://collector:4318".to_string())
633        );
634    }
635
636    #[test]
637    fn test_builder_protocol() {
638        let builder = OtelSdkBuilder::new().protocol(Protocol::Grpc);
639        let config = builder.extract_config().unwrap();
640
641        assert_eq!(config.endpoint.protocol, Protocol::Grpc);
642    }
643
644    #[test]
645    fn test_builder_service_name() {
646        let builder = OtelSdkBuilder::new().service_name("my-service");
647        let config = builder.extract_config().unwrap();
648
649        assert_eq!(config.resource.service_name, Some("my-service".to_string()));
650    }
651
652    #[test]
653    fn test_builder_disable_signals() {
654        let builder = OtelSdkBuilder::new()
655            .traces(false)
656            .metrics(false)
657            .logs(false);
658        let config = builder.extract_config().unwrap();
659
660        assert!(!config.traces.enabled);
661        assert!(!config.metrics.enabled);
662        assert!(!config.logs.enabled);
663    }
664
665    #[test]
666    fn test_builder_resource_fluent() {
667        let builder = OtelSdkBuilder::new().resource(|r| {
668            r.service_name("my-service")
669                .service_version("1.0.0")
670                .deployment_environment("production")
671                .attribute("custom.key", "custom.value")
672        });
673        let config = builder.extract_config().unwrap();
674
675        assert_eq!(config.resource.service_name, Some("my-service".to_string()));
676        assert_eq!(config.resource.service_version, Some("1.0.0".to_string()));
677        assert_eq!(
678            config.resource.deployment_environment,
679            Some("production".to_string())
680        );
681        assert_eq!(
682            config.resource.attributes.get("custom.key"),
683            Some(&"custom.value".to_string())
684        );
685    }
686
687    #[test]
688    fn test_builder_without_tracing_subscriber() {
689        let builder = OtelSdkBuilder::new().without_tracing_subscriber();
690        let config = builder.extract_config().unwrap();
691
692        assert!(!config.init_tracing_subscriber);
693    }
694
695    #[test]
696    fn test_builder_header() {
697        let builder = OtelSdkBuilder::new().header("Authorization", "Bearer token123");
698        let config = builder.extract_config().unwrap();
699
700        assert_eq!(
701            config.endpoint.headers.get("Authorization"),
702            Some(&"Bearer token123".to_string())
703        );
704    }
705
706    #[test]
707    fn test_builder_fallback() {
708        let builder = OtelSdkBuilder::new().fallback(ExportFallback::Stdout);
709        assert!(matches!(builder.fallback, ExportFallback::Stdout));
710    }
711
712    #[test]
713    fn test_builder_custom_fallback() {
714        let builder = OtelSdkBuilder::new().with_fallback(|_failure| Ok(()));
715        assert!(matches!(builder.fallback, ExportFallback::Custom(_)));
716    }
717
718    #[test]
719    fn test_with_standard_env_endpoint() {
720        temp_env::with_var(
721            "OTEL_EXPORTER_OTLP_ENDPOINT",
722            Some("http://custom:4318"),
723            || {
724                let builder = OtelSdkBuilder::new().with_standard_env();
725                let config = builder.extract_config().unwrap();
726                assert_eq!(config.endpoint.url, Some("http://custom:4318".to_string()));
727            },
728        );
729    }
730
731    #[test]
732    fn test_with_standard_env_service_name() {
733        temp_env::with_var("OTEL_SERVICE_NAME", Some("test-service"), || {
734            let builder = OtelSdkBuilder::new().with_standard_env();
735            let config = builder.extract_config().unwrap();
736            assert_eq!(
737                config.resource.service_name,
738                Some("test-service".to_string())
739            );
740        });
741    }
742
743    #[test]
744    fn test_with_standard_env_protocol_grpc() {
745        temp_env::with_var("OTEL_EXPORTER_OTLP_PROTOCOL", Some("grpc"), || {
746            let builder = OtelSdkBuilder::new().with_standard_env();
747            let config = builder.extract_config().unwrap();
748            assert_eq!(config.endpoint.protocol, Protocol::Grpc);
749        });
750    }
751
752    #[test]
753    fn test_with_standard_env_protocol_http_protobuf() {
754        temp_env::with_var("OTEL_EXPORTER_OTLP_PROTOCOL", Some("http/protobuf"), || {
755            let builder = OtelSdkBuilder::new().with_standard_env();
756            let config = builder.extract_config().unwrap();
757            assert_eq!(config.endpoint.protocol, Protocol::HttpBinary);
758        });
759    }
760
761    #[test]
762    fn test_with_standard_env_traces_disabled() {
763        temp_env::with_var("OTEL_TRACES_EXPORTER", Some("none"), || {
764            let builder = OtelSdkBuilder::new().with_standard_env();
765            let config = builder.extract_config().unwrap();
766            assert!(!config.traces.enabled);
767        });
768    }
769
770    #[test]
771    fn test_with_standard_env_metrics_disabled() {
772        temp_env::with_var("OTEL_METRICS_EXPORTER", Some("none"), || {
773            let builder = OtelSdkBuilder::new().with_standard_env();
774            let config = builder.extract_config().unwrap();
775            assert!(!config.metrics.enabled);
776        });
777    }
778
779    #[test]
780    fn test_with_standard_env_logs_disabled() {
781        temp_env::with_var("OTEL_LOGS_EXPORTER", Some("none"), || {
782            let builder = OtelSdkBuilder::new().with_standard_env();
783            let config = builder.extract_config().unwrap();
784            assert!(!config.logs.enabled);
785        });
786    }
787
788    #[test]
789    fn test_with_standard_env_multiple_vars() {
790        temp_env::with_vars(
791            [
792                ("OTEL_EXPORTER_OTLP_ENDPOINT", Some("http://collector:4317")),
793                ("OTEL_EXPORTER_OTLP_PROTOCOL", Some("grpc")),
794                ("OTEL_SERVICE_NAME", Some("multi-test")),
795                ("OTEL_TRACES_EXPORTER", Some("otlp")),
796            ],
797            || {
798                let builder = OtelSdkBuilder::new().with_standard_env();
799                let config = builder.extract_config().unwrap();
800
801                assert_eq!(
802                    config.endpoint.url,
803                    Some("http://collector:4317".to_string())
804                );
805                assert_eq!(config.endpoint.protocol, Protocol::Grpc);
806                assert_eq!(config.resource.service_name, Some("multi-test".to_string()));
807                assert!(config.traces.enabled);
808            },
809        );
810    }
811
812    #[test]
813    fn test_programmatic_overrides_env() {
814        temp_env::with_vars(
815            [
816                ("OTEL_EXPORTER_OTLP_ENDPOINT", Some("http://env:4318")),
817                ("OTEL_SERVICE_NAME", Some("env-service")),
818            ],
819            || {
820                let builder = OtelSdkBuilder::new()
821                    .with_standard_env()
822                    .endpoint("http://programmatic:4318")
823                    .service_name("programmatic-service");
824                let config = builder.extract_config().unwrap();
825
826                assert_eq!(
827                    config.endpoint.url,
828                    Some("http://programmatic:4318".to_string())
829                );
830                assert_eq!(
831                    config.resource.service_name,
832                    Some("programmatic-service".to_string())
833                );
834            },
835        );
836    }
837
838    #[test]
839    fn test_invalid_endpoint_url_rejected() {
840        let builder = OtelSdkBuilder::new().endpoint("not-a-valid-url");
841        let result = builder.extract_config();
842
843        assert!(result.is_err());
844        let err = result.unwrap_err();
845        assert!(
846            matches!(err, SdkError::InvalidEndpoint { ref url } if url == "not-a-valid-url"),
847            "Expected InvalidEndpoint error, got: {:?}",
848            err
849        );
850    }
851
852    #[test]
853    fn test_valid_http_endpoint_accepted() {
854        let builder = OtelSdkBuilder::new().endpoint("http://localhost:4318");
855        let config = builder.extract_config().unwrap();
856        assert_eq!(
857            config.endpoint.url,
858            Some("http://localhost:4318".to_string())
859        );
860    }
861
862    #[test]
863    fn test_valid_https_endpoint_accepted() {
864        let builder = OtelSdkBuilder::new().endpoint("https://collector.example.com:4318");
865        let config = builder.extract_config().unwrap();
866        assert_eq!(
867            config.endpoint.url,
868            Some("https://collector.example.com:4318".to_string())
869        );
870    }
871}