Skip to main content

spate_core/
telemetry.rs

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