Skip to main content

rs_zero/core/logging/
aggregation.rs

1use std::{
2    collections::BTreeMap,
3    sync::{Arc, Mutex},
4};
5
6/// Aggregated error group.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct ErrorGroup {
9    /// Normalized low-cardinality fingerprint.
10    pub fingerprint: String,
11    /// Number of recorded errors in this group.
12    pub count: u64,
13}
14
15/// Snapshot of all aggregated error groups.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct ErrorAggregationSnapshot {
18    /// Total recorded errors.
19    pub total: u64,
20    /// Error groups sorted by fingerprint.
21    pub groups: Vec<ErrorGroup>,
22}
23
24/// Groups repeated errors by a low-cardinality fingerprint.
25#[derive(Debug, Clone, Default)]
26pub struct ErrorAggregator {
27    groups: Arc<Mutex<BTreeMap<String, u64>>>,
28}
29
30impl ErrorAggregator {
31    /// Creates an empty aggregator.
32    pub fn new() -> Self {
33        Self::default()
34    }
35
36    /// Records one error message.
37    pub fn record(&self, message: &str) {
38        let fingerprint = fingerprint_error(message);
39        let mut groups = self.groups.lock().expect("aggregator mutex");
40        *groups.entry(fingerprint).or_default() += 1;
41    }
42
43    /// Returns an immutable snapshot.
44    pub fn snapshot(&self) -> ErrorAggregationSnapshot {
45        let groups = self.groups.lock().expect("aggregator mutex");
46        ErrorAggregationSnapshot {
47            total: groups.values().sum(),
48            groups: groups
49                .iter()
50                .map(|(fingerprint, count)| ErrorGroup {
51                    fingerprint: fingerprint.clone(),
52                    count: *count,
53                })
54                .collect(),
55        }
56    }
57}
58
59fn fingerprint_error(message: &str) -> String {
60    let tokens = message.split_whitespace().collect::<Vec<_>>();
61    let mut output = Vec::with_capacity(tokens.len());
62    let mut redact_next = false;
63    for token in tokens {
64        if redact_next {
65            output.push("*".to_string());
66            redact_next = false;
67            continue;
68        }
69        let lower = token.to_ascii_lowercase();
70        if matches!(lower.as_str(), "token" | "password" | "secret" | "key") {
71            output.push(lower);
72            redact_next = true;
73            continue;
74        }
75        output.push(normalize_dynamic_token(token));
76    }
77    output.join(" ")
78}
79
80fn normalize_dynamic_token(token: &str) -> String {
81    if token.chars().any(|value| value.is_ascii_digit()) {
82        "#".to_string()
83    } else {
84        token.to_ascii_lowercase()
85    }
86}