1use 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}