opentelemetry_configuration/
builder.rs1use crate::SdkError;
12use crate::config::{ComputeEnvironment, 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 if config.compute_environment != ComputeEnvironment::default() {
346 let env_str = match config.compute_environment {
347 ComputeEnvironment::Auto => "auto",
348 ComputeEnvironment::Lambda => "lambda",
349 ComputeEnvironment::Kubernetes => "kubernetes",
350 ComputeEnvironment::None => "none",
351 };
352 self.figment = self
353 .figment
354 .merge(Serialized::default("resource.compute_environment", env_str));
355 }
356 for (key, value) in config.attributes {
357 self.resource_attributes.insert(key, value);
358 }
359
360 self
361 }
362
363 pub fn traces(mut self, enabled: bool) -> Self {
367 self.figment = self
368 .figment
369 .merge(Serialized::default("traces.enabled", enabled));
370 self
371 }
372
373 pub fn metrics(mut self, enabled: bool) -> Self {
377 self.figment = self
378 .figment
379 .merge(Serialized::default("metrics.enabled", enabled));
380 self
381 }
382
383 pub fn logs(mut self, enabled: bool) -> Self {
387 self.figment = self
388 .figment
389 .merge(Serialized::default("logs.enabled", enabled));
390 self
391 }
392
393 pub fn without_tracing_subscriber(mut self) -> Self {
399 self.figment = self
400 .figment
401 .merge(Serialized::default("init_tracing_subscriber", false));
402 self
403 }
404
405 pub fn fallback(mut self, fallback: ExportFallback) -> Self {
427 self.fallback = fallback;
428 self
429 }
430
431 pub fn with_fallback<F>(mut self, f: F) -> Self
462 where
463 F: Fn(super::ExportFailure) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
464 + Send
465 + Sync
466 + 'static,
467 {
468 self.fallback = ExportFallback::custom(f);
469 self
470 }
471
472 pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
476 let header_key = format!("endpoint.headers.{}", key.into());
477 self.figment = self
478 .figment
479 .merge(Serialized::default(&header_key, value.into()));
480 self
481 }
482
483 pub fn instrumentation_scope_name(mut self, name: impl Into<String>) -> Self {
487 self.figment = self.figment.merge(Serialized::default(
488 "instrumentation_scope_name",
489 name.into(),
490 ));
491 self
492 }
493
494 pub fn compute_environment(mut self, env: ComputeEnvironment) -> Self {
502 let env_str = match env {
503 ComputeEnvironment::Auto => "auto",
504 ComputeEnvironment::Lambda => "lambda",
505 ComputeEnvironment::Kubernetes => "kubernetes",
506 ComputeEnvironment::None => "none",
507 };
508 self.figment = self
509 .figment
510 .merge(Serialized::default("resource.compute_environment", env_str));
511 self
512 }
513
514 pub fn with_rust_build_info(mut self, info: crate::RustBuildInfo) -> Self {
544 for kv in info.to_key_values() {
545 let key = kv.key.as_str().to_string();
546 let value = kv.value.as_str().to_string();
547 self.resource_attributes.insert(key, value);
548 }
549 self
550 }
551
552 pub fn extract_config(&self) -> Result<OtelSdkConfig, SdkError> {
559 let mut config: OtelSdkConfig = self
560 .figment
561 .extract()
562 .map_err(|e| SdkError::Config(Box::new(e)))?;
563
564 config
566 .resource
567 .attributes
568 .extend(self.resource_attributes.clone());
569
570 if let Some(ref url) = config.endpoint.url
571 && !url.starts_with("http://")
572 && !url.starts_with("https://")
573 {
574 return Err(SdkError::InvalidEndpoint { url: url.clone() });
575 }
576
577 Ok(config)
578 }
579
580 pub fn build(self) -> Result<OtelGuard, SdkError> {
610 let mut config: OtelSdkConfig = self
611 .figment
612 .extract()
613 .map_err(|e| SdkError::Config(Box::new(e)))?;
614
615 config.resource.attributes.extend(self.resource_attributes);
617
618 if let Some(ref url) = config.endpoint.url
619 && !url.starts_with("http://")
620 && !url.starts_with("https://")
621 {
622 return Err(SdkError::InvalidEndpoint { url: url.clone() });
623 }
624
625 OtelGuard::from_config(config, self.fallback, self.custom_resource)
626 }
627}
628
629impl Default for OtelSdkBuilder {
630 fn default() -> Self {
631 Self::new()
632 }
633}
634
635#[derive(Default)]
639#[must_use = "builders do nothing unless .build() is called"]
640pub struct ResourceConfigBuilder {
641 config: ResourceConfig,
642}
643
644impl ResourceConfigBuilder {
645 pub fn new() -> Self {
647 Self::default()
648 }
649
650 pub fn service_name(mut self, name: impl Into<String>) -> Self {
652 self.config.service_name = Some(name.into());
653 self
654 }
655
656 pub fn service_version(mut self, version: impl Into<String>) -> Self {
658 self.config.service_version = Some(version.into());
659 self
660 }
661
662 pub fn deployment_environment(mut self, env: impl Into<String>) -> Self {
664 self.config.deployment_environment = Some(env.into());
665 self
666 }
667
668 pub fn attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
670 self.config.attributes.insert(key.into(), value.into());
671 self
672 }
673
674 pub fn compute_environment(mut self, env: ComputeEnvironment) -> Self {
676 self.config.compute_environment = env;
677 self
678 }
679
680 pub fn build(self) -> ResourceConfig {
682 self.config
683 }
684}
685
686#[cfg(test)]
687mod tests {
688 use super::*;
689
690 #[test]
691 fn test_builder_default() {
692 let builder = OtelSdkBuilder::new();
693 let config = builder.extract_config().unwrap();
694
695 assert!(config.traces.enabled);
696 assert!(config.metrics.enabled);
697 assert!(config.logs.enabled);
698 assert!(config.init_tracing_subscriber);
699 assert_eq!(config.endpoint.protocol, Protocol::HttpBinary);
700 }
701
702 #[test]
703 fn test_builder_endpoint() {
704 let builder = OtelSdkBuilder::new().endpoint("http://collector:4318");
705 let config = builder.extract_config().unwrap();
706
707 assert_eq!(
708 config.endpoint.url,
709 Some("http://collector:4318".to_string())
710 );
711 }
712
713 #[test]
714 fn test_builder_protocol() {
715 let builder = OtelSdkBuilder::new().protocol(Protocol::Grpc);
716 let config = builder.extract_config().unwrap();
717
718 assert_eq!(config.endpoint.protocol, Protocol::Grpc);
719 }
720
721 #[test]
722 fn test_builder_service_name() {
723 let builder = OtelSdkBuilder::new().service_name("my-service");
724 let config = builder.extract_config().unwrap();
725
726 assert_eq!(config.resource.service_name, Some("my-service".to_string()));
727 }
728
729 #[test]
730 fn test_builder_disable_signals() {
731 let builder = OtelSdkBuilder::new()
732 .traces(false)
733 .metrics(false)
734 .logs(false);
735 let config = builder.extract_config().unwrap();
736
737 assert!(!config.traces.enabled);
738 assert!(!config.metrics.enabled);
739 assert!(!config.logs.enabled);
740 }
741
742 #[test]
743 fn test_builder_resource_fluent() {
744 let builder = OtelSdkBuilder::new().resource(|r| {
745 r.service_name("my-service")
746 .service_version("1.0.0")
747 .deployment_environment("production")
748 .attribute("custom.key", "custom.value")
749 });
750 let config = builder.extract_config().unwrap();
751
752 assert_eq!(config.resource.service_name, Some("my-service".to_string()));
753 assert_eq!(config.resource.service_version, Some("1.0.0".to_string()));
754 assert_eq!(
755 config.resource.deployment_environment,
756 Some("production".to_string())
757 );
758 assert_eq!(
759 config.resource.attributes.get("custom.key"),
760 Some(&"custom.value".to_string())
761 );
762 }
763
764 #[test]
765 fn test_builder_without_tracing_subscriber() {
766 let builder = OtelSdkBuilder::new().without_tracing_subscriber();
767 let config = builder.extract_config().unwrap();
768
769 assert!(!config.init_tracing_subscriber);
770 }
771
772 #[test]
773 fn test_builder_header() {
774 let builder = OtelSdkBuilder::new().header("Authorization", "Bearer token123");
775 let config = builder.extract_config().unwrap();
776
777 assert_eq!(
778 config.endpoint.headers.get("Authorization"),
779 Some(&"Bearer token123".to_string())
780 );
781 }
782
783 #[test]
784 fn test_builder_fallback() {
785 let builder = OtelSdkBuilder::new().fallback(ExportFallback::Stdout);
786 assert!(matches!(builder.fallback, ExportFallback::Stdout));
787 }
788
789 #[test]
790 fn test_builder_custom_fallback() {
791 let builder = OtelSdkBuilder::new().with_fallback(|_failure| Ok(()));
792 assert!(matches!(builder.fallback, ExportFallback::Custom(_)));
793 }
794
795 #[test]
796 fn test_with_standard_env_endpoint() {
797 temp_env::with_var(
798 "OTEL_EXPORTER_OTLP_ENDPOINT",
799 Some("http://custom:4318"),
800 || {
801 let builder = OtelSdkBuilder::new().with_standard_env();
802 let config = builder.extract_config().unwrap();
803 assert_eq!(config.endpoint.url, Some("http://custom:4318".to_string()));
804 },
805 );
806 }
807
808 #[test]
809 fn test_with_standard_env_service_name() {
810 temp_env::with_var("OTEL_SERVICE_NAME", Some("test-service"), || {
811 let builder = OtelSdkBuilder::new().with_standard_env();
812 let config = builder.extract_config().unwrap();
813 assert_eq!(
814 config.resource.service_name,
815 Some("test-service".to_string())
816 );
817 });
818 }
819
820 #[test]
821 fn test_with_standard_env_protocol_grpc() {
822 temp_env::with_var("OTEL_EXPORTER_OTLP_PROTOCOL", Some("grpc"), || {
823 let builder = OtelSdkBuilder::new().with_standard_env();
824 let config = builder.extract_config().unwrap();
825 assert_eq!(config.endpoint.protocol, Protocol::Grpc);
826 });
827 }
828
829 #[test]
830 fn test_with_standard_env_protocol_http_protobuf() {
831 temp_env::with_var("OTEL_EXPORTER_OTLP_PROTOCOL", Some("http/protobuf"), || {
832 let builder = OtelSdkBuilder::new().with_standard_env();
833 let config = builder.extract_config().unwrap();
834 assert_eq!(config.endpoint.protocol, Protocol::HttpBinary);
835 });
836 }
837
838 #[test]
839 fn test_with_standard_env_traces_disabled() {
840 temp_env::with_var("OTEL_TRACES_EXPORTER", Some("none"), || {
841 let builder = OtelSdkBuilder::new().with_standard_env();
842 let config = builder.extract_config().unwrap();
843 assert!(!config.traces.enabled);
844 });
845 }
846
847 #[test]
848 fn test_with_standard_env_metrics_disabled() {
849 temp_env::with_var("OTEL_METRICS_EXPORTER", Some("none"), || {
850 let builder = OtelSdkBuilder::new().with_standard_env();
851 let config = builder.extract_config().unwrap();
852 assert!(!config.metrics.enabled);
853 });
854 }
855
856 #[test]
857 fn test_with_standard_env_logs_disabled() {
858 temp_env::with_var("OTEL_LOGS_EXPORTER", Some("none"), || {
859 let builder = OtelSdkBuilder::new().with_standard_env();
860 let config = builder.extract_config().unwrap();
861 assert!(!config.logs.enabled);
862 });
863 }
864
865 #[test]
866 fn test_with_standard_env_multiple_vars() {
867 temp_env::with_vars(
868 [
869 ("OTEL_EXPORTER_OTLP_ENDPOINT", Some("http://collector:4317")),
870 ("OTEL_EXPORTER_OTLP_PROTOCOL", Some("grpc")),
871 ("OTEL_SERVICE_NAME", Some("multi-test")),
872 ("OTEL_TRACES_EXPORTER", Some("otlp")),
873 ],
874 || {
875 let builder = OtelSdkBuilder::new().with_standard_env();
876 let config = builder.extract_config().unwrap();
877
878 assert_eq!(
879 config.endpoint.url,
880 Some("http://collector:4317".to_string())
881 );
882 assert_eq!(config.endpoint.protocol, Protocol::Grpc);
883 assert_eq!(config.resource.service_name, Some("multi-test".to_string()));
884 assert!(config.traces.enabled);
885 },
886 );
887 }
888
889 #[test]
890 fn test_programmatic_overrides_env() {
891 temp_env::with_vars(
892 [
893 ("OTEL_EXPORTER_OTLP_ENDPOINT", Some("http://env:4318")),
894 ("OTEL_SERVICE_NAME", Some("env-service")),
895 ],
896 || {
897 let builder = OtelSdkBuilder::new()
898 .with_standard_env()
899 .endpoint("http://programmatic:4318")
900 .service_name("programmatic-service");
901 let config = builder.extract_config().unwrap();
902
903 assert_eq!(
904 config.endpoint.url,
905 Some("http://programmatic:4318".to_string())
906 );
907 assert_eq!(
908 config.resource.service_name,
909 Some("programmatic-service".to_string())
910 );
911 },
912 );
913 }
914
915 #[test]
916 fn test_invalid_endpoint_url_rejected() {
917 let builder = OtelSdkBuilder::new().endpoint("not-a-valid-url");
918 let result = builder.extract_config();
919
920 assert!(result.is_err());
921 let err = result.unwrap_err();
922 assert!(
923 matches!(err, SdkError::InvalidEndpoint { ref url } if url == "not-a-valid-url"),
924 "Expected InvalidEndpoint error, got: {:?}",
925 err
926 );
927 }
928
929 #[test]
930 fn test_valid_http_endpoint_accepted() {
931 let builder = OtelSdkBuilder::new().endpoint("http://localhost:4318");
932 let config = builder.extract_config().unwrap();
933 assert_eq!(
934 config.endpoint.url,
935 Some("http://localhost:4318".to_string())
936 );
937 }
938
939 #[test]
940 fn test_valid_https_endpoint_accepted() {
941 let builder = OtelSdkBuilder::new().endpoint("https://collector.example.com:4318");
942 let config = builder.extract_config().unwrap();
943 assert_eq!(
944 config.endpoint.url,
945 Some("https://collector.example.com:4318".to_string())
946 );
947 }
948}