provide-telemetry 0.7.0

Cross-language telemetry helpers with privacy, resilience, and OTLP support.
Documentation
// SPDX-FileCopyrightText: Copyright (C) 2026 provide.io llc
// SPDX-License-Identifier: Apache-2.0
// SPDX-Comment: Part of provide-telemetry.
//

use super::*;

use crate::testing::acquire_test_state_lock;

fn env_map(entries: &[(&str, &str)]) -> HashMap<String, String> {
    entries
        .iter()
        .map(|(key, value)| ((*key).to_string(), (*value).to_string()))
        .collect()
}

#[test]
fn from_env_test_reads_process_environment_wrapper() {
    let _guard = acquire_test_state_lock();
    std::env::set_var("PROVIDE_TELEMETRY_SERVICE_NAME", "env-wrapper");
    std::env::set_var("PROVIDE_TELEMETRY_ENV", "staging");
    std::env::set_var("PROVIDE_TELEMETRY_VERSION", "9.9.9");
    std::env::set_var("PROVIDE_LOG_FORMAT", "json");
    std::env::set_var("PROVIDE_LOG_INCLUDE_TIMESTAMP", "off");

    let cfg = TelemetryConfig::from_env().expect("process env should parse");

    std::env::remove_var("PROVIDE_TELEMETRY_SERVICE_NAME");
    std::env::remove_var("PROVIDE_TELEMETRY_ENV");
    std::env::remove_var("PROVIDE_TELEMETRY_VERSION");
    std::env::remove_var("PROVIDE_LOG_FORMAT");
    std::env::remove_var("PROVIDE_LOG_INCLUDE_TIMESTAMP");

    assert_eq!(cfg.service_name, "env-wrapper");
    assert_eq!(cfg.environment, "staging");
    assert_eq!(cfg.version, "9.9.9");
    assert_eq!(cfg.logging.fmt, "json");
    assert!(!cfg.logging.include_timestamp);
}

#[test]
fn from_map_test_parses_signal_specific_and_policy_fields() {
    let cfg = TelemetryConfig::from_map(&env_map(&[
        ("PROVIDE_TELEMETRY_STRICT_SCHEMA", "true"),
        ("PROVIDE_LOG_PII_MAX_DEPTH", "3"),
        ("PROVIDE_LOG_LEVEL", "DEBUG"),
        ("PROVIDE_LOG_FORMAT", "json"),
        ("PROVIDE_LOG_INCLUDE_TIMESTAMP", "off"),
        ("PROVIDE_LOG_MODULE_LEVELS", "pkg=debug,pkg.child=warning"),
        ("OTEL_EXPORTER_OTLP_LOGS_HEADERS", "x-log=1"),
        (
            "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
            "https://logs.example/v1/logs",
        ),
        ("OTEL_EXPORTER_OTLP_LOGS_PROTOCOL", "http/json"),
        ("PROVIDE_TRACE_ENABLED", "false"),
        ("PROVIDE_TRACE_SAMPLE_RATE", "0.25"),
        ("OTEL_EXPORTER_OTLP_TRACES_HEADERS", "x-trace=2"),
        (
            "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
            "https://traces.example/v1/traces",
        ),
        ("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", "grpc"),
        ("PROVIDE_METRICS_ENABLED", "no"),
        ("OTEL_EXPORTER_OTLP_METRICS_HEADERS", "x-metric=3"),
        (
            "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
            "https://metrics.example/v1/metrics",
        ),
        ("OTEL_EXPORTER_OTLP_METRICS_PROTOCOL", "http/protobuf"),
        ("OTEL_METRIC_EXPORT_INTERVAL", "5000"),
        ("PROVIDE_TELEMETRY_STRICT_EVENT_NAME", "yes"),
        ("PROVIDE_TELEMETRY_REQUIRED_KEYS", "request_id, actor_id"),
        ("PROVIDE_SAMPLING_LOGS_RATE", "0.1"),
        ("PROVIDE_SAMPLING_TRACES_RATE", "0.2"),
        ("PROVIDE_SAMPLING_METRICS_RATE", "0.3"),
        ("PROVIDE_BACKPRESSURE_LOGS_MAXSIZE", "11"),
        ("PROVIDE_BACKPRESSURE_TRACES_MAXSIZE", "12"),
        ("PROVIDE_BACKPRESSURE_METRICS_MAXSIZE", "13"),
        ("PROVIDE_EXPORTER_LOGS_RETRIES", "2"),
        ("PROVIDE_EXPORTER_TRACES_RETRIES", "3"),
        ("PROVIDE_EXPORTER_METRICS_RETRIES", "4"),
        ("PROVIDE_EXPORTER_LOGS_BACKOFF_SECONDS", "1.5"),
        ("PROVIDE_EXPORTER_TRACES_BACKOFF_SECONDS", "2.5"),
        ("PROVIDE_EXPORTER_METRICS_BACKOFF_SECONDS", "3.5"),
        ("PROVIDE_EXPORTER_LOGS_TIMEOUT_SECONDS", "4.5"),
        ("PROVIDE_EXPORTER_TRACES_TIMEOUT_SECONDS", "5.5"),
        ("PROVIDE_EXPORTER_METRICS_TIMEOUT_SECONDS", "6.5"),
        ("PROVIDE_EXPORTER_LOGS_SHUTDOWN_TIMEOUT_SECONDS", "2.25"),
        ("PROVIDE_LOG_OTLP_ENABLED", "false"),
        ("PROVIDE_EXPORTER_LOGS_FAIL_OPEN", "false"),
        ("PROVIDE_EXPORTER_TRACES_FAIL_OPEN", "off"),
        ("PROVIDE_EXPORTER_METRICS_FAIL_OPEN", "no"),
        ("PROVIDE_SLO_ENABLE_RED_METRICS", "true"),
        ("PROVIDE_SLO_ENABLE_USE_METRICS", "on"),
        ("PROVIDE_SECURITY_MAX_ATTR_VALUE_LENGTH", "256"),
        ("PROVIDE_SECURITY_MAX_ATTR_COUNT", "16"),
        ("PROVIDE_SECURITY_MAX_NESTING_DEPTH", "4"),
    ]))
    .expect("all explicit env values should parse");

    assert!(cfg.strict_schema);
    assert_eq!(cfg.pii_max_depth, 3);
    assert_eq!(cfg.logging.level, "DEBUG");
    assert_eq!(cfg.logging.fmt, "json");
    assert!(!cfg.logging.include_timestamp);
    assert_eq!(
        cfg.logging.module_levels.get("pkg").map(String::as_str),
        Some("DEBUG")
    );
    assert_eq!(
        cfg.logging
            .module_levels
            .get("pkg.child")
            .map(String::as_str),
        Some("WARNING")
    );
    assert_eq!(
        cfg.logging.otlp_headers.get("x-log").map(String::as_str),
        Some("1")
    );
    assert_eq!(
        cfg.logging.otlp_endpoint.as_deref(),
        Some("https://logs.example/v1/logs")
    );
    assert_eq!(cfg.logging.otlp_protocol, "http/json");

    assert!(!cfg.tracing.enabled);
    assert!((cfg.tracing.sample_rate - 0.25).abs() < f64::EPSILON);
    assert_eq!(
        cfg.tracing.otlp_headers.get("x-trace").map(String::as_str),
        Some("2")
    );
    assert_eq!(
        cfg.tracing.otlp_endpoint.as_deref(),
        Some("https://traces.example/v1/traces")
    );
    assert_eq!(cfg.tracing.otlp_protocol, "grpc");

    assert!(!cfg.metrics.enabled);
    assert_eq!(
        cfg.metrics.otlp_headers.get("x-metric").map(String::as_str),
        Some("3")
    );
    assert_eq!(
        cfg.metrics.otlp_endpoint.as_deref(),
        Some("https://metrics.example/v1/metrics")
    );
    assert_eq!(cfg.metrics.otlp_protocol, "http/protobuf");
    assert_eq!(cfg.metrics.metric_export_interval_ms, 5_000);

    assert!(cfg.event_schema.strict_event_name);
    assert_eq!(
        cfg.event_schema.required_keys,
        vec!["request_id", "actor_id"]
    );
    assert!((cfg.sampling.logs_rate - 0.1).abs() < f64::EPSILON);
    assert!((cfg.sampling.traces_rate - 0.2).abs() < f64::EPSILON);
    assert!((cfg.sampling.metrics_rate - 0.3).abs() < f64::EPSILON);
    assert_eq!(cfg.backpressure.logs_maxsize, 11);
    assert_eq!(cfg.backpressure.traces_maxsize, 12);
    assert_eq!(cfg.backpressure.metrics_maxsize, 13);
    assert_eq!(cfg.exporter.logs_retries, 2);
    assert_eq!(cfg.exporter.traces_retries, 3);
    assert_eq!(cfg.exporter.metrics_retries, 4);
    assert!((cfg.exporter.logs_backoff_seconds - 1.5).abs() < f64::EPSILON);
    assert!((cfg.exporter.traces_backoff_seconds - 2.5).abs() < f64::EPSILON);
    assert!((cfg.exporter.metrics_backoff_seconds - 3.5).abs() < f64::EPSILON);
    assert!((cfg.exporter.logs_timeout_seconds - 4.5).abs() < f64::EPSILON);
    assert!((cfg.exporter.traces_timeout_seconds - 5.5).abs() < f64::EPSILON);
    assert!((cfg.exporter.metrics_timeout_seconds - 6.5).abs() < f64::EPSILON);
    assert!((cfg.exporter.logs_shutdown_timeout_seconds - 2.25).abs() < f64::EPSILON);
    assert!(!cfg.logging.otlp_enabled);
    assert!(!cfg.exporter.logs_fail_open);
    assert!(!cfg.exporter.traces_fail_open);
    assert!(!cfg.exporter.metrics_fail_open);
    assert!(cfg.slo.enable_red_metrics);
    assert!(cfg.slo.enable_use_metrics);
    assert_eq!(cfg.security.max_attr_value_length, 256);
    assert_eq!(cfg.security.max_attr_count, 16);
    assert_eq!(cfg.security.max_nesting_depth, 4);
}

#[test]
fn from_map_test_shared_otlp_defaults_fill_all_three_signals() {
    let cfg = TelemetryConfig::from_map(&env_map(&[
        ("OTEL_EXPORTER_OTLP_HEADERS", "Authorization=Bearer%20token"),
        ("OTEL_EXPORTER_OTLP_ENDPOINT", "https://collector:4318/"),
        ("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf"),
    ]))
    .expect("shared OTLP env vars should parse");

    assert_eq!(
        cfg.logging
            .otlp_headers
            .get("Authorization")
            .map(String::as_str),
        Some("Bearer token")
    );
    assert_eq!(cfg.tracing.otlp_headers, cfg.logging.otlp_headers);
    assert_eq!(cfg.metrics.otlp_headers, cfg.logging.otlp_headers);
    assert_eq!(
        cfg.logging.otlp_endpoint.as_deref(),
        Some("https://collector:4318/v1/logs")
    );
    assert_eq!(
        cfg.tracing.otlp_endpoint.as_deref(),
        Some("https://collector:4318/v1/traces")
    );
    assert_eq!(
        cfg.metrics.otlp_endpoint.as_deref(),
        Some("https://collector:4318/v1/metrics")
    );
    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 from_map_test_defaults_for_shutdown_timeout_and_otlp_enabled() {
    // With no env vars, the bounded-shutdown deadline and the OTLP-enabled
    // gate must take their documented defaults (5.0s, true). Mirrors the
    // Python/TS/Go defaults pinned in their own test suites.
    let cfg = TelemetryConfig::from_map(&env_map(&[])).expect("empty env should parse");
    assert!((cfg.exporter.logs_shutdown_timeout_seconds - 5.0).abs() < f64::EPSILON);
    assert!(cfg.logging.otlp_enabled);
}

#[test]
fn from_map_test_invalid_scalar_env_values_fail_on_their_own_fields() {
    let cases = [
        ("PROVIDE_TELEMETRY_STRICT_SCHEMA", "maybe"),
        ("PROVIDE_LOG_PII_MAX_DEPTH", "-1"),
        ("PROVIDE_LOG_INCLUDE_TIMESTAMP", "maybe"),
        ("PROVIDE_TRACE_ENABLED", "maybe"),
        ("PROVIDE_TRACE_SAMPLE_RATE", "2.0"),
        ("PROVIDE_METRICS_ENABLED", "maybe"),
        ("OTEL_METRIC_EXPORT_INTERVAL", "-1"),
        ("PROVIDE_TELEMETRY_STRICT_EVENT_NAME", "maybe"),
        ("PROVIDE_SAMPLING_LOGS_RATE", "2.0"),
        ("PROVIDE_SAMPLING_TRACES_RATE", "2.0"),
        ("PROVIDE_SAMPLING_METRICS_RATE", "2.0"),
        ("PROVIDE_BACKPRESSURE_LOGS_MAXSIZE", "-1"),
        ("PROVIDE_BACKPRESSURE_TRACES_MAXSIZE", "-1"),
        ("PROVIDE_BACKPRESSURE_METRICS_MAXSIZE", "-1"),
        ("PROVIDE_EXPORTER_LOGS_RETRIES", "-1"),
        ("PROVIDE_EXPORTER_TRACES_RETRIES", "-1"),
        ("PROVIDE_EXPORTER_METRICS_RETRIES", "-1"),
        ("PROVIDE_EXPORTER_LOGS_BACKOFF_SECONDS", "-1"),
        ("PROVIDE_EXPORTER_TRACES_BACKOFF_SECONDS", "-1"),
        ("PROVIDE_EXPORTER_METRICS_BACKOFF_SECONDS", "-1"),
        ("PROVIDE_EXPORTER_LOGS_TIMEOUT_SECONDS", "-1"),
        ("PROVIDE_EXPORTER_TRACES_TIMEOUT_SECONDS", "-1"),
        ("PROVIDE_EXPORTER_METRICS_TIMEOUT_SECONDS", "-1"),
        ("PROVIDE_EXPORTER_LOGS_SHUTDOWN_TIMEOUT_SECONDS", "-1"),
        ("PROVIDE_LOG_OTLP_ENABLED", "maybe"),
        ("PROVIDE_EXPORTER_LOGS_FAIL_OPEN", "maybe"),
        ("PROVIDE_EXPORTER_TRACES_FAIL_OPEN", "maybe"),
        ("PROVIDE_EXPORTER_METRICS_FAIL_OPEN", "maybe"),
        ("PROVIDE_SLO_ENABLE_RED_METRICS", "maybe"),
        ("PROVIDE_SLO_ENABLE_USE_METRICS", "maybe"),
        ("PROVIDE_SECURITY_MAX_ATTR_VALUE_LENGTH", "-1"),
        ("PROVIDE_SECURITY_MAX_ATTR_COUNT", "-1"),
        ("PROVIDE_SECURITY_MAX_NESTING_DEPTH", "-1"),
    ];

    for (key, value) in cases {
        let cfg = env_map(&[(key, value)]);
        let err = TelemetryConfig::from_map(&cfg)
            .expect_err(&format!("expected {key}={value:?} to fail"));
        assert!(
            err.message.contains(key),
            "error for {key} should mention the field, got: {}",
            err.message
        );
    }
}

/// The exporter-retries ceiling, at its exact boundary. Retries past what the
/// resilience layer's attempt cap can honor would be silently meaningless, so
/// the parse rejects them — the same contract as TypeScript's `requireRetries`
/// and `TelemetryConfig::validate`.
#[test]
fn from_map_test_exporter_retries_are_capped_at_one_hundred() {
    let at_cap = env_map(&[
        ("PROVIDE_EXPORTER_LOGS_RETRIES", "100"),
        ("PROVIDE_EXPORTER_TRACES_RETRIES", "100"),
        ("PROVIDE_EXPORTER_METRICS_RETRIES", "100"),
    ]);
    let cfg = TelemetryConfig::from_map(&at_cap).expect("100 retries is the accepted maximum");
    assert_eq!(cfg.exporter.logs_retries, 100);
    assert_eq!(cfg.exporter.traces_retries, 100);
    assert_eq!(cfg.exporter.metrics_retries, 100);

    for key in [
        "PROVIDE_EXPORTER_LOGS_RETRIES",
        "PROVIDE_EXPORTER_TRACES_RETRIES",
        "PROVIDE_EXPORTER_METRICS_RETRIES",
    ] {
        let over = env_map(&[(key, "101")]);
        let err = TelemetryConfig::from_map(&over).expect_err("101 retries must be rejected");
        assert!(
            err.message.contains(key) && err.message.contains("must be at most 100, got 101"),
            "unexpected message for {key}: {}",
            err.message
        );
    }
}

#[test]
fn from_map_test_invalid_otlp_header_env_values_are_skipped_without_failing_parse() {
    for key in [
        "OTEL_EXPORTER_OTLP_HEADERS",
        "OTEL_EXPORTER_OTLP_LOGS_HEADERS",
        "OTEL_EXPORTER_OTLP_TRACES_HEADERS",
        "OTEL_EXPORTER_OTLP_METRICS_HEADERS",
    ] {
        let cfg = TelemetryConfig::from_map(&env_map(&[(key, "bad=%ZZ,ok=value")]))
            .expect("invalid header pairs should be ignored");
        let headers = match key {
            "OTEL_EXPORTER_OTLP_LOGS_HEADERS" => cfg.logging.otlp_headers,
            "OTEL_EXPORTER_OTLP_TRACES_HEADERS" => cfg.tracing.otlp_headers,
            "OTEL_EXPORTER_OTLP_METRICS_HEADERS" => cfg.metrics.otlp_headers,
            _ => cfg.logging.otlp_headers,
        };
        assert_eq!(headers.get("ok").map(String::as_str), Some("value"));
        assert!(!headers.contains_key("bad"));
    }
}