provide-telemetry 0.3.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 hmac::{Hmac, Mac};
use sha2::{Digest, Sha256};
use std::sync::{Mutex, OnceLock};
use uuid::Uuid;

type HmacSha256 = Hmac<Sha256>;

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RedactionReceipt {
    pub receipt_id: String,
    pub timestamp: String,
    pub service_name: String,
    pub field_path: String,
    pub action: String,
    pub original_hash: String,
    pub hmac: Option<String>,
}

#[derive(Clone, Debug, PartialEq, Eq)]
struct ReceiptConfig {
    enabled: bool,
    signing_key: Option<String>,
    service_name: String,
    test_mode: bool,
}

impl Default for ReceiptConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            signing_key: None,
            service_name: "unknown".to_string(),
            test_mode: false,
        }
    }
}

static CONFIG: OnceLock<Mutex<ReceiptConfig>> = OnceLock::new();
static RECEIPTS: OnceLock<Mutex<Vec<RedactionReceipt>>> = OnceLock::new();

fn config() -> &'static Mutex<ReceiptConfig> {
    CONFIG.get_or_init(|| Mutex::new(ReceiptConfig::default()))
}

fn receipts() -> &'static Mutex<Vec<RedactionReceipt>> {
    RECEIPTS.get_or_init(|| Mutex::new(Vec::new()))
}

fn bytes_to_hex(bytes: &[u8]) -> String {
    bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}

pub fn enable_receipts(enabled: bool, signing_key: Option<&str>, service_name: Option<&str>) {
    let test_mode = config()
        .lock()
        .expect("receipt config lock poisoned")
        .test_mode;
    *config().lock().expect("receipt config lock poisoned") = ReceiptConfig {
        enabled,
        signing_key: signing_key.map(str::to_string),
        service_name: service_name.unwrap_or("unknown").to_string(),
        test_mode,
    };
}

pub fn emit_receipt(field_path: &str, action: &str, original_value: &str) {
    let snapshot = config()
        .lock()
        .expect("receipt config lock poisoned")
        .clone();
    if !snapshot.enabled {
        return;
    }

    let original_hash = {
        let mut hasher = Sha256::new();
        hasher.update(original_value.as_bytes());
        bytes_to_hex(&hasher.finalize())
    };
    let receipt_id = Uuid::new_v4().to_string();
    let timestamp = format!("{:?}", std::time::SystemTime::now());
    let hmac = snapshot.signing_key.as_ref().map(|key| {
        let payload = format!(
            "{}|{}|{}|{}|{}",
            receipt_id, timestamp, field_path, action, original_hash
        );
        let mut mac = HmacSha256::new_from_slice(key.as_bytes()).expect("valid HMAC key");
        mac.update(payload.as_bytes());
        bytes_to_hex(&mac.finalize().into_bytes())
    });

    let receipt = RedactionReceipt {
        receipt_id,
        timestamp,
        service_name: snapshot.service_name,
        field_path: field_path.to_string(),
        action: action.to_string(),
        original_hash,
        hmac,
    };

    if snapshot.test_mode {
        receipts()
            .lock()
            .expect("receipt log lock poisoned")
            .push(receipt);
    }
}

pub fn get_emitted_receipts_for_tests() -> Vec<RedactionReceipt> {
    receipts()
        .lock()
        .expect("receipt log lock poisoned")
        .clone()
}

pub fn reset_receipts_for_tests() {
    *config().lock().expect("receipt config lock poisoned") = ReceiptConfig {
        test_mode: true,
        ..ReceiptConfig::default()
    };
    receipts()
        .lock()
        .expect("receipt log lock poisoned")
        .clear();
}