opentelemetry_configuration/
config.rs

1//! Configuration types for the OpenTelemetry SDK.
2//!
3//! These types are designed to be deserialised from multiple sources using
4//! figment, supporting layered configuration from defaults, files, and
5//! environment variables.
6
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::time::Duration;
10
11/// OTLP export protocol.
12#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "lowercase")]
14pub enum Protocol {
15    /// gRPC protocol (default port 4317).
16    Grpc,
17    /// HTTP with Protocol Buffers encoding (default port 4318).
18    #[default]
19    #[serde(alias = "http_binary", alias = "http-binary")]
20    HttpBinary,
21    /// HTTP with JSON encoding (default port 4318).
22    #[serde(alias = "http_json", alias = "http-json")]
23    HttpJson,
24}
25
26impl Protocol {
27    /// Returns the default endpoint for this protocol.
28    pub fn default_endpoint(&self) -> &'static str {
29        match self {
30            Protocol::Grpc => "http://localhost:4317",
31            Protocol::HttpBinary | Protocol::HttpJson => "http://localhost:4318",
32        }
33    }
34
35    /// Returns the default port for this protocol.
36    pub fn default_port(&self) -> u16 {
37        match self {
38            Protocol::Grpc => 4317,
39            Protocol::HttpBinary | Protocol::HttpJson => 4318,
40        }
41    }
42}
43
44/// Complete OpenTelemetry SDK configuration.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46#[serde(default)]
47pub struct OtelSdkConfig {
48    /// Endpoint configuration.
49    pub endpoint: EndpointConfig,
50
51    /// Resource configuration.
52    pub resource: ResourceConfig,
53
54    /// Traces configuration.
55    pub traces: SignalConfig,
56
57    /// Metrics configuration.
58    pub metrics: SignalConfig,
59
60    /// Logs configuration.
61    pub logs: SignalConfig,
62
63    /// Whether to initialise the tracing subscriber.
64    pub init_tracing_subscriber: bool,
65}
66
67impl Default for OtelSdkConfig {
68    fn default() -> Self {
69        Self {
70            endpoint: EndpointConfig::default(),
71            resource: ResourceConfig::default(),
72            traces: SignalConfig::default_enabled(),
73            metrics: SignalConfig::default_enabled(),
74            logs: SignalConfig::default_enabled(),
75            init_tracing_subscriber: true,
76        }
77    }
78}
79
80impl OtelSdkConfig {
81    /// Returns the effective endpoint URL, using protocol defaults if not specified.
82    pub fn effective_endpoint(&self) -> String {
83        self.endpoint
84            .url
85            .clone()
86            .unwrap_or_else(|| self.endpoint.protocol.default_endpoint().to_string())
87    }
88
89    /// Returns the endpoint URL for a specific signal type.
90    pub fn signal_endpoint(&self, signal_path: &str) -> String {
91        let base = self.effective_endpoint();
92        let base = base.trim_end_matches('/');
93
94        match self.endpoint.protocol {
95            Protocol::Grpc => base.to_string(),
96            Protocol::HttpBinary | Protocol::HttpJson => {
97                format!("{}{}", base, signal_path)
98            }
99        }
100    }
101
102    /// Merges another config into this one, with `other` taking precedence.
103    pub fn merge(mut self, other: Self) -> Self {
104        self.endpoint = self.endpoint.merge(other.endpoint);
105        self.resource = self.resource.merge(other.resource);
106        self.traces = self.traces.merge(other.traces);
107        self.metrics = self.metrics.merge(other.metrics);
108        self.logs = self.logs.merge(other.logs);
109
110        if other.init_tracing_subscriber != Self::default().init_tracing_subscriber {
111            self.init_tracing_subscriber = other.init_tracing_subscriber;
112        }
113
114        self
115    }
116}
117
118/// Endpoint configuration.
119#[derive(Debug, Clone, Serialize, Deserialize)]
120#[serde(default)]
121pub struct EndpointConfig {
122    /// OTLP endpoint URL.
123    ///
124    /// If not specified, uses the protocol's default:
125    /// - gRPC: `http://localhost:4317`
126    /// - HTTP: `http://localhost:4318`
127    pub url: Option<String>,
128
129    /// Export protocol.
130    pub protocol: Protocol,
131
132    /// Request timeout.
133    #[serde(with = "humantime_serde")]
134    pub timeout: Duration,
135
136    /// HTTP headers for authentication or customisation.
137    #[serde(default)]
138    pub headers: HashMap<String, String>,
139}
140
141impl Default for EndpointConfig {
142    fn default() -> Self {
143        Self {
144            url: None,
145            protocol: Protocol::default(),
146            timeout: Duration::from_secs(10),
147            headers: HashMap::new(),
148        }
149    }
150}
151
152impl EndpointConfig {
153    /// Merges another config into this one, with `other` taking precedence.
154    pub fn merge(mut self, other: Self) -> Self {
155        if other.url.is_some() {
156            self.url = other.url;
157        }
158        if other.protocol != Protocol::default() {
159            self.protocol = other.protocol;
160        }
161        if other.timeout != Self::default().timeout {
162            self.timeout = other.timeout;
163        }
164        self.headers.extend(other.headers);
165        self
166    }
167}
168
169/// Resource configuration.
170#[derive(Debug, Clone, Default, Serialize, Deserialize)]
171#[serde(default)]
172pub struct ResourceConfig {
173    /// Service name.
174    ///
175    /// If not specified, attempts to detect from `AWS_LAMBDA_FUNCTION_NAME`.
176    pub service_name: Option<String>,
177
178    /// Service version.
179    pub service_version: Option<String>,
180
181    /// Deployment environment (e.g., "production", "staging").
182    pub deployment_environment: Option<String>,
183
184    /// Additional resource attributes.
185    #[serde(default)]
186    pub attributes: HashMap<String, String>,
187
188    /// Whether to detect Lambda resource attributes from environment.
189    #[serde(default = "default_true")]
190    pub detect_lambda: bool,
191}
192
193fn default_true() -> bool {
194    true
195}
196
197impl ResourceConfig {
198    /// Creates a new resource config with a service name.
199    pub fn with_service_name(name: impl Into<String>) -> Self {
200        Self {
201            service_name: Some(name.into()),
202            ..Default::default()
203        }
204    }
205
206    /// Merges another config into this one, with `other` taking precedence.
207    pub fn merge(mut self, other: Self) -> Self {
208        if other.service_name.is_some() {
209            self.service_name = other.service_name;
210        }
211        if other.service_version.is_some() {
212            self.service_version = other.service_version;
213        }
214        if other.deployment_environment.is_some() {
215            self.deployment_environment = other.deployment_environment;
216        }
217        self.attributes.extend(other.attributes);
218        if !other.detect_lambda {
219            self.detect_lambda = false;
220        }
221        self
222    }
223
224    /// Detects resource attributes from the Lambda environment.
225    pub fn detect_from_environment(&mut self) {
226        if !self.detect_lambda {
227            return;
228        }
229
230        if self.service_name.is_none() {
231            self.service_name = std::env::var("AWS_LAMBDA_FUNCTION_NAME").ok();
232        }
233
234        if self.service_version.is_none() {
235            self.service_version = std::env::var("AWS_LAMBDA_FUNCTION_VERSION").ok();
236        }
237
238        if let Ok(region) = std::env::var("AWS_REGION") {
239            self.attributes
240                .entry("cloud.region".to_string())
241                .or_insert(region);
242        }
243
244        if let Ok(memory) = std::env::var("AWS_LAMBDA_FUNCTION_MEMORY_SIZE") {
245            self.attributes
246                .entry("faas.max_memory".to_string())
247                .or_insert(memory);
248        }
249
250        self.attributes
251            .entry("cloud.provider".to_string())
252            .or_insert_with(|| "aws".to_string());
253
254        self.attributes
255            .entry("faas.instance".to_string())
256            .or_insert_with(|| std::env::var("AWS_LAMBDA_LOG_STREAM_NAME").unwrap_or_default());
257    }
258}
259
260/// Configuration for an individual signal type (traces, metrics, logs).
261#[derive(Debug, Clone, Default, Serialize, Deserialize)]
262#[serde(default)]
263pub struct SignalConfig {
264    /// Whether this signal is enabled.
265    pub enabled: bool,
266
267    /// Batch export configuration.
268    pub batch: BatchConfig,
269}
270
271impl SignalConfig {
272    /// Creates a default config with the signal enabled.
273    pub fn default_enabled() -> Self {
274        Self {
275            enabled: true,
276            batch: BatchConfig::default(),
277        }
278    }
279
280    /// Merges another config into this one, with `other` taking precedence.
281    pub fn merge(mut self, other: Self) -> Self {
282        self.enabled = other.enabled;
283        self.batch = self.batch.merge(other.batch);
284        self
285    }
286}
287
288/// Batch exporter configuration.
289#[derive(Debug, Clone, Serialize, Deserialize)]
290#[serde(default)]
291pub struct BatchConfig {
292    /// Maximum queue size.
293    pub max_queue_size: usize,
294
295    /// Maximum batch size for export.
296    pub max_export_batch_size: usize,
297
298    /// Scheduled delay between exports.
299    #[serde(with = "humantime_serde")]
300    pub scheduled_delay: Duration,
301
302    /// Maximum time to wait for export to complete.
303    #[serde(with = "humantime_serde")]
304    pub export_timeout: Duration,
305}
306
307impl Default for BatchConfig {
308    fn default() -> Self {
309        Self {
310            max_queue_size: 2048,
311            max_export_batch_size: 512,
312            scheduled_delay: Duration::from_secs(5),
313            export_timeout: Duration::from_secs(30),
314        }
315    }
316}
317
318impl BatchConfig {
319    /// Merges another config into this one, with `other` taking precedence.
320    pub fn merge(mut self, other: Self) -> Self {
321        let default = Self::default();
322        if other.max_queue_size != default.max_queue_size {
323            self.max_queue_size = other.max_queue_size;
324        }
325        if other.max_export_batch_size != default.max_export_batch_size {
326            self.max_export_batch_size = other.max_export_batch_size;
327        }
328        if other.scheduled_delay != default.scheduled_delay {
329            self.scheduled_delay = other.scheduled_delay;
330        }
331        if other.export_timeout != default.export_timeout {
332            self.export_timeout = other.export_timeout;
333        }
334        self
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    #[test]
343    fn test_protocol_default() {
344        assert_eq!(Protocol::default(), Protocol::HttpBinary);
345    }
346
347    #[test]
348    fn test_protocol_default_endpoint() {
349        assert_eq!(Protocol::Grpc.default_endpoint(), "http://localhost:4317");
350        assert_eq!(
351            Protocol::HttpBinary.default_endpoint(),
352            "http://localhost:4318"
353        );
354        assert_eq!(
355            Protocol::HttpJson.default_endpoint(),
356            "http://localhost:4318"
357        );
358    }
359
360    #[test]
361    fn test_protocol_serde() {
362        let protocol: Protocol = serde_json::from_str(r#""grpc""#).unwrap();
363        assert_eq!(protocol, Protocol::Grpc);
364
365        let protocol: Protocol = serde_json::from_str(r#""httpbinary""#).unwrap();
366        assert_eq!(protocol, Protocol::HttpBinary);
367
368        let protocol: Protocol = serde_json::from_str(r#""http_binary""#).unwrap();
369        assert_eq!(protocol, Protocol::HttpBinary);
370
371        let protocol: Protocol = serde_json::from_str(r#""http-json""#).unwrap();
372        assert_eq!(protocol, Protocol::HttpJson);
373    }
374
375    #[test]
376    fn test_otel_sdk_config_effective_endpoint() {
377        let config = OtelSdkConfig::default();
378        assert_eq!(config.effective_endpoint(), "http://localhost:4318");
379
380        let mut config = OtelSdkConfig::default();
381        config.endpoint.protocol = Protocol::Grpc;
382        assert_eq!(config.effective_endpoint(), "http://localhost:4317");
383
384        let mut config = OtelSdkConfig::default();
385        config.endpoint.url = Some("http://collector:4318".to_string());
386        assert_eq!(config.effective_endpoint(), "http://collector:4318");
387    }
388
389    #[test]
390    fn test_otel_sdk_config_signal_endpoint() {
391        let config = OtelSdkConfig::default();
392        assert_eq!(
393            config.signal_endpoint("/v1/traces"),
394            "http://localhost:4318/v1/traces"
395        );
396
397        let mut config = OtelSdkConfig::default();
398        config.endpoint.protocol = Protocol::Grpc;
399        assert_eq!(
400            config.signal_endpoint("/v1/traces"),
401            "http://localhost:4317"
402        );
403    }
404
405    #[test]
406    fn test_resource_config_with_service_name() {
407        let config = ResourceConfig::with_service_name("my-service");
408        assert_eq!(config.service_name, Some("my-service".to_string()));
409    }
410
411    #[test]
412    fn test_resource_config_merge() {
413        let base = ResourceConfig {
414            service_name: Some("base".to_string()),
415            service_version: Some("1.0.0".to_string()),
416            attributes: [("key1".to_string(), "value1".to_string())]
417                .into_iter()
418                .collect(),
419            ..Default::default()
420        };
421
422        let override_config = ResourceConfig {
423            service_name: Some("override".to_string()),
424            attributes: [("key2".to_string(), "value2".to_string())]
425                .into_iter()
426                .collect(),
427            ..Default::default()
428        };
429
430        let merged = base.merge(override_config);
431        assert_eq!(merged.service_name, Some("override".to_string()));
432        assert_eq!(merged.service_version, Some("1.0.0".to_string()));
433        assert_eq!(merged.attributes.get("key1"), Some(&"value1".to_string()));
434        assert_eq!(merged.attributes.get("key2"), Some(&"value2".to_string()));
435    }
436
437    #[test]
438    fn test_signal_config_default() {
439        let config = SignalConfig::default();
440        assert!(!config.enabled);
441
442        let config = SignalConfig::default_enabled();
443        assert!(config.enabled);
444    }
445
446    #[test]
447    fn test_batch_config_defaults() {
448        let config = BatchConfig::default();
449        assert_eq!(config.max_queue_size, 2048);
450        assert_eq!(config.max_export_batch_size, 512);
451        assert_eq!(config.scheduled_delay, Duration::from_secs(5));
452        assert_eq!(config.export_timeout, Duration::from_secs(30));
453    }
454
455    #[test]
456    fn test_endpoint_config_merge() {
457        let base = EndpointConfig {
458            url: Some("http://base:4318".to_string()),
459            headers: [("auth".to_string(), "token1".to_string())]
460                .into_iter()
461                .collect(),
462            ..Default::default()
463        };
464
465        let override_config = EndpointConfig {
466            url: Some("http://override:4318".to_string()),
467            headers: [("x-custom".to_string(), "value".to_string())]
468                .into_iter()
469                .collect(),
470            ..Default::default()
471        };
472
473        let merged = base.merge(override_config);
474        assert_eq!(merged.url, Some("http://override:4318".to_string()));
475        assert_eq!(merged.headers.get("auth"), Some(&"token1".to_string()));
476        assert_eq!(merged.headers.get("x-custom"), Some(&"value".to_string()));
477    }
478}