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