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}