opentelemetry_configuration/
builder.rs

1//! Builder for OpenTelemetry SDK configuration.
2//!
3//! Supports layered configuration: defaults → files → env vars → programmatic.
4
5use crate::SdkError;
6use crate::config::{ComputeEnvironment, OtelSdkConfig, Protocol, ResourceConfig};
7use crate::guard::OtelGuard;
8use figment::Figment;
9use figment::providers::{Env, Format, Serialized, Toml};
10use opentelemetry_sdk::Resource;
11use std::path::Path;
12
13/// Builder for configuring and initialising the OpenTelemetry SDK.
14///
15/// # Example
16///
17/// ```no_run
18/// use opentelemetry_configuration::{OtelSdkBuilder, SdkError};
19///
20/// let _guard = OtelSdkBuilder::new()
21///     .service_name("my-service")
22///     .build()?;
23/// # Ok::<(), SdkError>(())
24/// ```
25#[must_use = "builders do nothing unless .build() is called"]
26pub struct OtelSdkBuilder {
27    figment: Figment,
28    custom_resource: Option<Resource>,
29    resource_attributes: std::collections::HashMap<String, String>,
30}
31
32impl OtelSdkBuilder {
33    /// Creates a new builder with default configuration.
34    pub fn new() -> Self {
35        Self {
36            figment: Figment::from(Serialized::defaults(OtelSdkConfig::default())),
37            custom_resource: None,
38            resource_attributes: std::collections::HashMap::new(),
39        }
40    }
41
42    /// Creates a builder from an existing figment for complex configuration chains.
43    pub fn from_figment(figment: Figment) -> Self {
44        Self {
45            figment,
46            custom_resource: None,
47            resource_attributes: std::collections::HashMap::new(),
48        }
49    }
50
51    /// Merges configuration from a TOML file. Missing files are silently skipped.
52    pub fn with_file<P: AsRef<Path>>(mut self, path: P) -> Self {
53        let path = path.as_ref();
54        if path.exists() {
55            self.figment = self.figment.merge(Toml::file(path));
56        }
57        self
58    }
59
60    /// Merges configuration from environment variables with the given prefix.
61    ///
62    /// Variables are split on underscores: `PREFIX_FOO_BAR` → `foo.bar`.
63    pub fn with_env(mut self, prefix: &str) -> Self {
64        self.figment = self.figment.merge(Env::prefixed(prefix).split("_"));
65        self
66    }
67
68    /// Merges standard `OTEL_*` environment variables per OpenTelemetry spec.
69    pub fn with_standard_env(mut self) -> Self {
70        if let Ok(endpoint) = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT") {
71            self.figment = self
72                .figment
73                .merge(Serialized::default("endpoint.url", endpoint));
74        }
75
76        if let Ok(protocol) = std::env::var("OTEL_EXPORTER_OTLP_PROTOCOL") {
77            let protocol = match protocol.as_str() {
78                "grpc" => "grpc",
79                "http/json" => "httpjson",
80                // "http/protobuf" and unknown values default to httpbinary
81                _ => "httpbinary",
82            };
83            self.figment = self
84                .figment
85                .merge(Serialized::default("endpoint.protocol", protocol));
86        }
87
88        if let Ok(service_name) = std::env::var("OTEL_SERVICE_NAME") {
89            self.figment = self
90                .figment
91                .merge(Serialized::default("resource.service_name", service_name));
92        }
93
94        if let Ok(exporter) = std::env::var("OTEL_TRACES_EXPORTER") {
95            let enabled = exporter != "none";
96            self.figment = self
97                .figment
98                .merge(Serialized::default("traces.enabled", enabled));
99        }
100
101        if let Ok(exporter) = std::env::var("OTEL_METRICS_EXPORTER") {
102            let enabled = exporter != "none";
103            self.figment = self
104                .figment
105                .merge(Serialized::default("metrics.enabled", enabled));
106        }
107
108        if let Ok(exporter) = std::env::var("OTEL_LOGS_EXPORTER") {
109            let enabled = exporter != "none";
110            self.figment = self
111                .figment
112                .merge(Serialized::default("logs.enabled", enabled));
113        }
114
115        self
116    }
117
118    /// Sets the OTLP endpoint URL. For HTTP, signal paths are appended automatically.
119    pub fn endpoint(mut self, url: impl Into<String>) -> Self {
120        self.figment = self
121            .figment
122            .merge(Serialized::default("endpoint.url", url.into()));
123        self
124    }
125
126    /// Sets the export protocol.
127    ///
128    /// This overrides any configuration from files or environment variables.
129    ///
130    /// The default endpoint changes based on protocol:
131    /// - `Protocol::Grpc` → `http://localhost:4317`
132    /// - `Protocol::HttpBinary` → `http://localhost:4318`
133    /// - `Protocol::HttpJson` → `http://localhost:4318`
134    pub fn protocol(mut self, protocol: Protocol) -> Self {
135        let protocol_str = match protocol {
136            Protocol::Grpc => "grpc",
137            Protocol::HttpBinary => "httpbinary",
138            Protocol::HttpJson => "httpjson",
139        };
140        self.figment = self
141            .figment
142            .merge(Serialized::default("endpoint.protocol", protocol_str));
143        self
144    }
145
146    /// Sets the service name resource attribute.
147    ///
148    /// This is the most commonly configured resource attribute and identifies
149    /// your service in the telemetry backend.
150    pub fn service_name(mut self, name: impl Into<String>) -> Self {
151        self.figment = self
152            .figment
153            .merge(Serialized::default("resource.service_name", name.into()));
154        self
155    }
156
157    /// Sets the service version resource attribute.
158    pub fn service_version(mut self, version: impl Into<String>) -> Self {
159        self.figment = self.figment.merge(Serialized::default(
160            "resource.service_version",
161            version.into(),
162        ));
163        self
164    }
165
166    /// Sets the deployment environment resource attribute.
167    pub fn deployment_environment(mut self, env: impl Into<String>) -> Self {
168        self.figment = self.figment.merge(Serialized::default(
169            "resource.deployment_environment",
170            env.into(),
171        ));
172        self
173    }
174
175    /// Adds a custom resource attribute.
176    ///
177    /// Resource attributes describe the entity producing telemetry. Use this for
178    /// application-specific metadata not covered by standard semantic conventions.
179    ///
180    /// # Example
181    ///
182    /// ```no_run
183    /// # fn main() -> Result<(), opentelemetry_configuration::SdkError> {
184    /// use opentelemetry_configuration::OtelSdkBuilder;
185    ///
186    /// let _guard = OtelSdkBuilder::new()
187    ///     .service_name("my-service")
188    ///     .resource_attribute("git.commit", "abc123")
189    ///     .resource_attribute("feature.flags", "new-ui,beta-api")
190    ///     .build()?;
191    /// # Ok(())
192    /// # }
193    /// ```
194    pub fn resource_attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
195        self.resource_attributes.insert(key.into(), value.into());
196        self
197    }
198
199    /// Provides a pre-built OpenTelemetry Resource.
200    ///
201    /// This takes precedence over individual resource configuration.
202    /// Use this when you need fine-grained control over resource construction.
203    pub fn with_resource(mut self, resource: Resource) -> Self {
204        self.custom_resource = Some(resource);
205        self
206    }
207
208    /// Configures the resource using a builder function.
209    ///
210    /// # Example
211    ///
212    /// ```no_run
213    /// use opentelemetry_configuration::{OtelSdkBuilder, SdkError};
214    ///
215    /// let _guard = OtelSdkBuilder::new()
216    ///     .resource(|r| r
217    ///         .service_name("my-lambda")
218    ///         .service_version(env!("CARGO_PKG_VERSION"))
219    ///         .deployment_environment("production"))
220    ///     .build()?;
221    /// # Ok::<(), SdkError>(())
222    /// ```
223    pub fn resource<F>(mut self, f: F) -> Self
224    where
225        F: FnOnce(ResourceConfigBuilder) -> ResourceConfigBuilder,
226    {
227        let builder = f(ResourceConfigBuilder::new());
228        let config = builder.build();
229
230        if let Some(name) = &config.service_name {
231            self.figment = self
232                .figment
233                .merge(Serialized::default("resource.service_name", name.clone()));
234        }
235        if let Some(version) = &config.service_version {
236            self.figment = self.figment.merge(Serialized::default(
237                "resource.service_version",
238                version.clone(),
239            ));
240        }
241        if let Some(env) = &config.deployment_environment {
242            self.figment = self.figment.merge(Serialized::default(
243                "resource.deployment_environment",
244                env.clone(),
245            ));
246        }
247        if config.compute_environment != ComputeEnvironment::default() {
248            let env_str = match config.compute_environment {
249                ComputeEnvironment::Auto => "auto",
250                ComputeEnvironment::Lambda => "lambda",
251                ComputeEnvironment::Kubernetes => "kubernetes",
252                ComputeEnvironment::None => "none",
253            };
254            self.figment = self
255                .figment
256                .merge(Serialized::default("resource.compute_environment", env_str));
257        }
258        for (key, value) in config.attributes {
259            self.resource_attributes.insert(key, value);
260        }
261
262        self
263    }
264
265    /// Enables or disables trace collection.
266    ///
267    /// Default: enabled
268    pub fn traces(mut self, enabled: bool) -> Self {
269        self.figment = self
270            .figment
271            .merge(Serialized::default("traces.enabled", enabled));
272        self
273    }
274
275    /// Enables or disables metrics collection.
276    ///
277    /// Default: enabled
278    pub fn metrics(mut self, enabled: bool) -> Self {
279        self.figment = self
280            .figment
281            .merge(Serialized::default("metrics.enabled", enabled));
282        self
283    }
284
285    /// Enables or disables log collection.
286    ///
287    /// Default: enabled
288    pub fn logs(mut self, enabled: bool) -> Self {
289        self.figment = self
290            .figment
291            .merge(Serialized::default("logs.enabled", enabled));
292        self
293    }
294
295    /// Disables automatic tracing subscriber initialisation.
296    ///
297    /// By default, the SDK sets up a `tracing-subscriber` with
298    /// `tracing-opentelemetry` and `opentelemetry-appender-tracing` integration.
299    /// Disable this if you want to configure the subscriber yourself.
300    pub fn without_tracing_subscriber(mut self) -> Self {
301        self.figment = self
302            .figment
303            .merge(Serialized::default("init_tracing_subscriber", false));
304        self
305    }
306
307    /// Adds an HTTP header to all export requests.
308    ///
309    /// Headers are applied to trace, metric, and log exporters. Common uses include
310    /// authentication tokens and custom routing metadata.
311    ///
312    /// # Example
313    ///
314    /// ```no_run
315    /// # fn main() -> Result<(), opentelemetry_configuration::SdkError> {
316    /// use opentelemetry_configuration::OtelSdkBuilder;
317    ///
318    /// let api_token = std::env::var("API_TOKEN").unwrap_or_default();
319    /// let _guard = OtelSdkBuilder::new()
320    ///     .service_name("my-service")
321    ///     .header("Authorization", format!("Bearer {api_token}"))
322    ///     .build()?;
323    /// # Ok(())
324    /// # }
325    /// ```
326    pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
327        let header_key = format!("endpoint.headers.{}", key.into());
328        self.figment = self
329            .figment
330            .merge(Serialized::default(&header_key, value.into()));
331        self
332    }
333
334    /// Sets the instrumentation scope name (otel.library.name).
335    ///
336    /// If not set, defaults to the service name, then "opentelemetry-configuration".
337    pub fn instrumentation_scope_name(mut self, name: impl Into<String>) -> Self {
338        self.figment = self.figment.merge(Serialized::default(
339            "instrumentation_scope_name",
340            name.into(),
341        ));
342        self
343    }
344
345    /// Sets the compute environment for resource detection.
346    ///
347    /// Controls which resource detectors are run automatically:
348    /// - `Auto` (default): Runs generic detectors and probes for Lambda/K8s
349    /// - `Lambda`: Runs generic detectors + Lambda-specific attributes
350    /// - `Kubernetes`: Runs generic detectors + K8s detector
351    /// - `None`: No automatic detection, only explicit configuration
352    pub fn compute_environment(mut self, env: ComputeEnvironment) -> Self {
353        let env_str = match env {
354            ComputeEnvironment::Auto => "auto",
355            ComputeEnvironment::Lambda => "lambda",
356            ComputeEnvironment::Kubernetes => "kubernetes",
357            ComputeEnvironment::None => "none",
358        };
359        self.figment = self
360            .figment
361            .merge(Serialized::default("resource.compute_environment", env_str));
362        self
363    }
364
365    /// Adds Rust build-time information as resource attributes.
366    ///
367    /// Use with the [`capture_rust_build_info!`](crate::capture_rust_build_info) macro
368    /// to add rustc version and channel information to telemetry.
369    ///
370    /// Requires [`emit_rustc_env`](crate::emit_rustc_env) to be called in your build.rs.
371    ///
372    /// # Example
373    ///
374    /// In build.rs:
375    ///
376    /// ```
377    /// opentelemetry_configuration::emit_rustc_env();
378    /// ```
379    ///
380    /// In main.rs:
381    ///
382    /// ```no_run
383    /// # fn main() -> Result<(), opentelemetry_configuration::SdkError> {
384    /// use opentelemetry_configuration::{OtelSdkBuilder, capture_rust_build_info};
385    ///
386    /// let _guard = OtelSdkBuilder::new()
387    ///     .service_name("my-service")
388    ///     .with_rust_build_info(capture_rust_build_info!())
389    ///     .build()?;
390    /// # Ok(())
391    /// # }
392    /// ```
393    ///
394    /// # Attributes Added
395    ///
396    /// - `process.runtime.version` - rustc version (e.g., "1.84.0")
397    /// - `process.runtime.description` - full version string
398    /// - `rust.channel` - release channel ("stable", "beta", or "nightly")
399    pub fn with_rust_build_info(mut self, info: crate::RustBuildInfo) -> Self {
400        for kv in info.to_key_values() {
401            let key = kv.key.as_str().to_string();
402            let value = kv.value.as_str().to_string();
403            self.resource_attributes.insert(key, value);
404        }
405        self
406    }
407
408    /// Extracts the configuration for inspection or debugging.
409    ///
410    /// # Errors
411    ///
412    /// Returns an error if configuration extraction fails or if the endpoint
413    /// URL is invalid.
414    pub fn extract_config(&self) -> Result<OtelSdkConfig, SdkError> {
415        let mut config: OtelSdkConfig = self
416            .figment
417            .extract()
418            .map_err(|e| SdkError::Config(Box::new(e)))?;
419
420        config
421            .resource
422            .attributes
423            .extend(self.resource_attributes.clone());
424
425        if let Some(ref url) = config.endpoint.url
426            && !url.starts_with("http://")
427            && !url.starts_with("https://")
428        {
429            return Err(SdkError::InvalidEndpoint { url: url.clone() });
430        }
431
432        Ok(config)
433    }
434
435    /// Builds and initialises the OpenTelemetry SDK.
436    ///
437    /// Returns an [`OtelGuard`] that manages provider lifecycle. When the
438    /// guard is dropped, all providers are flushed and shut down.
439    ///
440    /// # Errors
441    ///
442    /// Returns an error if:
443    /// - Configuration extraction fails
444    /// - Provider initialisation fails
445    /// - Tracing subscriber initialisation fails
446    ///
447    /// # Example
448    ///
449    /// ```no_run
450    /// use opentelemetry_configuration::{OtelSdkBuilder, SdkError};
451    ///
452    /// fn main() -> Result<(), SdkError> {
453    ///     let _guard = OtelSdkBuilder::new()
454    ///         .with_env("OTEL_")
455    ///         .service_name("my-lambda")
456    ///         .build()?;
457    ///
458    ///     tracing::info!("Application started");
459    ///
460    ///     // Guard automatically shuts down providers on drop
461    ///     Ok(())
462    /// }
463    /// ```
464    pub fn build(self) -> Result<OtelGuard, SdkError> {
465        let mut config: OtelSdkConfig = self
466            .figment
467            .extract()
468            .map_err(|e| SdkError::Config(Box::new(e)))?;
469
470        config.resource.attributes.extend(self.resource_attributes);
471
472        if let Some(ref url) = config.endpoint.url
473            && !url.starts_with("http://")
474            && !url.starts_with("https://")
475        {
476            return Err(SdkError::InvalidEndpoint { url: url.clone() });
477        }
478
479        OtelGuard::from_config(&config, self.custom_resource)
480    }
481}
482
483impl Default for OtelSdkBuilder {
484    fn default() -> Self {
485        Self::new()
486    }
487}
488
489/// Builder for resource configuration.
490///
491/// Used with [`OtelSdkBuilder::resource`] for fluent configuration.
492#[derive(Default)]
493#[must_use = "builders do nothing unless .build() is called"]
494pub struct ResourceConfigBuilder {
495    config: ResourceConfig,
496}
497
498impl ResourceConfigBuilder {
499    /// Creates a new resource config builder.
500    pub fn new() -> Self {
501        Self::default()
502    }
503
504    /// Sets the service name.
505    pub fn service_name(mut self, name: impl Into<String>) -> Self {
506        self.config.service_name = Some(name.into());
507        self
508    }
509
510    /// Sets the service version.
511    pub fn service_version(mut self, version: impl Into<String>) -> Self {
512        self.config.service_version = Some(version.into());
513        self
514    }
515
516    /// Sets the deployment environment.
517    pub fn deployment_environment(mut self, env: impl Into<String>) -> Self {
518        self.config.deployment_environment = Some(env.into());
519        self
520    }
521
522    /// Adds a resource attribute.
523    pub fn attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
524        self.config.attributes.insert(key.into(), value.into());
525        self
526    }
527
528    /// Sets the compute environment for resource detection.
529    pub fn compute_environment(mut self, env: ComputeEnvironment) -> Self {
530        self.config.compute_environment = env;
531        self
532    }
533
534    /// Builds the resource configuration.
535    #[must_use]
536    pub fn build(self) -> ResourceConfig {
537        self.config
538    }
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544
545    #[test]
546    fn new_builder_enables_all_signals_with_http_binary_protocol() {
547        let builder = OtelSdkBuilder::new();
548        let config = builder.extract_config().unwrap();
549
550        assert!(config.traces.enabled);
551        assert!(config.metrics.enabled);
552        assert!(config.logs.enabled);
553        assert!(config.init_tracing_subscriber);
554        assert_eq!(config.endpoint.protocol, Protocol::HttpBinary);
555    }
556
557    #[test]
558    fn builder_methods_can_disable_individual_signals() {
559        let builder = OtelSdkBuilder::new()
560            .traces(false)
561            .metrics(false)
562            .logs(false);
563        let config = builder.extract_config().unwrap();
564
565        assert!(!config.traces.enabled);
566        assert!(!config.metrics.enabled);
567        assert!(!config.logs.enabled);
568    }
569
570    #[test]
571    fn test_builder_resource_fluent() {
572        let builder = OtelSdkBuilder::new().resource(|r| {
573            r.service_name("my-service")
574                .service_version("1.0.0")
575                .deployment_environment("production")
576                .attribute("custom.key", "custom.value")
577        });
578        let config = builder.extract_config().unwrap();
579
580        assert_eq!(config.resource.service_name, Some("my-service".to_string()));
581        assert_eq!(config.resource.service_version, Some("1.0.0".to_string()));
582        assert_eq!(
583            config.resource.deployment_environment,
584            Some("production".to_string())
585        );
586        assert_eq!(
587            config.resource.attributes.get("custom.key"),
588            Some(&"custom.value".to_string())
589        );
590    }
591
592    #[test]
593    fn test_builder_without_tracing_subscriber() {
594        let builder = OtelSdkBuilder::new().without_tracing_subscriber();
595        let config = builder.extract_config().unwrap();
596
597        assert!(!config.init_tracing_subscriber);
598    }
599
600    #[test]
601    fn test_with_standard_env_service_name() {
602        temp_env::with_var("OTEL_SERVICE_NAME", Some("test-service"), || {
603            let builder = OtelSdkBuilder::new().with_standard_env();
604            let config = builder.extract_config().unwrap();
605            assert_eq!(
606                config.resource.service_name,
607                Some("test-service".to_string())
608            );
609        });
610    }
611
612    #[test]
613    fn test_with_standard_env_multiple_vars() {
614        temp_env::with_vars(
615            [
616                ("OTEL_EXPORTER_OTLP_ENDPOINT", Some("http://collector:4317")),
617                ("OTEL_EXPORTER_OTLP_PROTOCOL", Some("grpc")),
618                ("OTEL_SERVICE_NAME", Some("multi-test")),
619                ("OTEL_TRACES_EXPORTER", Some("otlp")),
620            ],
621            || {
622                let builder = OtelSdkBuilder::new().with_standard_env();
623                let config = builder.extract_config().unwrap();
624
625                assert_eq!(
626                    config.endpoint.url,
627                    Some("http://collector:4317".to_string())
628                );
629                assert_eq!(config.endpoint.protocol, Protocol::Grpc);
630                assert_eq!(config.resource.service_name, Some("multi-test".to_string()));
631                assert!(config.traces.enabled);
632            },
633        );
634    }
635
636    #[test]
637    fn test_programmatic_overrides_env() {
638        temp_env::with_vars(
639            [
640                ("OTEL_EXPORTER_OTLP_ENDPOINT", Some("http://env:4318")),
641                ("OTEL_SERVICE_NAME", Some("env-service")),
642            ],
643            || {
644                let builder = OtelSdkBuilder::new()
645                    .with_standard_env()
646                    .endpoint("http://programmatic:4318")
647                    .service_name("programmatic-service");
648                let config = builder.extract_config().unwrap();
649
650                assert_eq!(
651                    config.endpoint.url,
652                    Some("http://programmatic:4318".to_string())
653                );
654                assert_eq!(
655                    config.resource.service_name,
656                    Some("programmatic-service".to_string())
657                );
658            },
659        );
660    }
661
662    #[test]
663    fn test_invalid_endpoint_url_rejected() {
664        let builder = OtelSdkBuilder::new().endpoint("not-a-valid-url");
665        let result = builder.extract_config();
666
667        assert!(result.is_err());
668        let err = result.unwrap_err();
669        assert!(
670            matches!(err, SdkError::InvalidEndpoint { ref url } if url == "not-a-valid-url"),
671            "Expected InvalidEndpoint error, got: {err:?}"
672        );
673    }
674
675    #[test]
676    fn test_valid_http_endpoint_accepted() {
677        let builder = OtelSdkBuilder::new().endpoint("http://localhost:4318");
678        let config = builder.extract_config().unwrap();
679        assert_eq!(
680            config.endpoint.url,
681            Some("http://localhost:4318".to_string())
682        );
683    }
684
685    #[test]
686    fn test_valid_https_endpoint_accepted() {
687        let builder = OtelSdkBuilder::new().endpoint("https://collector.example.com:4318");
688        let config = builder.extract_config().unwrap();
689        assert_eq!(
690            config.endpoint.url,
691            Some("https://collector.example.com:4318".to_string())
692        );
693    }
694
695    #[test]
696    fn extract_config_rejects_endpoint_with_ftp_scheme() {
697        let builder = OtelSdkBuilder::new().endpoint("ftp://collector:21");
698        let result = builder.extract_config();
699
700        assert!(result.is_err());
701        let err = result.unwrap_err();
702        assert!(
703            matches!(err, SdkError::InvalidEndpoint { ref url } if url == "ftp://collector:21"),
704            "Expected InvalidEndpoint error for ftp scheme, got: {err:?}"
705        );
706    }
707
708    #[test]
709    fn with_standard_env_maps_unknown_protocol_to_default() {
710        temp_env::with_var(
711            "OTEL_EXPORTER_OTLP_PROTOCOL",
712            Some("unknown-protocol"),
713            || {
714                let builder = OtelSdkBuilder::new().with_standard_env();
715                let config = builder.extract_config().unwrap();
716                assert_eq!(
717                    config.endpoint.protocol,
718                    Protocol::HttpBinary,
719                    "Unknown protocol should fall back to HttpBinary"
720                );
721            },
722        );
723    }
724
725    #[test]
726    fn configuration_layering_follows_correct_precedence() {
727        use std::io::Write;
728
729        let mut file = tempfile::NamedTempFile::new().unwrap();
730        writeln!(
731            file,
732            r#"
733[resource]
734service_name = "file-service"
735
736[endpoint]
737url = "http://file-collector:4318"
738"#
739        )
740        .unwrap();
741
742        temp_env::with_vars(
743            [
744                ("OTEL_SERVICE_NAME", Some("env-service")),
745                (
746                    "OTEL_EXPORTER_OTLP_ENDPOINT",
747                    Some("http://env-collector:4318"),
748                ),
749            ],
750            || {
751                let builder = OtelSdkBuilder::new()
752                    .with_file(file.path())
753                    .with_standard_env()
754                    .service_name("programmatic-service");
755                let config = builder.extract_config().unwrap();
756
757                assert_eq!(
758                    config.resource.service_name,
759                    Some("programmatic-service".to_string()),
760                    "Programmatic config should override env and file"
761                );
762                assert_eq!(
763                    config.endpoint.url,
764                    Some("http://env-collector:4318".to_string()),
765                    "Env config should override file config"
766                );
767            },
768        );
769    }
770
771    #[test]
772    fn with_file_merges_toml_config() {
773        use std::io::Write;
774
775        let mut file = tempfile::NamedTempFile::new().unwrap();
776        writeln!(
777            file,
778            r#"
779[resource]
780service_name = "toml-service"
781service_version = "2.0.0"
782
783[endpoint]
784url = "http://toml-collector:4318"
785protocol = "grpc"
786
787[traces]
788enabled = false
789"#
790        )
791        .unwrap();
792
793        let builder = OtelSdkBuilder::new().with_file(file.path());
794        let config = builder.extract_config().unwrap();
795
796        assert_eq!(
797            config.resource.service_name,
798            Some("toml-service".to_string())
799        );
800        assert_eq!(config.resource.service_version, Some("2.0.0".to_string()));
801        assert_eq!(
802            config.endpoint.url,
803            Some("http://toml-collector:4318".to_string())
804        );
805        assert_eq!(config.endpoint.protocol, Protocol::Grpc);
806        assert!(!config.traces.enabled);
807    }
808
809    #[test]
810    fn with_env_reads_prefixed_environment_variables() {
811        temp_env::with_var("MYAPP_ENDPOINT_URL", Some("http://custom:4318"), || {
812            let builder = OtelSdkBuilder::new().with_env("MYAPP_");
813            let config = builder.extract_config().unwrap();
814            assert_eq!(config.endpoint.url, Some("http://custom:4318".to_string()));
815        });
816    }
817
818    #[test]
819    fn header_adds_to_endpoint_headers() {
820        let builder = OtelSdkBuilder::new()
821            .header("X-Custom", "value1")
822            .header("X-Another", "value2");
823        let config = builder.extract_config().unwrap();
824
825        assert_eq!(
826            config.endpoint.headers.get("X-Custom"),
827            Some(&"value1".to_string())
828        );
829        assert_eq!(
830            config.endpoint.headers.get("X-Another"),
831            Some(&"value2".to_string())
832        );
833    }
834
835    #[test]
836    fn instrumentation_scope_name_overrides_default() {
837        let builder = OtelSdkBuilder::new().instrumentation_scope_name("custom-scope");
838        let config = builder.extract_config().unwrap();
839
840        assert_eq!(
841            config.instrumentation_scope_name,
842            Some("custom-scope".to_string())
843        );
844    }
845}