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/// Compute environment for resource attribute detection.
12#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "lowercase")]
14pub enum ComputeEnvironment {
15    /// Automatically detect the compute environment.
16    /// Runs generic detectors (host, OS, process) and probes for Lambda/K8s.
17    #[default]
18    Auto,
19    /// AWS Lambda - generic detectors + Lambda-specific attributes (faas.*)
20    Lambda,
21    /// Kubernetes - generic detectors + K8s-specific attributes
22    Kubernetes,
23    /// No automatic detection - only use explicitly configured attributes.
24    None,
25}
26
27/// OTLP export protocol.
28#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "lowercase")]
30pub enum Protocol {
31    /// gRPC protocol (default port 4317).
32    Grpc,
33    /// HTTP with Protocol Buffers encoding (default port 4318).
34    #[default]
35    #[serde(alias = "http_binary", alias = "http-binary")]
36    HttpBinary,
37    /// HTTP with JSON encoding (default port 4318).
38    #[serde(alias = "http_json", alias = "http-json")]
39    HttpJson,
40}
41
42impl Protocol {
43    /// Returns the default endpoint for this protocol.
44    pub fn default_endpoint(&self) -> &'static str {
45        match self {
46            Protocol::Grpc => "http://localhost:4317",
47            Protocol::HttpBinary | Protocol::HttpJson => "http://localhost:4318",
48        }
49    }
50
51    /// Returns the default port for this protocol.
52    pub fn default_port(&self) -> u16 {
53        match self {
54            Protocol::Grpc => 4317,
55            Protocol::HttpBinary | Protocol::HttpJson => 4318,
56        }
57    }
58}
59
60/// Complete OpenTelemetry SDK configuration.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62#[serde(default)]
63pub struct OtelSdkConfig {
64    /// Endpoint configuration.
65    pub endpoint: EndpointConfig,
66
67    /// Resource configuration.
68    pub resource: ResourceConfig,
69
70    /// Traces configuration.
71    pub traces: SignalConfig,
72
73    /// Metrics configuration.
74    pub metrics: SignalConfig,
75
76    /// Logs configuration.
77    pub logs: SignalConfig,
78
79    /// Whether to initialise the tracing subscriber.
80    pub init_tracing_subscriber: bool,
81
82    /// Name for the instrumentation scope (otel.library.name).
83    /// Defaults to service_name if set, otherwise "opentelemetry-configuration".
84    pub instrumentation_scope_name: Option<String>,
85}
86
87impl Default for OtelSdkConfig {
88    fn default() -> Self {
89        Self {
90            endpoint: EndpointConfig::default(),
91            resource: ResourceConfig::default(),
92            traces: SignalConfig::default_enabled(),
93            metrics: SignalConfig::default_enabled(),
94            logs: SignalConfig::default_enabled(),
95            init_tracing_subscriber: true,
96            instrumentation_scope_name: None,
97        }
98    }
99}
100
101impl OtelSdkConfig {
102    /// Returns the effective endpoint URL, using protocol defaults if not specified.
103    pub fn effective_endpoint(&self) -> String {
104        self.endpoint
105            .url
106            .clone()
107            .unwrap_or_else(|| self.endpoint.protocol.default_endpoint().to_string())
108    }
109
110    /// Returns the endpoint URL for a specific signal type.
111    pub fn signal_endpoint(&self, signal_path: &str) -> String {
112        let base = self.effective_endpoint();
113        let base = base.trim_end_matches('/');
114
115        match self.endpoint.protocol {
116            Protocol::Grpc => base.to_string(),
117            Protocol::HttpBinary | Protocol::HttpJson => {
118                format!("{}{}", base, signal_path)
119            }
120        }
121    }
122
123    /// Merges another config into this one, with `other` taking precedence.
124    pub fn merge(mut self, other: Self) -> Self {
125        self.endpoint = self.endpoint.merge(other.endpoint);
126        self.resource = self.resource.merge(other.resource);
127        self.traces = self.traces.merge(other.traces);
128        self.metrics = self.metrics.merge(other.metrics);
129        self.logs = self.logs.merge(other.logs);
130
131        if other.init_tracing_subscriber != Self::default().init_tracing_subscriber {
132            self.init_tracing_subscriber = other.init_tracing_subscriber;
133        }
134
135        self
136    }
137}
138
139/// Endpoint configuration.
140#[derive(Debug, Clone, Serialize, Deserialize)]
141#[serde(default)]
142pub struct EndpointConfig {
143    /// OTLP endpoint URL.
144    ///
145    /// If not specified, uses the protocol's default:
146    /// - gRPC: `http://localhost:4317`
147    /// - HTTP: `http://localhost:4318`
148    pub url: Option<String>,
149
150    /// Export protocol.
151    pub protocol: Protocol,
152
153    /// Request timeout.
154    #[serde(with = "humantime_serde")]
155    pub timeout: Duration,
156
157    /// HTTP headers for authentication or customisation.
158    #[serde(default)]
159    pub headers: HashMap<String, String>,
160}
161
162impl Default for EndpointConfig {
163    fn default() -> Self {
164        Self {
165            url: None,
166            protocol: Protocol::default(),
167            timeout: Duration::from_secs(10),
168            headers: HashMap::new(),
169        }
170    }
171}
172
173impl EndpointConfig {
174    /// Merges another config into this one, with `other` taking precedence.
175    pub fn merge(mut self, other: Self) -> Self {
176        if other.url.is_some() {
177            self.url = other.url;
178        }
179        if other.protocol != Protocol::default() {
180            self.protocol = other.protocol;
181        }
182        if other.timeout != Self::default().timeout {
183            self.timeout = other.timeout;
184        }
185        self.headers.extend(other.headers);
186        self
187    }
188}
189
190/// Resource configuration.
191#[derive(Debug, Clone, Default, Serialize, Deserialize)]
192#[serde(default)]
193pub struct ResourceConfig {
194    /// Service name.
195    pub service_name: Option<String>,
196
197    /// Service version.
198    pub service_version: Option<String>,
199
200    /// Deployment environment (e.g., "production", "staging").
201    pub deployment_environment: Option<String>,
202
203    /// Additional resource attributes.
204    #[serde(default)]
205    pub attributes: HashMap<String, String>,
206
207    /// Compute environment for automatic resource detection.
208    #[serde(default)]
209    pub compute_environment: ComputeEnvironment,
210}
211
212impl ResourceConfig {
213    /// Creates a new resource config with a service name.
214    pub fn with_service_name(name: impl Into<String>) -> Self {
215        Self {
216            service_name: Some(name.into()),
217            ..Default::default()
218        }
219    }
220
221    /// Merges another config into this one, with `other` taking precedence.
222    pub fn merge(mut self, other: Self) -> Self {
223        if other.service_name.is_some() {
224            self.service_name = other.service_name;
225        }
226        if other.service_version.is_some() {
227            self.service_version = other.service_version;
228        }
229        if other.deployment_environment.is_some() {
230            self.deployment_environment = other.deployment_environment;
231        }
232        self.attributes.extend(other.attributes);
233        if other.compute_environment != ComputeEnvironment::default() {
234            self.compute_environment = other.compute_environment;
235        }
236        self
237    }
238}
239
240/// Configuration for an individual signal type (traces, metrics, logs).
241#[derive(Debug, Clone, Default, Serialize, Deserialize)]
242#[serde(default)]
243pub struct SignalConfig {
244    /// Whether this signal is enabled.
245    pub enabled: bool,
246
247    /// Batch export configuration.
248    pub batch: BatchConfig,
249}
250
251impl SignalConfig {
252    /// Creates a default config with the signal enabled.
253    pub fn default_enabled() -> Self {
254        Self {
255            enabled: true,
256            batch: BatchConfig::default(),
257        }
258    }
259
260    /// Merges another config into this one, with `other` taking precedence.
261    pub fn merge(mut self, other: Self) -> Self {
262        self.enabled = other.enabled;
263        self.batch = self.batch.merge(other.batch);
264        self
265    }
266}
267
268/// Batch exporter configuration.
269#[derive(Debug, Clone, Serialize, Deserialize)]
270#[serde(default)]
271pub struct BatchConfig {
272    /// Maximum queue size.
273    pub max_queue_size: usize,
274
275    /// Maximum batch size for export.
276    pub max_export_batch_size: usize,
277
278    /// Scheduled delay between exports.
279    #[serde(with = "humantime_serde")]
280    pub scheduled_delay: Duration,
281
282    /// Maximum time to wait for export to complete.
283    #[serde(with = "humantime_serde")]
284    pub export_timeout: Duration,
285}
286
287impl Default for BatchConfig {
288    fn default() -> Self {
289        Self {
290            max_queue_size: 2048,
291            max_export_batch_size: 512,
292            scheduled_delay: Duration::from_secs(5),
293            export_timeout: Duration::from_secs(30),
294        }
295    }
296}
297
298impl BatchConfig {
299    /// Merges another config into this one, with `other` taking precedence.
300    pub fn merge(mut self, other: Self) -> Self {
301        let default = Self::default();
302        if other.max_queue_size != default.max_queue_size {
303            self.max_queue_size = other.max_queue_size;
304        }
305        if other.max_export_batch_size != default.max_export_batch_size {
306            self.max_export_batch_size = other.max_export_batch_size;
307        }
308        if other.scheduled_delay != default.scheduled_delay {
309            self.scheduled_delay = other.scheduled_delay;
310        }
311        if other.export_timeout != default.export_timeout {
312            self.export_timeout = other.export_timeout;
313        }
314        self
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn test_compute_environment_default() {
324        assert_eq!(ComputeEnvironment::default(), ComputeEnvironment::Auto);
325    }
326
327    #[test]
328    fn test_compute_environment_serde() {
329        let env: ComputeEnvironment = serde_json::from_str(r#""auto""#).unwrap();
330        assert_eq!(env, ComputeEnvironment::Auto);
331
332        let env: ComputeEnvironment = serde_json::from_str(r#""lambda""#).unwrap();
333        assert_eq!(env, ComputeEnvironment::Lambda);
334
335        let env: ComputeEnvironment = serde_json::from_str(r#""kubernetes""#).unwrap();
336        assert_eq!(env, ComputeEnvironment::Kubernetes);
337
338        let env: ComputeEnvironment = serde_json::from_str(r#""none""#).unwrap();
339        assert_eq!(env, ComputeEnvironment::None);
340    }
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}