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 std::sync::{Mutex, OnceLock};

use crate::sampling::Signal;

#[derive(Clone, Debug, Default, PartialEq)]
pub struct HealthSnapshot {
    pub emitted_logs: u64,
    pub emitted_traces: u64,
    pub emitted_metrics: u64,
    pub dropped_logs: u64,
    pub dropped_traces: u64,
    pub dropped_metrics: u64,
    pub export_failures_logs: u64,
    pub export_failures_traces: u64,
    pub export_failures_metrics: u64,
    pub retries_logs: u64,
    pub retries_traces: u64,
    pub retries_metrics: u64,
    pub export_latency_ms_logs: f64,
    pub export_latency_ms_traces: f64,
    pub export_latency_ms_metrics: f64,
    pub async_blocking_risk_logs: u64,
    pub async_blocking_risk_traces: u64,
    pub async_blocking_risk_metrics: u64,
    pub circuit_state_logs: String,
    pub circuit_state_traces: String,
    pub circuit_state_metrics: String,
    pub circuit_open_count_logs: u64,
    pub circuit_open_count_traces: u64,
    pub circuit_open_count_metrics: u64,
    pub setup_error: Option<String>,
    /// Governance receipts a configured `ReceiptSink` refused or panicked on.
    /// Without this, a sink that silently drops every receipt is
    /// indistinguishable from one that delivers them.
    pub receipt_failures: u64,
}

static HEALTH: OnceLock<Mutex<HealthSnapshot>> = OnceLock::new();

#[cfg_attr(test, mutants::skip)] // Equivalent mutants only swap in Mutex::default().
fn default_health_mutex() -> Mutex<HealthSnapshot> {
    Mutex::new(HealthSnapshot::default())
}

fn health() -> &'static Mutex<HealthSnapshot> {
    HEALTH.get_or_init(default_health_mutex)
}

pub fn get_health_snapshot() -> HealthSnapshot {
    let mut snapshot = crate::_lock::lock(health()).clone();
    if let Ok((state, count, _)) = crate::resilience::get_circuit_state(Signal::Logs) {
        snapshot.circuit_state_logs = state;
        snapshot.circuit_open_count_logs = count as u64;
    }
    if let Ok((state, count, _)) = crate::resilience::get_circuit_state(Signal::Traces) {
        snapshot.circuit_state_traces = state;
        snapshot.circuit_open_count_traces = count as u64;
    }
    if let Ok((state, count, _)) = crate::resilience::get_circuit_state(Signal::Metrics) {
        snapshot.circuit_state_metrics = state;
        snapshot.circuit_open_count_metrics = count as u64;
    }
    snapshot
}

pub fn increment_dropped(signal: Signal, amount: u64) {
    let mut snapshot = crate::_lock::lock(health());
    match signal {
        Signal::Logs => snapshot.dropped_logs += amount,
        Signal::Traces => snapshot.dropped_traces += amount,
        Signal::Metrics => snapshot.dropped_metrics += amount,
    }
}

pub fn increment_emitted(signal: Signal, amount: u64) {
    let mut snapshot = crate::_lock::lock(health());
    match signal {
        Signal::Logs => snapshot.emitted_logs += amount,
        Signal::Traces => snapshot.emitted_traces += amount,
        Signal::Metrics => snapshot.emitted_metrics += amount,
    }
}

pub fn increment_retries(signal: Signal, amount: u64) {
    let mut snapshot = crate::_lock::lock(health());
    match signal {
        Signal::Logs => snapshot.retries_logs += amount,
        Signal::Traces => snapshot.retries_traces += amount,
        Signal::Metrics => snapshot.retries_metrics += amount,
    }
}

pub fn record_export_failure(signal: Signal) {
    let mut snapshot = crate::_lock::lock(health());
    match signal {
        Signal::Logs => snapshot.export_failures_logs += 1,
        Signal::Traces => snapshot.export_failures_traces += 1,
        Signal::Metrics => snapshot.export_failures_metrics += 1,
    }
}

pub fn record_export_latency(signal: Signal, latency_ms: f64) {
    let mut snapshot = crate::_lock::lock(health());
    match signal {
        Signal::Logs => snapshot.export_latency_ms_logs = latency_ms.max(0.0),
        Signal::Traces => snapshot.export_latency_ms_traces = latency_ms.max(0.0),
        Signal::Metrics => snapshot.export_latency_ms_metrics = latency_ms.max(0.0),
    }
}

/// Count one export that parked a thread it should not have parked.
///
/// The Rust reading of `async_blocking_risk_*`: `flush_telemetry` and
/// `shutdown_telemetry` are synchronous, so the caller's thread waits for the
/// drain. On an ordinary thread that costs nothing but the caller's own time.
/// On a Tokio worker it stalls the executor — every other task multiplexed onto
/// that worker stops until the drain returns — which is the same hazard
/// Python counts when a blocking export runs on an asyncio loop.
pub fn increment_async_blocking_risk(signal: Signal) {
    let mut snapshot = crate::_lock::lock(health());
    match signal {
        Signal::Logs => snapshot.async_blocking_risk_logs += 1,
        Signal::Traces => snapshot.async_blocking_risk_traces += 1,
        Signal::Metrics => snapshot.async_blocking_risk_metrics += 1,
    }
}

/// Count a receipt its sink would not take.
///
/// Deliberately a counter and not a log line: receipts are produced *by*
/// redaction, so logging a delivery failure would redact, produce another
/// receipt, fail again, and never stop.
pub fn increment_receipt_failures() {
    crate::_lock::lock(health()).receipt_failures += 1;
}

pub fn _reset_health_for_tests() {
    *crate::_lock::lock(health()) = HealthSnapshot::default();
}

#[cfg(test)]
#[path = "health_tests.rs"]
mod tests;