Skip to main content

etl_core/
telemetry.rs

1//! Structured logging: `tracing` initialisation (JSON for Kubernetes) and
2//! rate-limited hot-path logging helpers.
3//!
4//! # Rate limiting on the hot path
5//!
6//! A poison-message storm that logs per record will destroy the pinned
7//! pipeline threads. Hot-path warnings must go through a [`RateLimit`],
8//! most conveniently via [`rate_limited_warn!`](crate::rate_limited_warn):
9//!
10//! ```
11//! use etl_core::rate_limited_warn;
12//! use etl_core::telemetry::RateLimit;
13//! use std::time::Duration;
14//!
15//! static DESER_WARN: RateLimit = RateLimit::new(5, Duration::from_secs(10));
16//!
17//! // In the record loop:
18//! rate_limited_warn!(DESER_WARN, reason = "malformed", "payload skipped");
19//! ```
20
21use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
22use std::sync::{Mutex, OnceLock};
23use std::time::{Duration, Instant};
24
25/// Monotonic reference point shared by every [`RateLimit`], so a window
26/// deadline can be stored as a plain `AtomicU64` of nanoseconds and compared
27/// on the lock-free fast path.
28fn base_instant() -> Instant {
29    static BASE: OnceLock<Instant> = OnceLock::new();
30    *BASE.get_or_init(Instant::now)
31}
32
33fn nanos_since_base(now: Instant) -> u64 {
34    now.saturating_duration_since(base_instant()).as_nanos() as u64
35}
36
37/// Output format for logs.
38#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
39#[non_exhaustive]
40pub enum LogFormat {
41    /// One JSON object per line with flattened event fields — the shape
42    /// Kubernetes log pipelines expect. Default.
43    #[default]
44    Json,
45    /// Human-readable output for local development.
46    Pretty,
47}
48
49/// Initialise the global `tracing` subscriber.
50///
51/// The filter comes from `RUST_LOG` when set, else `default_filter`
52/// (e.g. `"info,etl_core=debug"`). Idempotent: returns `true` if this call
53/// installed the subscriber, `false` if one (ours or foreign) was already
54/// installed — never panics, so libraries and tests can call it freely.
55pub fn init(format: LogFormat, default_filter: &str) -> bool {
56    let filter = tracing_subscriber::EnvFilter::try_from_default_env()
57        .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(default_filter));
58    match format {
59        LogFormat::Json => tracing_subscriber::fmt()
60            .json()
61            .flatten_event(true)
62            .with_target(true)
63            .with_env_filter(filter)
64            .try_init()
65            .is_ok(),
66        LogFormat::Pretty => tracing_subscriber::fmt()
67            .with_target(true)
68            .with_env_filter(filter)
69            .try_init()
70            .is_ok(),
71    }
72}
73
74/// Decision returned by [`RateLimit::check`].
75#[derive(Clone, Copy, Debug, PartialEq, Eq)]
76pub enum Decision {
77    /// Log this event. `suppressed_before` is how many events were dropped
78    /// since the last allowed one (attach it to the log line).
79    Allow {
80        /// Events suppressed since the previous allowed event.
81        suppressed_before: u64,
82    },
83    /// Drop this event silently.
84    Suppress,
85}
86
87#[derive(Debug)]
88struct RateLimitState {
89    window_start: Option<Instant>,
90    allowed_in_window: u32,
91    suppressed: u64,
92}
93
94/// A per-callsite token bucket: up to `capacity` events per `window`, then
95/// suppression with a count carried into the first event of the next
96/// window.
97///
98/// `const`-constructible for use in `static`s. Under a poison storm every
99/// pinned pipeline thread hits the *same* limiter once per failing record, so
100/// the exact-accounting mutex is genuinely contended — exactly the case the
101/// limiter exists for. To keep steady-state suppression off the shared lock,
102/// a relaxed-atomic fast path short-circuits while the window stays
103/// saturated: a single relaxed load of `saturated` plus a deadline compare,
104/// no mutex. The mutex path remains the source of truth for allow decisions
105/// and window rolls; suppressed events counted on the fast path are folded
106/// back in at the next roll, so the carried `suppressed` count is exact in
107/// practice (best-effort under concurrent rolls).
108#[derive(Debug)]
109pub struct RateLimit {
110    capacity: u32,
111    window: Duration,
112    state: Mutex<RateLimitState>,
113    /// Set while the current window is exhausted; lets the fast path suppress
114    /// without the mutex. Cleared on a window roll (or superseded by
115    /// `window_end_nanos` once the deadline passes).
116    saturated: AtomicBool,
117    /// Deadline of the saturated window, nanoseconds since [`base_instant`].
118    window_end_nanos: AtomicU64,
119    /// Events suppressed on the lock-free fast path this window, folded into
120    /// the carried count when the window rolls.
121    fast_suppressed: AtomicU64,
122}
123
124impl RateLimit {
125    /// A limiter allowing `capacity` events per `window`.
126    #[must_use]
127    pub const fn new(capacity: u32, window: Duration) -> Self {
128        RateLimit {
129            capacity,
130            window,
131            state: Mutex::new(RateLimitState {
132                window_start: None,
133                allowed_in_window: 0,
134                suppressed: 0,
135            }),
136            saturated: AtomicBool::new(false),
137            window_end_nanos: AtomicU64::new(0),
138            fast_suppressed: AtomicU64::new(0),
139        }
140    }
141
142    /// Decide whether to log an event happening now.
143    pub fn check(&self) -> Decision {
144        self.check_at(Instant::now())
145    }
146
147    /// Decide for an event at `now` (injectable for tests).
148    pub fn check_at(&self, now: Instant) -> Decision {
149        // Fast path: while the window stays saturated, suppress with a single
150        // relaxed load and a deadline compare — no mutex. Once the deadline
151        // passes the compare fails and we fall through to roll the window.
152        if self.saturated.load(Ordering::Relaxed)
153            && nanos_since_base(now) < self.window_end_nanos.load(Ordering::Relaxed)
154        {
155            self.fast_suppressed.fetch_add(1, Ordering::Relaxed);
156            return Decision::Suppress;
157        }
158
159        let mut s = self.state.lock().expect("rate limit lock");
160        let window_expired = match s.window_start {
161            None => true,
162            Some(start) => now.saturating_duration_since(start) >= self.window,
163        };
164        if window_expired {
165            let suppressed_before = s.suppressed + self.fast_suppressed.swap(0, Ordering::Relaxed);
166            s.window_start = Some(now);
167            s.suppressed = 0;
168            if self.capacity == 0 {
169                s.allowed_in_window = 0;
170                s.suppressed = 1;
171                self.arm(now);
172                return Decision::Suppress;
173            }
174            s.allowed_in_window = 1;
175            self.saturated.store(false, Ordering::Relaxed);
176            return Decision::Allow { suppressed_before };
177        }
178        if s.allowed_in_window < self.capacity {
179            s.allowed_in_window += 1;
180            Decision::Allow {
181                suppressed_before: 0,
182            }
183        } else {
184            s.suppressed += 1;
185            if let Some(start) = s.window_start {
186                self.arm(start);
187            }
188            Decision::Suppress
189        }
190    }
191
192    /// Arm the fast path for the window that started at `window_start`.
193    fn arm(&self, window_start: Instant) {
194        self.window_end_nanos.store(
195            nanos_since_base(window_start + self.window),
196            Ordering::Relaxed,
197        );
198        self.saturated.store(true, Ordering::Relaxed);
199    }
200}
201
202/// `tracing::warn!` behind a [`RateLimit`]. When events were suppressed
203/// since the last allowed one, the emitted line carries a `suppressed`
204/// field with the count.
205#[macro_export]
206macro_rules! rate_limited_warn {
207    ($limiter:expr, $($arg:tt)+) => {
208        match $limiter.check() {
209            $crate::telemetry::Decision::Allow { suppressed_before } => {
210                if suppressed_before > 0 {
211                    ::tracing::warn!(suppressed = suppressed_before, $($arg)+);
212                } else {
213                    ::tracing::warn!($($arg)+);
214                }
215            }
216            $crate::telemetry::Decision::Suppress => {}
217        }
218    };
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn allows_up_to_capacity_then_suppresses() {
227        let limit = RateLimit::new(2, Duration::from_secs(10));
228        let t0 = Instant::now();
229        assert_eq!(
230            limit.check_at(t0),
231            Decision::Allow {
232                suppressed_before: 0
233            }
234        );
235        assert_eq!(
236            limit.check_at(t0 + Duration::from_secs(1)),
237            Decision::Allow {
238                suppressed_before: 0
239            }
240        );
241        for i in 2..5 {
242            assert_eq!(
243                limit.check_at(t0 + Duration::from_secs(i)),
244                Decision::Suppress
245            );
246        }
247        // New window: allowed again, carrying the suppressed count.
248        assert_eq!(
249            limit.check_at(t0 + Duration::from_secs(10)),
250            Decision::Allow {
251                suppressed_before: 3
252            }
253        );
254        // And the fresh window counts from one.
255        assert_eq!(
256            limit.check_at(t0 + Duration::from_secs(11)),
257            Decision::Allow {
258                suppressed_before: 0
259            }
260        );
261        assert_eq!(
262            limit.check_at(t0 + Duration::from_secs(12)),
263            Decision::Suppress
264        );
265    }
266
267    #[test]
268    fn fast_path_suppression_preserves_the_carried_count() {
269        // After saturation, most suppressions take the lock-free fast path;
270        // their count must still be folded into the next window's first event.
271        let limit = RateLimit::new(1, Duration::from_secs(10));
272        let t0 = Instant::now();
273        assert_eq!(
274            limit.check_at(t0),
275            Decision::Allow {
276                suppressed_before: 0
277            }
278        );
279        for i in 1..=100 {
280            assert_eq!(
281                limit.check_at(t0 + Duration::from_millis(i)),
282                Decision::Suppress
283            );
284        }
285        assert_eq!(
286            limit.check_at(t0 + Duration::from_secs(10)),
287            Decision::Allow {
288                suppressed_before: 100
289            }
290        );
291    }
292
293    #[test]
294    fn zero_capacity_suppresses_everything() {
295        let limit = RateLimit::new(0, Duration::from_secs(1));
296        let t0 = Instant::now();
297        assert_eq!(limit.check_at(t0), Decision::Suppress);
298        assert_eq!(
299            limit.check_at(t0 + Duration::from_secs(2)),
300            Decision::Suppress
301        );
302    }
303
304    #[test]
305    fn usable_from_a_static_via_the_macro() {
306        static LIMIT: RateLimit = RateLimit::new(1, Duration::from_secs(60));
307        // No subscriber installed: the macro must still be safe to call.
308        rate_limited_warn!(LIMIT, code = 7, "first is allowed");
309        rate_limited_warn!(LIMIT, code = 8, "second is suppressed");
310    }
311
312    #[test]
313    fn init_is_idempotent() {
314        // Whichever test initialises first wins; both calls must be safe.
315        let _ = init(LogFormat::Pretty, "warn");
316        let second = init(LogFormat::Json, "warn");
317        assert!(!second, "second init reports already-installed");
318    }
319}