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.
477 pub fn extract_config(&self) -> Result<OtelSdkConfig, SdkError> {
478 let mut config: OtelSdkConfig = self
479 .figment
480 .extract()
481 .map_err(|e| SdkError::Config(Box::new(e)))?;
482
483 // Merge resource attributes that couldn't go through figment
484 config
485 .resource
486 .attributes
487 .extend(self.resource_attributes.clone());
488
489 Ok(config)
490 }
491
492 /// Builds and initialises the OpenTelemetry SDK.
493 ///
494 /// Returns an [`OtelGuard`] that manages provider lifecycle. When the
495 /// guard is dropped, all providers are flushed and shut down.
496 ///
497 /// # Errors
498 ///
499 /// Returns an error if:
500 /// - Configuration extraction fails
501 /// - Provider initialisation fails
502 /// - Tracing subscriber initialisation fails
503 ///
504 /// # Example
505 ///
506 /// ```no_run
507 /// use opentelemetry_configuration::{OtelSdkBuilder, SdkError};
508 ///
509 /// fn main() -> Result<(), SdkError> {
510 /// let _guard = OtelSdkBuilder::new()
511 /// .with_env("OTEL_")
512 /// .service_name("my-lambda")
513 /// .build()?;
514 ///
515 /// tracing::info!("Application started");
516 ///
517 /// // Guard automatically shuts down providers on drop
518 /// Ok(())
519 /// }
520 /// ```
521 pub fn build(self) -> Result<OtelGuard, SdkError> {
522 let mut config: OtelSdkConfig = self
523 .figment
524 .extract()
525 .map_err(|e| SdkError::Config(Box::new(e)))?;
526
527 // Merge resource attributes that couldn't go through figment
528 config.resource.attributes.extend(self.resource_attributes);
529
530 // Detect Lambda resource attributes from environment
531 config.resource.detect_from_environment();
532
533 OtelGuard::from_config(config, self.fallback, self.custom_resource)
534 }
535}
536
537impl Default for OtelSdkBuilder {
538 fn default() -> Self {
539 Self::new()
540 }
541}
542
543/// Builder for resource configuration.
544///
545/// Used with [`OtelSdkBuilder::resource`] for fluent configuration.
546#[derive(Default)]
547#[must_use = "builders do nothing unless .build() is called"]
548pub struct ResourceConfigBuilder {
549 config: ResourceConfig,
550}
551
552impl ResourceConfigBuilder {
553 /// Creates a new resource config builder.
554 pub fn new() -> Self {
555 Self::default()
556 }
557
558 /// Sets the service name.
559 pub fn service_name(mut self, name: impl Into<String>) -> Self {
560 self.config.service_name = Some(name.into());
561 self
562 }
563
564 /// Sets the service version.
565 pub fn service_version(mut self, version: impl Into<String>) -> Self {
566 self.config.service_version = Some(version.into());
567 self
568 }
569
570 /// Sets the deployment environment.
571 pub fn deployment_environment(mut self, env: impl Into<String>) -> Self {
572 self.config.deployment_environment = Some(env.into());
573 self
574 }
575
576 /// Adds a resource attribute.
577 pub fn attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
578 self.config.attributes.insert(key.into(), value.into());
579 self
580 }
581
582 /// Disables automatic Lambda resource detection.
583 pub fn without_lambda_detection(mut self) -> Self {
584 self.config.detect_lambda = false;
585 self
586 }
587
588 /// Builds the resource configuration.
589 pub fn build(self) -> ResourceConfig {
590 self.config
591 }
592}
593
594#[cfg(test)]
595mod tests {
596 use super::*;
597
598 #[test]
599 fn test_builder_default() {
600 let builder = OtelSdkBuilder::new();
601 let config = builder.extract_config().unwrap();
602
603 assert!(config.traces.enabled);
604 assert!(config.metrics.enabled);
605 assert!(config.logs.enabled);
606 assert!(config.init_tracing_subscriber);
607 assert_eq!(config.endpoint.protocol, Protocol::HttpBinary);
608 }
609
610 #[test]
611 fn test_builder_endpoint() {
612 let builder = OtelSdkBuilder::new().endpoint("http://collector:4318");
613 let config = builder.extract_config().unwrap();
614
615 assert_eq!(
616 config.endpoint.url,
617 Some("http://collector:4318".to_string())
618 );
619 }
620
621 #[test]
622 fn test_builder_protocol() {
623 let builder = OtelSdkBuilder::new().protocol(Protocol::Grpc);
624 let config = builder.extract_config().unwrap();
625
626 assert_eq!(config.endpoint.protocol, Protocol::Grpc);
627 }
628
629 #[test]
630 fn test_builder_service_name() {
631 let builder = OtelSdkBuilder::new().service_name("my-service");
632 let config = builder.extract_config().unwrap();
633
634 assert_eq!(config.resource.service_name, Some("my-service".to_string()));
635 }
636
637 #[test]
638 fn test_builder_disable_signals() {
639 let builder = OtelSdkBuilder::new()
640 .traces(false)
641 .metrics(false)
642 .logs(false);
643 let config = builder.extract_config().unwrap();
644
645 assert!(!config.traces.enabled);
646 assert!(!config.metrics.enabled);
647 assert!(!config.logs.enabled);
648 }
649
650 #[test]
651 fn test_builder_resource_fluent() {
652 let builder = OtelSdkBuilder::new().resource(|r| {
653 r.service_name("my-service")
654 .service_version("1.0.0")
655 .deployment_environment("production")
656 .attribute("custom.key", "custom.value")
657 });
658 let config = builder.extract_config().unwrap();
659
660 assert_eq!(config.resource.service_name, Some("my-service".to_string()));
661 assert_eq!(config.resource.service_version, Some("1.0.0".to_string()));
662 assert_eq!(
663 config.resource.deployment_environment,
664 Some("production".to_string())
665 );
666 assert_eq!(
667 config.resource.attributes.get("custom.key"),
668 Some(&"custom.value".to_string())
669 );
670 }
671
672 #[test]
673 fn test_builder_without_tracing_subscriber() {
674 let builder = OtelSdkBuilder::new().without_tracing_subscriber();
675 let config = builder.extract_config().unwrap();
676
677 assert!(!config.init_tracing_subscriber);
678 }
679
680 #[test]
681 fn test_builder_header() {
682 let builder = OtelSdkBuilder::new().header("Authorization", "Bearer token123");
683 let config = builder.extract_config().unwrap();
684
685 assert_eq!(
686 config.endpoint.headers.get("Authorization"),
687 Some(&"Bearer token123".to_string())
688 );
689 }
690
691 #[test]
692 fn test_builder_fallback() {
693 let builder = OtelSdkBuilder::new().fallback(ExportFallback::Stdout);
694 assert!(matches!(builder.fallback, ExportFallback::Stdout));
695 }
696
697 #[test]
698 fn test_builder_custom_fallback() {
699 let builder = OtelSdkBuilder::new().with_fallback(|_failure| Ok(()));
700 assert!(matches!(builder.fallback, ExportFallback::Custom(_)));
701 }
702}