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.
//
mod runtime_test_support;

use std::process::Command;

#[cfg(feature = "otel")]
use provide_telemetry::get_runtime_status;
use provide_telemetry::{reconfigure_telemetry, reload_runtime_from_env, setup_telemetry};
use runtime_test_support::*;

#[test]
fn runtime_test_reload_runtime_from_env_warns_for_all_cold_field_drift() {
    let _guard = runtime_lock().lock().expect("runtime lock poisoned");
    with_env(
        &[
            ("PROVIDE_TELEMETRY_SERVICE_NAME", "initial-service"),
            ("PROVIDE_TELEMETRY_ENV", "dev"),
            ("PROVIDE_TELEMETRY_VERSION", "1.0.0"),
            ("PROVIDE_TRACE_ENABLED", "true"),
            ("PROVIDE_METRICS_ENABLED", "true"),
        ],
        || {
            reset_runtime();
            setup_telemetry(None).expect("setup should succeed");

            std::env::set_var("PROVIDE_TELEMETRY_ENV", "prod");
            std::env::set_var("PROVIDE_TELEMETRY_VERSION", "2.0.0");
            std::env::set_var("PROVIDE_TRACE_ENABLED", "false");
            std::env::set_var("PROVIDE_METRICS_ENABLED", "false");

            let reloaded = reload_runtime_from_env().expect("reload should succeed");
            assert_eq!(reloaded.environment, "dev");
            assert_eq!(reloaded.version, "1.0.0");
            assert!(reloaded.tracing.enabled);
            assert!(reloaded.metrics.enabled);
        },
    );
}

#[test]
fn runtime_test_cold_drift_subprocess_probe() {
    if std::env::var("PROVIDE_COLD_DRIFT_SUBPROCESS").as_deref() != Ok("1") {
        return;
    }

    reset_runtime();
    setup_telemetry(None).expect("subprocess setup should succeed");
    std::env::set_var("PROVIDE_TELEMETRY_SERVICE_NAME", "changed-service");
    std::env::set_var("PROVIDE_TELEMETRY_ENV", "prod");
    std::env::set_var("PROVIDE_TELEMETRY_VERSION", "2.0.0");
    std::env::set_var("PROVIDE_TRACE_ENABLED", "false");
    std::env::set_var("PROVIDE_METRICS_ENABLED", "false");
    reload_runtime_from_env().expect("subprocess reload should succeed");
}

#[test]
fn runtime_test_cold_drift_warning_is_observable_on_stderr() {
    let output = Command::new(std::env::current_exe().expect("current test executable"))
        .args([
            "--exact",
            "runtime_test_cold_drift_subprocess_probe",
            "--nocapture",
        ])
        .env("PROVIDE_COLD_DRIFT_SUBPROCESS", "1")
        .env("PROVIDE_TELEMETRY_SERVICE_NAME", "initial-service")
        .env("PROVIDE_TELEMETRY_ENV", "dev")
        .env("PROVIDE_TELEMETRY_VERSION", "1.0.0")
        .env("PROVIDE_TRACE_ENABLED", "true")
        .env("PROVIDE_METRICS_ENABLED", "true")
        .env_remove("OTEL_EXPORTER_OTLP_ENDPOINT")
        .env_remove("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT")
        .env_remove("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT")
        .env_remove("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT")
        .output()
        .expect("cold-drift subprocess should run");

    assert!(
        output.status.success(),
        "subprocess failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    let stderr = String::from_utf8(output.stderr).expect("stderr must be UTF-8");
    assert!(stderr.contains("runtime.cold_field_drift"));
    for field in [
        "service_name",
        "environment",
        "version",
        "tracing.enabled",
        "metrics.enabled",
    ] {
        assert!(stderr.contains(field), "missing {field} in {stderr:?}");
    }
    assert!(stderr.contains("restart required to apply"));
}

#[test]
fn runtime_test_reconfigure_telemetry_none_reads_environment() {
    let _guard = runtime_lock().lock().expect("runtime lock poisoned");
    with_env(
        &[
            ("PROVIDE_TELEMETRY_SERVICE_NAME", "from-env"),
            ("PROVIDE_TELEMETRY_ENV", "stage"),
        ],
        || {
            reset_runtime();
            #[cfg(not(feature = "otel"))]
            setup_telemetry(None).expect("setup should succeed");

            let cfg = reconfigure_telemetry(None).expect("reconfigure should read env");
            assert_eq!(cfg.service_name, "from-env");
            assert_eq!(cfg.environment, "stage");
        },
    );
}

#[cfg(feature = "otel")]
#[test]
fn runtime_test_reconfigure_telemetry_rejects_identity_change_when_otel_provider_is_live() {
    let _guard = runtime_lock().lock().expect("runtime lock poisoned");
    with_env(
        &[
            ("OTEL_EXPORTER_OTLP_ENDPOINT", "http://127.0.0.1:4318"),
            ("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf"),
        ],
        || {
            reset_runtime();
            let current = setup_telemetry(None).expect("setup should succeed");
            let status = get_runtime_status();
            assert!(status.providers.logs, "logs provider should be live");
            assert!(status.providers.traces, "traces provider should be live");
            assert!(status.providers.metrics, "metrics provider should be live");

            let mut hot_only = current.clone();
            hot_only.sampling.logs_rate = 0.75;
            let updated = reconfigure_telemetry(Some(hot_only))
                .expect("hot-only drift must remain allowed with live providers");
            assert_eq!(updated.sampling.logs_rate, 0.75);

            let mut changed = current.clone();
            changed.service_name = "other-service".to_string();

            let err = reconfigure_telemetry(Some(changed))
                .expect_err("provider-changing identity drift must be rejected");
            assert!(err.message.contains("restart the process"));

            let mut changed = current.clone();
            changed.logging.otlp_endpoint = Some("http://127.0.0.1:4319/v1/logs".to_string());
            let err = reconfigure_telemetry(Some(changed))
                .expect_err("live log provider drift must be rejected");
            assert!(err.message.contains("restart the process"));

            let mut changed = current.clone();
            changed.tracing.otlp_endpoint = Some("http://127.0.0.1:4319/v1/traces".to_string());
            let err = reconfigure_telemetry(Some(changed))
                .expect_err("live trace provider drift must be rejected");
            assert!(err.message.contains("restart the process"));

            let mut changed = current.clone();
            changed.metrics.metric_export_interval_ms += 1;
            let err = reconfigure_telemetry(Some(changed))
                .expect_err("live metrics provider drift must be rejected");
            assert!(err.message.contains("restart the process"));
        },
    );
}