opentelemetry_configuration/
builder.rs1use 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#[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 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 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 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 pub fn with_env(mut self, prefix: &str) -> Self {
154 self.figment = self.figment.merge(Env::prefixed(prefix).split("_"));
155 self
156 }
157
158 pub fn with_standard_env(mut self) -> Self {
169 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 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 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 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 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 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 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 pub fn with_resource(mut self, resource: Resource) -> Self {
302 self.custom_resource = Some(resource);
303 self
304 }
305
306 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 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 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 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 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 pub fn fallback(mut self, fallback: ExportFallback) -> Self {
416 self.fallback = fallback;
417 self
418 }
419
420 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 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 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 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 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 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 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#[derive(Default)]
562#[must_use = "builders do nothing unless .build() is called"]
563pub struct ResourceConfigBuilder {
564 config: ResourceConfig,
565}
566
567impl ResourceConfigBuilder {
568 pub fn new() -> Self {
570 Self::default()
571 }
572
573 pub fn service_name(mut self, name: impl Into<String>) -> Self {
575 self.config.service_name = Some(name.into());
576 self
577 }
578
579 pub fn service_version(mut self, version: impl Into<String>) -> Self {
581 self.config.service_version = Some(version.into());
582 self
583 }
584
585 pub fn deployment_environment(mut self, env: impl Into<String>) -> Self {
587 self.config.deployment_environment = Some(env.into());
588 self
589 }
590
591 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 pub fn without_lambda_detection(mut self) -> Self {
599 self.config.detect_lambda = false;
600 self
601 }
602
603 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}