Skip to main content

provide_telemetry/
config.rs

1// SPDX-FileCopyrightText: Copyright (C) 2026 provide.io llc
2// SPDX-License-Identifier: Apache-2.0
3// SPDX-Comment: Part of provide-telemetry.
4//
5
6use std::collections::HashMap;
7use std::env;
8
9use percent_encoding::percent_decode_str;
10use serde::{Deserialize, Serialize};
11
12use crate::errors::ConfigurationError;
13
14#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
15pub struct RuntimeOverrides {
16    pub sampling: Option<SamplingConfig>,
17    pub backpressure: Option<BackpressureConfig>,
18    pub exporter: Option<ExporterPolicyConfig>,
19    pub security: Option<SecurityConfig>,
20    pub slo: Option<SLOConfig>,
21    pub pii_max_depth: Option<usize>,
22    pub strict_schema: Option<bool>,
23}
24
25#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
26pub struct LoggingConfig {
27    pub level: String,
28    pub fmt: String,
29    pub otlp_headers: HashMap<String, String>,
30}
31
32impl Default for LoggingConfig {
33    fn default() -> Self {
34        Self {
35            level: "INFO".to_string(),
36            fmt: "console".to_string(),
37            otlp_headers: HashMap::new(),
38        }
39    }
40}
41
42#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
43pub struct TracingConfig {
44    pub enabled: bool,
45    pub otlp_headers: HashMap<String, String>,
46}
47
48impl Default for TracingConfig {
49    fn default() -> Self {
50        Self {
51            enabled: true,
52            otlp_headers: HashMap::new(),
53        }
54    }
55}
56
57#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
58pub struct MetricsConfig {
59    pub enabled: bool,
60    pub otlp_headers: HashMap<String, String>,
61}
62
63impl Default for MetricsConfig {
64    fn default() -> Self {
65        Self {
66            enabled: true,
67            otlp_headers: HashMap::new(),
68        }
69    }
70}
71
72#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
73pub struct SchemaConfig {
74    pub required_keys: Vec<String>,
75}
76
77#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
78pub struct SamplingConfig {
79    pub logs_rate: f64,
80    pub traces_rate: f64,
81    pub metrics_rate: f64,
82}
83
84impl Default for SamplingConfig {
85    fn default() -> Self {
86        Self {
87            logs_rate: 1.0,
88            traces_rate: 1.0,
89            metrics_rate: 1.0,
90        }
91    }
92}
93
94#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
95pub struct BackpressureConfig {
96    pub logs_maxsize: usize,
97    pub traces_maxsize: usize,
98    pub metrics_maxsize: usize,
99}
100
101#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
102pub struct ExporterPolicyConfig {
103    pub logs_retries: usize,
104    pub traces_retries: usize,
105    pub metrics_retries: usize,
106    pub logs_backoff_seconds: f64,
107    pub traces_backoff_seconds: f64,
108    pub metrics_backoff_seconds: f64,
109    pub logs_timeout_seconds: f64,
110    pub traces_timeout_seconds: f64,
111    pub metrics_timeout_seconds: f64,
112    pub logs_fail_open: bool,
113    pub traces_fail_open: bool,
114    pub metrics_fail_open: bool,
115}
116
117impl Default for ExporterPolicyConfig {
118    fn default() -> Self {
119        Self {
120            logs_retries: 0,
121            traces_retries: 0,
122            metrics_retries: 0,
123            logs_backoff_seconds: 0.0,
124            traces_backoff_seconds: 0.0,
125            metrics_backoff_seconds: 0.0,
126            logs_timeout_seconds: 10.0,
127            traces_timeout_seconds: 10.0,
128            metrics_timeout_seconds: 10.0,
129            logs_fail_open: true,
130            traces_fail_open: true,
131            metrics_fail_open: true,
132        }
133    }
134}
135
136#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
137pub struct SLOConfig {
138    pub enable_red_metrics: bool,
139    pub enable_use_metrics: bool,
140}
141
142#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
143pub struct SecurityConfig {
144    pub max_attr_value_length: usize,
145    pub max_attr_count: usize,
146}
147
148impl Default for SecurityConfig {
149    fn default() -> Self {
150        Self {
151            max_attr_value_length: 1024,
152            max_attr_count: 64,
153        }
154    }
155}
156
157#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
158pub struct TelemetryConfig {
159    pub service_name: String,
160    pub environment: String,
161    pub version: String,
162    pub strict_schema: bool,
163    pub pii_max_depth: usize,
164    pub logging: LoggingConfig,
165    pub tracing: TracingConfig,
166    pub metrics: MetricsConfig,
167    pub event_schema: SchemaConfig,
168    pub sampling: SamplingConfig,
169    pub backpressure: BackpressureConfig,
170    pub exporter: ExporterPolicyConfig,
171    pub slo: SLOConfig,
172    pub security: SecurityConfig,
173}
174
175impl Default for TelemetryConfig {
176    fn default() -> Self {
177        Self {
178            service_name: "provide-service".to_string(),
179            environment: "dev".to_string(),
180            version: "0.0.0".to_string(),
181            strict_schema: false,
182            pii_max_depth: 8,
183            logging: LoggingConfig::default(),
184            tracing: TracingConfig::default(),
185            metrics: MetricsConfig::default(),
186            event_schema: SchemaConfig::default(),
187            sampling: SamplingConfig::default(),
188            backpressure: BackpressureConfig::default(),
189            exporter: ExporterPolicyConfig::default(),
190            slo: SLOConfig::default(),
191            security: SecurityConfig::default(),
192        }
193    }
194}
195
196impl TelemetryConfig {
197    pub fn from_env() -> Result<Self, ConfigurationError> {
198        let env_map = env::vars().collect::<HashMap<_, _>>();
199        Self::from_map(&env_map)
200    }
201
202    pub fn from_map(env: &HashMap<String, String>) -> Result<Self, ConfigurationError> {
203        let shared_headers = parse_otlp_headers(env_value(env, &["OTEL_EXPORTER_OTLP_HEADERS"]))?
204            .unwrap_or_default();
205
206        Ok(Self {
207            service_name: env_value(env, &["PROVIDE_TELEMETRY_SERVICE_NAME"])
208                .unwrap_or("provide-service")
209                .to_string(),
210            environment: env_value(env, &["PROVIDE_TELEMETRY_ENV", "PROVIDE_ENV"])
211                .unwrap_or("dev")
212                .to_string(),
213            version: env_value(env, &["PROVIDE_TELEMETRY_VERSION", "PROVIDE_VERSION"])
214                .unwrap_or("0.0.0")
215                .to_string(),
216            strict_schema: parse_bool(
217                env_value(env, &["PROVIDE_TELEMETRY_STRICT_SCHEMA"]),
218                false,
219                "PROVIDE_TELEMETRY_STRICT_SCHEMA",
220            )?,
221            pii_max_depth: parse_usize(
222                env_value(env, &["PROVIDE_LOG_PII_MAX_DEPTH"]),
223                8,
224                "PROVIDE_LOG_PII_MAX_DEPTH",
225            )?,
226            logging: LoggingConfig {
227                level: env_value(env, &["PROVIDE_LOG_LEVEL"])
228                    .unwrap_or("INFO")
229                    .to_string(),
230                fmt: env_value(env, &["PROVIDE_LOG_FORMAT"])
231                    .unwrap_or("console")
232                    .to_string(),
233                otlp_headers: parse_otlp_headers(env_value(
234                    env,
235                    &["OTEL_EXPORTER_OTLP_LOGS_HEADERS"],
236                ))?
237                .unwrap_or_else(|| shared_headers.clone()),
238            },
239            tracing: TracingConfig {
240                enabled: parse_bool(
241                    env_value(env, &["PROVIDE_TRACE_ENABLED"]),
242                    true,
243                    "PROVIDE_TRACE_ENABLED",
244                )?,
245                otlp_headers: parse_otlp_headers(env_value(
246                    env,
247                    &["OTEL_EXPORTER_OTLP_TRACES_HEADERS"],
248                ))?
249                .unwrap_or_else(|| shared_headers.clone()),
250            },
251            metrics: MetricsConfig {
252                enabled: parse_bool(
253                    env_value(env, &["PROVIDE_METRICS_ENABLED"]),
254                    true,
255                    "PROVIDE_METRICS_ENABLED",
256                )?,
257                otlp_headers: parse_otlp_headers(env_value(
258                    env,
259                    &["OTEL_EXPORTER_OTLP_METRICS_HEADERS"],
260                ))?
261                .unwrap_or(shared_headers),
262            },
263            event_schema: SchemaConfig::default(),
264            sampling: SamplingConfig {
265                logs_rate: parse_rate(
266                    env_value(env, &["PROVIDE_SAMPLING_LOGS_RATE"]),
267                    1.0,
268                    "PROVIDE_SAMPLING_LOGS_RATE",
269                )?,
270                traces_rate: parse_rate(
271                    env_value(env, &["PROVIDE_SAMPLING_TRACES_RATE"]),
272                    1.0,
273                    "PROVIDE_SAMPLING_TRACES_RATE",
274                )?,
275                metrics_rate: parse_rate(
276                    env_value(env, &["PROVIDE_SAMPLING_METRICS_RATE"]),
277                    1.0,
278                    "PROVIDE_SAMPLING_METRICS_RATE",
279                )?,
280            },
281            backpressure: BackpressureConfig {
282                logs_maxsize: parse_usize(
283                    env_value(env, &["PROVIDE_BACKPRESSURE_LOGS_MAXSIZE"]),
284                    0,
285                    "PROVIDE_BACKPRESSURE_LOGS_MAXSIZE",
286                )?,
287                traces_maxsize: parse_usize(
288                    env_value(env, &["PROVIDE_BACKPRESSURE_TRACES_MAXSIZE"]),
289                    0,
290                    "PROVIDE_BACKPRESSURE_TRACES_MAXSIZE",
291                )?,
292                metrics_maxsize: parse_usize(
293                    env_value(env, &["PROVIDE_BACKPRESSURE_METRICS_MAXSIZE"]),
294                    0,
295                    "PROVIDE_BACKPRESSURE_METRICS_MAXSIZE",
296                )?,
297            },
298            exporter: ExporterPolicyConfig {
299                logs_retries: parse_usize(
300                    env_value(env, &["PROVIDE_EXPORTER_LOGS_RETRIES"]),
301                    0,
302                    "PROVIDE_EXPORTER_LOGS_RETRIES",
303                )?,
304                traces_retries: parse_usize(
305                    env_value(env, &["PROVIDE_EXPORTER_TRACES_RETRIES"]),
306                    0,
307                    "PROVIDE_EXPORTER_TRACES_RETRIES",
308                )?,
309                metrics_retries: parse_usize(
310                    env_value(env, &["PROVIDE_EXPORTER_METRICS_RETRIES"]),
311                    0,
312                    "PROVIDE_EXPORTER_METRICS_RETRIES",
313                )?,
314                logs_backoff_seconds: parse_non_negative_float(
315                    env_value(env, &["PROVIDE_EXPORTER_LOGS_BACKOFF_SECONDS"]),
316                    0.0,
317                    "PROVIDE_EXPORTER_LOGS_BACKOFF_SECONDS",
318                )?,
319                traces_backoff_seconds: parse_non_negative_float(
320                    env_value(env, &["PROVIDE_EXPORTER_TRACES_BACKOFF_SECONDS"]),
321                    0.0,
322                    "PROVIDE_EXPORTER_TRACES_BACKOFF_SECONDS",
323                )?,
324                metrics_backoff_seconds: parse_non_negative_float(
325                    env_value(env, &["PROVIDE_EXPORTER_METRICS_BACKOFF_SECONDS"]),
326                    0.0,
327                    "PROVIDE_EXPORTER_METRICS_BACKOFF_SECONDS",
328                )?,
329                logs_timeout_seconds: parse_non_negative_float(
330                    env_value(env, &["PROVIDE_EXPORTER_LOGS_TIMEOUT_SECONDS"]),
331                    10.0,
332                    "PROVIDE_EXPORTER_LOGS_TIMEOUT_SECONDS",
333                )?,
334                traces_timeout_seconds: parse_non_negative_float(
335                    env_value(env, &["PROVIDE_EXPORTER_TRACES_TIMEOUT_SECONDS"]),
336                    10.0,
337                    "PROVIDE_EXPORTER_TRACES_TIMEOUT_SECONDS",
338                )?,
339                metrics_timeout_seconds: parse_non_negative_float(
340                    env_value(env, &["PROVIDE_EXPORTER_METRICS_TIMEOUT_SECONDS"]),
341                    10.0,
342                    "PROVIDE_EXPORTER_METRICS_TIMEOUT_SECONDS",
343                )?,
344                logs_fail_open: parse_bool(
345                    env_value(env, &["PROVIDE_EXPORTER_LOGS_FAIL_OPEN"]),
346                    true,
347                    "PROVIDE_EXPORTER_LOGS_FAIL_OPEN",
348                )?,
349                traces_fail_open: parse_bool(
350                    env_value(env, &["PROVIDE_EXPORTER_TRACES_FAIL_OPEN"]),
351                    true,
352                    "PROVIDE_EXPORTER_TRACES_FAIL_OPEN",
353                )?,
354                metrics_fail_open: parse_bool(
355                    env_value(env, &["PROVIDE_EXPORTER_METRICS_FAIL_OPEN"]),
356                    true,
357                    "PROVIDE_EXPORTER_METRICS_FAIL_OPEN",
358                )?,
359            },
360            slo: SLOConfig {
361                enable_red_metrics: parse_bool(
362                    env_value(env, &["PROVIDE_SLO_ENABLE_RED_METRICS"]),
363                    false,
364                    "PROVIDE_SLO_ENABLE_RED_METRICS",
365                )?,
366                enable_use_metrics: parse_bool(
367                    env_value(env, &["PROVIDE_SLO_ENABLE_USE_METRICS"]),
368                    false,
369                    "PROVIDE_SLO_ENABLE_USE_METRICS",
370                )?,
371            },
372            security: SecurityConfig {
373                max_attr_value_length: parse_usize(
374                    env_value(env, &["PROVIDE_SECURITY_MAX_ATTR_VALUE_LENGTH"]),
375                    1024,
376                    "PROVIDE_SECURITY_MAX_ATTR_VALUE_LENGTH",
377                )?,
378                max_attr_count: parse_usize(
379                    env_value(env, &["PROVIDE_SECURITY_MAX_ATTR_COUNT"]),
380                    64,
381                    "PROVIDE_SECURITY_MAX_ATTR_COUNT",
382                )?,
383            },
384        })
385    }
386}
387
388pub fn redact_config(cfg: &TelemetryConfig) -> TelemetryConfig {
389    fn mask(headers: &HashMap<String, String>) -> HashMap<String, String> {
390        headers
391            .keys()
392            .map(|k| (k.clone(), "***REDACTED***".to_string()))
393            .collect()
394    }
395    let mut out = cfg.clone();
396    if !out.logging.otlp_headers.is_empty() {
397        out.logging.otlp_headers = mask(&cfg.logging.otlp_headers);
398    }
399    if !out.tracing.otlp_headers.is_empty() {
400        out.tracing.otlp_headers = mask(&cfg.tracing.otlp_headers);
401    }
402    if !out.metrics.otlp_headers.is_empty() {
403        out.metrics.otlp_headers = mask(&cfg.metrics.otlp_headers);
404    }
405    out
406}
407
408fn env_value<'a>(env: &'a HashMap<String, String>, keys: &[&str]) -> Option<&'a str> {
409    keys.iter()
410        .find_map(|key| env.get(*key).map(String::as_str))
411}
412
413fn parse_bool(raw: Option<&str>, default: bool, field: &str) -> Result<bool, ConfigurationError> {
414    match raw.map(str::trim) {
415        None | Some("") => Ok(default),
416        Some(value)
417            if matches!(
418                value.to_ascii_lowercase().as_str(),
419                "1" | "true" | "yes" | "on"
420            ) =>
421        {
422            Ok(true)
423        }
424        Some(value)
425            if matches!(
426                value.to_ascii_lowercase().as_str(),
427                "0" | "false" | "no" | "off"
428            ) =>
429        {
430            Ok(false)
431        }
432        Some(value) => Err(ConfigurationError::new(format!(
433            "invalid boolean for {field}: {value:?} (expected one of: 1,true,yes,on,0,false,no,off)"
434        ))),
435    }
436}
437
438fn parse_usize(
439    raw: Option<&str>,
440    default: usize,
441    field: &str,
442) -> Result<usize, ConfigurationError> {
443    match raw.map(str::trim) {
444        None | Some("") => Ok(default),
445        Some(value) => value.parse::<usize>().map_err(|_| {
446            ConfigurationError::new(format!("invalid integer for {field}: {value:?}"))
447        }),
448    }
449}
450
451fn parse_non_negative_float(
452    raw: Option<&str>,
453    default: f64,
454    field: &str,
455) -> Result<f64, ConfigurationError> {
456    match raw.map(str::trim) {
457        None | Some("") => Ok(default),
458        Some(value) => {
459            let parsed = value.parse::<f64>().map_err(|_| {
460                ConfigurationError::new(format!("invalid float for {field}: {value:?}"))
461            })?;
462            if !parsed.is_finite() || parsed < 0.0 {
463                return Err(ConfigurationError::new(format!(
464                    "{field} must be >= 0, got {parsed}"
465                )));
466            }
467            Ok(parsed)
468        }
469    }
470}
471
472fn parse_rate(raw: Option<&str>, default: f64, field: &str) -> Result<f64, ConfigurationError> {
473    let parsed = parse_non_negative_float(raw, default, field)?;
474    if !(0.0..=1.0).contains(&parsed) {
475        return Err(ConfigurationError::new(format!(
476            "{field} must be in [0, 1], got {parsed}"
477        )));
478    }
479    Ok(parsed)
480}
481
482fn parse_otlp_headers(
483    raw: Option<&str>,
484) -> Result<Option<HashMap<String, String>>, ConfigurationError> {
485    let Some(raw) = raw else {
486        return Ok(None);
487    };
488    if raw.trim().is_empty() {
489        return Ok(Some(HashMap::new()));
490    }
491
492    let mut headers = HashMap::new();
493    for pair in raw.split(',') {
494        let Some((key, value)) = pair.split_once('=') else {
495            continue;
496        };
497        let Ok(key) = decode_header_component(key.trim()) else {
498            continue;
499        };
500        if key.is_empty() {
501            continue;
502        }
503        let Ok(value) = decode_header_component(value.trim()) else {
504            continue;
505        };
506        headers.insert(key, value);
507    }
508    Ok(Some(headers))
509}
510
511fn decode_header_component(raw: &str) -> Result<String, ConfigurationError> {
512    if has_invalid_percent_encoding(raw) {
513        return Err(ConfigurationError::new(format!(
514            "invalid OTLP header encoding: {raw:?}"
515        )));
516    }
517    Ok(percent_decode_str(raw).decode_utf8_lossy().into_owned())
518}
519
520fn has_invalid_percent_encoding(raw: &str) -> bool {
521    let bytes = raw.as_bytes();
522    let mut idx = 0;
523    while idx < bytes.len() {
524        if bytes[idx] == b'%' {
525            if idx + 2 >= bytes.len()
526                || !bytes[idx + 1].is_ascii_hexdigit()
527                || !bytes[idx + 2].is_ascii_hexdigit()
528            {
529                return true;
530            }
531            idx += 3;
532            continue;
533        }
534        idx += 1;
535    }
536    false
537}