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}