rs-zero 0.2.6

Rust-first microservice framework inspired by go-zero engineering practices
Documentation
use std::{
    collections::BTreeMap,
    sync::{Arc, Mutex},
};

/// Aggregated error group.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ErrorGroup {
    /// Normalized low-cardinality fingerprint.
    pub fingerprint: String,
    /// Number of recorded errors in this group.
    pub count: u64,
}

/// Snapshot of all aggregated error groups.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ErrorAggregationSnapshot {
    /// Total recorded errors.
    pub total: u64,
    /// Error groups sorted by fingerprint.
    pub groups: Vec<ErrorGroup>,
}

/// Groups repeated errors by a low-cardinality fingerprint.
#[derive(Debug, Clone, Default)]
pub struct ErrorAggregator {
    groups: Arc<Mutex<BTreeMap<String, u64>>>,
}

impl ErrorAggregator {
    /// Creates an empty aggregator.
    pub fn new() -> Self {
        Self::default()
    }

    /// Records one error message.
    pub fn record(&self, message: &str) {
        let fingerprint = fingerprint_error(message);
        let mut groups = self.groups.lock().expect("aggregator mutex");
        *groups.entry(fingerprint).or_default() += 1;
    }

    /// Returns an immutable snapshot.
    pub fn snapshot(&self) -> ErrorAggregationSnapshot {
        let groups = self.groups.lock().expect("aggregator mutex");
        ErrorAggregationSnapshot {
            total: groups.values().sum(),
            groups: groups
                .iter()
                .map(|(fingerprint, count)| ErrorGroup {
                    fingerprint: fingerprint.clone(),
                    count: *count,
                })
                .collect(),
        }
    }
}

fn fingerprint_error(message: &str) -> String {
    let tokens = message.split_whitespace().collect::<Vec<_>>();
    let mut output = Vec::with_capacity(tokens.len());
    let mut redact_next = false;
    for token in tokens {
        if redact_next {
            output.push("*".to_string());
            redact_next = false;
            continue;
        }
        let lower = token.to_ascii_lowercase();
        if matches!(lower.as_str(), "token" | "password" | "secret" | "key") {
            output.push(lower);
            redact_next = true;
            continue;
        }
        output.push(normalize_dynamic_token(token));
    }
    output.join(" ")
}

fn normalize_dynamic_token(token: &str) -> String {
    if token.chars().any(|value| value.is_ascii_digit()) {
        "#".to_string()
    } else {
        token.to_ascii_lowercase()
    }
}