Skip to main content

hyperi_rustlib/logger/
helpers.rs

1// Project:   hyperi-rustlib
2// File:      src/logger/helpers.rs
3// Purpose:   Log spam protection helpers
4// Language:  Rust
5//
6// License:   FSL-1.1-ALv2
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9//! Log spam protection helpers.
10//!
11//! Atomic helper functions for per-site log rate limiting.
12//! Use alongside the global `tracing-throttle` layer for defence-in-depth.
13
14use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
15use std::time::{SystemTime, UNIX_EPOCH};
16
17/// Log on state transition only. Returns true if state changed.
18///
19/// Use for sustained conditions: memory pressure, circuit breaker, disk full.
20/// Log the transition, not every check cycle.
21///
22/// # Example
23/// ```
24/// use std::sync::atomic::AtomicBool;
25/// use hyperi_rustlib::logger::log_state_change;
26///
27/// static PRESSURE_HIGH: AtomicBool = AtomicBool::new(false);
28/// if log_state_change(&PRESSURE_HIGH, true) {
29///     // Only logs once when transitioning to high
30/// }
31/// ```
32#[inline]
33pub fn log_state_change(flag: &AtomicBool, new_state: bool) -> bool {
34    flag.swap(new_state, Ordering::Relaxed) != new_state
35}
36
37/// Log every Nth occurrence. Returns true on first call and every `sample_rate`-th call.
38///
39/// Use for per-message errors in hot paths: send failures, validation errors.
40/// Always increment metrics separately — this only controls log emission.
41///
42/// # Example
43/// ```
44/// use std::sync::atomic::AtomicU64;
45/// use hyperi_rustlib::logger::log_sampled;
46///
47/// static SEND_ERRORS: AtomicU64 = AtomicU64::new(0);
48/// if log_sampled(&SEND_ERRORS, 1000) {
49///     // Logs first occurrence, then every 1000th
50/// }
51/// ```
52#[inline]
53pub fn log_sampled(counter: &AtomicU64, sample_rate: u64) -> bool {
54    let count = counter.fetch_add(1, Ordering::Relaxed) + 1;
55    count == 1 || count.is_multiple_of(sample_rate)
56}
57
58/// Log at most once per interval. Returns true if enough time has passed.
59///
60/// Use for tight recv/poll loop errors: UDP recv, Kafka consumer, health checks.
61///
62/// # Example
63/// ```
64/// use std::sync::atomic::AtomicU64;
65/// use hyperi_rustlib::logger::log_debounced;
66///
67/// static LAST_WARN: AtomicU64 = AtomicU64::new(0);
68/// if log_debounced(&LAST_WARN, 5000) {
69///     // Logs at most once per 5 seconds
70/// }
71/// ```
72#[inline]
73pub fn log_debounced(last_epoch_ms: &AtomicU64, min_interval_ms: u64) -> bool {
74    let now = u64::try_from(
75        SystemTime::now()
76            .duration_since(UNIX_EPOCH)
77            .unwrap_or_default()
78            .as_millis(),
79    )
80    .unwrap_or(u64::MAX);
81    let last = last_epoch_ms.load(Ordering::Relaxed);
82    if now.saturating_sub(last) >= min_interval_ms {
83        last_epoch_ms.store(now, Ordering::Relaxed);
84        true
85    } else {
86        false
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    #[test]
95    fn test_state_change_transitions() {
96        let flag = AtomicBool::new(false);
97        // false -> true: changed
98        assert!(log_state_change(&flag, true));
99        // true -> true: no change
100        assert!(!log_state_change(&flag, true));
101        // true -> false: changed
102        assert!(log_state_change(&flag, false));
103        // false -> false: no change
104        assert!(!log_state_change(&flag, false));
105    }
106
107    #[test]
108    fn test_sampled_first_and_nth() {
109        let counter = AtomicU64::new(0);
110        // First call always logs (count=1)
111        assert!(log_sampled(&counter, 1000));
112        // Calls 2..999 do not log (998 calls)
113        for _ in 0..998 {
114            assert!(!log_sampled(&counter, 1000));
115        }
116        // Call 1000: count=1000, 1000 % 1000 == 0 -> logs
117        assert!(log_sampled(&counter, 1000));
118        // Call 1001: count=1001, 1001 % 1000 == 1 -> does not log
119        assert!(!log_sampled(&counter, 1000));
120    }
121
122    #[test]
123    fn test_sampled_rate_1() {
124        let counter = AtomicU64::new(0);
125        for _ in 0..10 {
126            assert!(log_sampled(&counter, 1));
127        }
128    }
129
130    #[test]
131    fn test_debounced_first_call() {
132        let last = AtomicU64::new(0);
133        // First call always returns true (last is 0, epoch is large)
134        assert!(log_debounced(&last, 5000));
135    }
136
137    #[test]
138    fn test_debounced_within_interval() {
139        let last = AtomicU64::new(0);
140        // First call: logs
141        assert!(log_debounced(&last, 60_000)); // 60s interval
142        // Immediate second call: suppressed (within 60s)
143        assert!(!log_debounced(&last, 60_000));
144    }
145}