opentelemetry_configuration/
builder.rs1use 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#[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 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 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 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 pub fn with_env(mut self, prefix: &str) -> Self {
64 self.figment = self.figment.merge(Env::prefixed(prefix).split("_"));
65 self
66 }
67
68 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 _ => "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 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 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 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 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 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 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 pub fn with_resource(mut self, resource: Resource) -> Self {
204 self.custom_resource = Some(resource);
205 self
206 }
207
208 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 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 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 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 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 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 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 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 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 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 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#[derive(Default)]
493#[must_use = "builders do nothing unless .build() is called"]
494pub struct ResourceConfigBuilder {
495 config: ResourceConfig,
496}
497
498impl ResourceConfigBuilder {
499 pub fn new() -> Self {
501 Self::default()
502 }
503
504 pub fn service_name(mut self, name: impl Into<String>) -> Self {
506 self.config.service_name = Some(name.into());
507 self
508 }
509
510 pub fn service_version(mut self, version: impl Into<String>) -> Self {
512 self.config.service_version = Some(version.into());
513 self
514 }
515
516 pub fn deployment_environment(mut self, env: impl Into<String>) -> Self {
518 self.config.deployment_environment = Some(env.into());
519 self
520 }
521
522 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 pub fn compute_environment(mut self, env: ComputeEnvironment) -> Self {
530 self.config.compute_environment = env;
531 self
532 }
533
534 #[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}