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}