use std::collections::HashMap;
use provide_telemetry::{redact_config, setup_telemetry, ConfigurationError, TelemetryConfig};
fn config_from(entries: &[(&str, &str)]) -> Result<TelemetryConfig, ConfigurationError> {
let env: HashMap<String, String> = entries
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
TelemetryConfig::from_map(&env)
}
#[test]
fn test_telemetry_config_default() {
let config = TelemetryConfig::default();
assert!(!config.service_name.is_empty());
}
#[test]
fn test_telemetry_config_from_env() {
let _config = TelemetryConfig::from_env();
}
#[test]
fn test_setup_telemetry() {
let _ = setup_telemetry(None);
let _ = setup_telemetry(None);
}
fn config_with_header(header: &str) -> Result<TelemetryConfig, String> {
let mut env = HashMap::new();
env.insert("OTEL_EXPORTER_OTLP_HEADERS".to_string(), header.to_string());
TelemetryConfig::from_map(&env).map_err(|e| e.to_string())
}
#[test]
fn test_percent_encoding_valid_is_accepted() {
let cfg = config_with_header("x-custom=hello%41world").expect("valid encoding must not fail");
let logs_headers = &cfg.logging.otlp_headers;
assert_eq!(
logs_headers.get("x-custom").map(|s| s.as_str()),
Some("helloAworld")
);
}
#[test]
fn test_percent_encoding_percent_at_end_is_rejected() {
let cfg = config_with_header("x-bad=value%").expect("config-level parse must succeed");
assert!(
!cfg.logging.otlp_headers.contains_key("x-bad"),
"header with bare % at end should be silently skipped"
);
}
#[test]
fn test_percent_encoding_one_hex_digit_is_rejected() {
let cfg = config_with_header("x-bad=value%4").expect("config-level parse must succeed");
assert!(
!cfg.logging.otlp_headers.contains_key("x-bad"),
"header with %<one-digit> should be silently skipped"
);
}
#[test]
fn test_percent_encoding_non_hex_first_char_is_rejected() {
let cfg = config_with_header("x-bad=value%GF").expect("config-level parse must succeed");
assert!(
!cfg.logging.otlp_headers.contains_key("x-bad"),
"header with %G (non-hex first digit) should be silently skipped"
);
}
#[test]
fn test_percent_encoding_non_hex_second_char_is_rejected() {
let cfg = config_with_header("x-bad=value%4Z").expect("config-level parse must succeed");
assert!(
!cfg.logging.otlp_headers.contains_key("x-bad"),
"header with %4Z (non-hex second digit) should be silently skipped"
);
}
#[test]
fn test_percent_encoding_boundary_exactly_two_chars_after_percent_is_valid() {
let cfg = config_with_header("x-ok=%4F").expect("config-level parse must succeed");
assert_eq!(
cfg.logging.otlp_headers.get("x-ok").map(|s| s.as_str()),
Some("O"),
"%4F (exactly idx+2 == len-1) must be treated as valid encoding"
);
}
#[test]
fn config_test_parse_bool_falsy_values_are_accepted() {
for val in &["false", "False", "FALSE", "0", "no", "NO", "off", "OFF"] {
let cfg = config_from(&[("PROVIDE_TRACE_ENABLED", val)])
.unwrap_or_else(|e| panic!("{val:?} should parse as false, got error: {e}"));
assert!(!cfg.tracing.enabled, "{val:?} must parse as false");
}
}
#[test]
fn config_test_non_negative_float_rejects_infinity() {
let err = config_from(&[("PROVIDE_EXPORTER_LOGS_TIMEOUT_SECONDS", "inf")])
.expect_err("infinity must be rejected");
assert!(err
.message
.contains("PROVIDE_EXPORTER_LOGS_TIMEOUT_SECONDS"));
}
#[test]
fn config_test_non_negative_float_rejects_negative() {
let err = config_from(&[("PROVIDE_EXPORTER_LOGS_BACKOFF_SECONDS", "-1")])
.expect_err("negative float must be rejected");
assert!(err
.message
.contains("PROVIDE_EXPORTER_LOGS_BACKOFF_SECONDS"));
}
#[test]
fn config_test_non_negative_float_accepts_zero() {
let cfg = config_from(&[("PROVIDE_EXPORTER_LOGS_BACKOFF_SECONDS", "0.0")])
.expect("zero must be a valid non-negative float");
assert_eq!(cfg.exporter.logs_backoff_seconds, 0.0);
}
#[test]
fn redact_config_masks_otlp_header_values() {
let cfg = config_from(&[(
"OTEL_EXPORTER_OTLP_HEADERS",
"authorization=Bearer secret123",
)])
.unwrap();
let redacted = redact_config(&cfg);
assert!(
redacted.logging.otlp_headers.contains_key("authorization"),
"key must be preserved"
);
assert_eq!(
redacted
.logging
.otlp_headers
.get("authorization")
.map(String::as_str),
Some("***REDACTED***"),
"value must be masked"
);
}
#[test]
fn redact_config_masks_each_signal_header_map_independently() {
let mut cfg = TelemetryConfig::default();
cfg.logging
.otlp_headers
.insert("logs-token".to_string(), "logs-secret".to_string());
cfg.tracing
.otlp_headers
.insert("traces-token".to_string(), "traces-secret".to_string());
cfg.metrics
.otlp_headers
.insert("metrics-token".to_string(), "metrics-secret".to_string());
let redacted = redact_config(&cfg);
for (headers, key) in [
(&redacted.logging.otlp_headers, "logs-token"),
(&redacted.tracing.otlp_headers, "traces-token"),
(&redacted.metrics.otlp_headers, "metrics-token"),
] {
assert_eq!(headers.get(key).map(String::as_str), Some("***REDACTED***"));
}
}
#[test]
fn redact_config_preserves_non_header_fields() {
let cfg = config_from(&[
("PROVIDE_TELEMETRY_SERVICE_NAME", "my-service"),
("PROVIDE_TELEMETRY_ENV", "prod"),
])
.unwrap();
let redacted = redact_config(&cfg);
assert_eq!(redacted.service_name, "my-service");
assert_eq!(redacted.environment, "prod");
}
#[test]
fn redact_config_empty_headers_unchanged() {
let cfg = TelemetryConfig::default();
let redacted = redact_config(&cfg);
assert!(
redacted.logging.otlp_headers.is_empty(),
"empty headers must stay empty"
);
}
#[test]
fn redact_config_does_not_mutate_original() {
let cfg = config_from(&[("OTEL_EXPORTER_OTLP_HEADERS", "x-token=realvalue")]).unwrap();
let original_value = cfg.logging.otlp_headers.get("x-token").cloned();
let _ = redact_config(&cfg);
assert_eq!(
cfg.logging.otlp_headers.get("x-token").cloned(),
original_value,
"original config must not be mutated"
);
}
#[test]
fn otlp_endpoint_shared_falls_back_with_per_signal_path_appended() {
let cfg = config_from(&[("OTEL_EXPORTER_OTLP_ENDPOINT", "https://shared:4318")]).unwrap();
assert_eq!(
cfg.logging.otlp_endpoint.as_deref(),
Some("https://shared:4318/v1/logs")
);
assert_eq!(
cfg.tracing.otlp_endpoint.as_deref(),
Some("https://shared:4318/v1/traces")
);
assert_eq!(
cfg.metrics.otlp_endpoint.as_deref(),
Some("https://shared:4318/v1/metrics")
);
}
#[test]
fn otlp_endpoint_shared_strips_trailing_slash_before_appending() {
let cfg = config_from(&[("OTEL_EXPORTER_OTLP_ENDPOINT", "https://shared:4318/")]).unwrap();
assert_eq!(
cfg.tracing.otlp_endpoint.as_deref(),
Some("https://shared:4318/v1/traces"),
"trailing slash on shared endpoint must not produce a double slash"
);
}
#[test]
fn otlp_endpoint_signal_specific_overrides_shared_verbatim() {
let cfg = config_from(&[
("OTEL_EXPORTER_OTLP_ENDPOINT", "https://shared:4318"),
(
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
"https://traces-only:4318/custom/path",
),
])
.unwrap();
assert_eq!(
cfg.logging.otlp_endpoint.as_deref(),
Some("https://shared:4318/v1/logs"),
"logs should fall back to shared with /v1/logs appended"
);
assert_eq!(
cfg.tracing.otlp_endpoint.as_deref(),
Some("https://traces-only:4318/custom/path"),
"traces should use its own override verbatim"
);
assert_eq!(
cfg.metrics.otlp_endpoint.as_deref(),
Some("https://shared:4318/v1/metrics"),
"metrics should fall back to shared with /v1/metrics appended"
);
}
#[test]
fn otlp_endpoint_blank_signal_specific_vars_do_not_mask_shared_fallback() {
let cfg = config_from(&[
("OTEL_EXPORTER_OTLP_ENDPOINT", "https://shared:4318"),
("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", ""),
("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", ""),
("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", ""),
])
.unwrap();
assert_eq!(
cfg.logging.otlp_endpoint.as_deref(),
Some("https://shared:4318/v1/logs"),
"blank logs endpoint should behave as unset and fall back to shared"
);
assert_eq!(
cfg.tracing.otlp_endpoint.as_deref(),
Some("https://shared:4318/v1/traces"),
"blank traces endpoint should behave as unset and fall back to shared"
);
assert_eq!(
cfg.metrics.otlp_endpoint.as_deref(),
Some("https://shared:4318/v1/metrics"),
"blank metrics endpoint should behave as unset and fall back to shared"
);
}
#[test]
fn otlp_endpoint_is_none_when_neither_shared_nor_signal_env_set() {
let cfg = config_from(&[]).unwrap();
assert!(cfg.logging.otlp_endpoint.is_none());
assert!(cfg.tracing.otlp_endpoint.is_none());
assert!(cfg.metrics.otlp_endpoint.is_none());
}
#[test]
fn otlp_protocol_shared_falls_back_to_all_three_signals() {
let cfg = config_from(&[("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf")]).unwrap();
assert_eq!(cfg.logging.otlp_protocol, "http/protobuf");
assert_eq!(cfg.tracing.otlp_protocol, "http/protobuf");
assert_eq!(cfg.metrics.otlp_protocol, "http/protobuf");
}
#[test]
fn otlp_protocol_signal_specific_overrides_shared() {
let cfg = config_from(&[
("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf"),
("OTEL_EXPORTER_OTLP_METRICS_PROTOCOL", "grpc"),
])
.unwrap();
assert_eq!(cfg.logging.otlp_protocol, "http/protobuf");
assert_eq!(cfg.tracing.otlp_protocol, "http/protobuf");
assert_eq!(
cfg.metrics.otlp_protocol, "grpc",
"metrics should use its override"
);
}
#[test]
fn otlp_protocol_defaults_to_empty_string_when_unset() {
let cfg = config_from(&[]).unwrap();
assert_eq!(cfg.logging.otlp_protocol, "");
assert_eq!(cfg.tracing.otlp_protocol, "");
assert_eq!(cfg.metrics.otlp_protocol, "");
}
#[test]
fn otel_metric_export_interval_defaults_to_sixty_seconds() {
let cfg = config_from(&[]).unwrap();
assert_eq!(
cfg.metrics.metric_export_interval_ms, 60_000,
"default export interval must be 60 000 ms"
);
}
#[test]
fn otel_metric_export_interval_parsed_from_env() {
let cfg = config_from(&[("OTEL_METRIC_EXPORT_INTERVAL", "5000")]).unwrap();
assert_eq!(
cfg.metrics.metric_export_interval_ms, 5_000,
"custom interval must be taken from env var"
);
}
#[test]
fn otel_metric_export_interval_rejects_non_integer() {
let result = config_from(&[("OTEL_METRIC_EXPORT_INTERVAL", "1.5")]);
assert!(
result.is_err(),
"non-integer interval must be rejected as ConfigurationError"
);
}
#[test]
fn config_structs_serde_default_empty_object() {
serde_json::from_str::<provide_telemetry::MetricsConfig>("{}")
.expect("MetricsConfig must deserialize from {}");
serde_json::from_str::<provide_telemetry::TracingConfig>("{}")
.expect("TracingConfig must deserialize from {}");
serde_json::from_str::<provide_telemetry::SecurityConfig>("{}")
.expect("SecurityConfig must deserialize from {}");
}
#[test]
fn security_config_serde_default_values() {
let cfg: provide_telemetry::SecurityConfig =
serde_json::from_str("{}").expect("SecurityConfig empty object must use defaults");
assert_eq!(cfg.max_attr_value_length, 1024);
assert_eq!(cfg.max_attr_count, 64);
assert_eq!(cfg.max_nesting_depth, 8);
}
#[test]
fn metrics_config_serde_missing_interval_uses_default() {
let cfg: provide_telemetry::MetricsConfig =
serde_json::from_str(r#"{"enabled": true, "otlp_headers": {}, "otlp_protocol": ""}"#)
.expect("MetricsConfig missing interval field must deserialize");
assert_eq!(cfg.metric_export_interval_ms, 60_000);
}
#[test]
fn telemetry_config_serde_round_trip_with_defaults() {
let partial = r#"{"service_name": "round-trip-test"}"#;
let cfg: provide_telemetry::TelemetryConfig = serde_json::from_str(partial)
.expect("TelemetryConfig with only service_name must deserialize");
assert_eq!(cfg.service_name, "round-trip-test");
assert_eq!(cfg.environment, "dev");
assert_eq!(cfg.security.max_attr_value_length, 1024);
assert_eq!(cfg.metrics.metric_export_interval_ms, 60_000);
assert!((cfg.sampling.logs_rate - 1.0).abs() < f64::EPSILON);
}