Skip to main content

featherbit/debug/
store.rs

1//! Debug settings and the bounded in-memory trace buffer.
2//!
3//! One [`DebugState`] is built at startup from `system.yaml` and hung off
4//! `SharedState`. It answers "should this request be traced?" on the hot path
5//! and owns the ring buffer that the Admin API reads.
6
7use std::collections::{HashMap, VecDeque};
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::sync::{Arc, Mutex};
10
11use serde::Serialize;
12
13use crate::config::DebugConfig;
14
15use super::trace::{CaptureOptions, RedactionPolicy, Trace, TraceSource};
16
17/// Resolved debug settings plus the trace buffer.
18///
19/// Every field is immutable after construction — `system.yaml` is not
20/// hot-reloaded, so debug mode cannot be switched on in a running process.
21/// That is deliberate: it means a compromised Admin API credential cannot
22/// start capturing request contexts.
23pub struct DebugState {
24    pub enabled: bool,
25    pub sandbox_enabled: bool,
26    /// Lowercased, because `GatewayRequest` stores header names as hyper
27    /// normalised them.
28    pub trigger_header: String,
29    pub trace_all: bool,
30    pub capture_bodies: bool,
31    pub max_body_bytes: usize,
32    pub max_traces: usize,
33    pub max_steps: usize,
34    pub sandbox_timeout_seconds: u64,
35    redaction: RedactionPolicy,
36    // `std::sync::Mutex`, not tokio's: the critical section is a `push_back`
37    // plus at most one `pop_front` and is never held across an `.await`, so the
38    // std mutex is both correct and cheaper. Clippy's `await_holding_lock`
39    // keeps it that way.
40    traces: Mutex<VecDeque<Arc<Trace>>>,
41    seq: AtomicU64,
42    /// Traces dropped to keep the buffer at `max_traces`.
43    ///
44    /// Kept so a caller can tell an empty result apart from a rotated-out
45    /// one. Without it, filtering for something that has just aged out is
46    /// indistinguishable from filtering for something that never happened --
47    /// which has already produced one confident, wrong "the filter is broken"
48    /// diagnosis.
49    evicted: AtomicU64,
50}
51
52/// What the ring buffer currently holds, and what it has thrown away.
53#[derive(Debug, Clone, Serialize)]
54pub struct Retention {
55    /// Configured ceiling (`debug.max_traces`).
56    pub max_traces: usize,
57    /// Traces held right now.
58    pub retained: usize,
59    /// Traces evicted since startup.
60    pub evicted: u64,
61    /// `seq` of the oldest trace still held; `None` when the buffer is empty.
62    /// Anything older than this is gone.
63    pub oldest_seq: Option<u64>,
64    /// Whether anything has been evicted at all. When true, an empty result
65    /// may mean "rotated out", not "never happened".
66    pub truncated: bool,
67}
68
69/// Row shape for the trace list view.
70#[derive(Debug, Clone, Serialize)]
71pub struct TraceSummary {
72    pub id: String,
73    pub seq: u64,
74    pub source: TraceSource,
75    pub started_ms: u64,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub route: Option<String>,
78    pub policy: String,
79    pub method: String,
80    pub path: String,
81    pub status: u16,
82    pub duration_us: u64,
83    pub step_count: usize,
84    pub error_count: usize,
85    pub captured_bodies: bool,
86}
87
88impl DebugState {
89    /// Builds the runtime state from config.
90    pub fn new(cfg: &DebugConfig) -> Self {
91        Self {
92            enabled: cfg.enabled,
93            sandbox_enabled: cfg.sandbox,
94            trigger_header: cfg.trigger_header.to_lowercase(),
95            trace_all: cfg.trace_all,
96            capture_bodies: cfg.capture_bodies,
97            max_body_bytes: cfg.max_body_bytes,
98            max_traces: cfg.max_traces,
99            max_steps: cfg.max_steps,
100            sandbox_timeout_seconds: cfg.sandbox_timeout_seconds,
101            redaction: RedactionPolicy::new(
102                &cfg.redact_headers,
103                &cfg.redact_query_params,
104                &cfg.redact_message_keys,
105            ),
106            traces: Mutex::new(VecDeque::new()),
107            seq: AtomicU64::new(0),
108            evicted: AtomicU64::new(0),
109        }
110    }
111
112    /// Capture knobs for a snapshot.
113    pub fn capture_options(&self) -> CaptureOptions {
114        CaptureOptions {
115            capture_bodies: self.capture_bodies,
116            max_body_bytes: self.max_body_bytes,
117            redaction: self.redaction.clone(),
118        }
119    }
120
121    /// Whether this request should be traced.
122    ///
123    /// The whole cost on an untraced request: one `HashMap::get` against the
124    /// already-built header map, and only when debug is enabled at all.
125    pub fn should_trace(&self, headers: &HashMap<String, Vec<String>>) -> bool {
126        if !self.enabled {
127            return false;
128        }
129        self.trace_all || headers.contains_key(&self.trigger_header)
130    }
131
132    /// Whether the request explicitly asked to be traced via the trigger
133    /// header, as opposed to being swept up by `trace_all`.
134    ///
135    /// The `x-featherbit-trace-id` response header is returned only in this
136    /// case: the id is a reply to a caller who opted in. Under `trace_all` the
137    /// traffic is anonymous, so stamping every response with a debug id would
138    /// leak it to clients that never asked — those traces are found in the
139    /// panel/list instead.
140    pub fn header_opt_in(&self, headers: &HashMap<String, Vec<String>>) -> bool {
141        self.enabled && headers.contains_key(&self.trigger_header)
142    }
143
144    /// Next monotonic sequence number.
145    pub fn next_seq(&self) -> u64 {
146        self.seq.fetch_add(1, Ordering::Relaxed)
147    }
148
149    /// Stores a trace, evicting the oldest entries beyond `max_traces`.
150    pub fn record(&self, trace: Trace) {
151        if self.max_traces == 0 {
152            return;
153        }
154        let mut buf = self.traces.lock().unwrap_or_else(|e| e.into_inner());
155        let mut dropped = 0u64;
156        while buf.len() >= self.max_traces {
157            buf.pop_front();
158            dropped += 1;
159        }
160        if dropped > 0 {
161            self.evicted.fetch_add(dropped, Ordering::Relaxed);
162        }
163        buf.push_back(Arc::new(trace));
164    }
165
166    /// What the buffer holds and what it has discarded.
167    ///
168    /// Returned alongside every trace listing so an empty result is
169    /// self-explanatory: with `truncated` set and an `oldest_seq` to compare
170    /// against, "no traces matched" and "the matches aged out" stop looking
171    /// the same.
172    pub fn retention(&self) -> Retention {
173        let buf = self.traces.lock().unwrap_or_else(|e| e.into_inner());
174        let evicted = self.evicted.load(Ordering::Relaxed);
175        Retention {
176            max_traces: self.max_traces,
177            retained: buf.len(),
178            evicted,
179            oldest_seq: buf.front().map(|t| t.seq),
180            truncated: evicted > 0,
181        }
182    }
183
184    /// Summaries, newest first.
185    pub fn list(&self) -> Vec<TraceSummary> {
186        let buf = self.traces.lock().unwrap_or_else(|e| e.into_inner());
187        buf.iter()
188            .rev()
189            .map(|t| TraceSummary {
190                id: t.id.clone(),
191                seq: t.seq,
192                source: t.source,
193                started_ms: t.started_ms,
194                route: t.route.clone(),
195                policy: t.policy.clone(),
196                method: t.method.clone(),
197                path: t.path.clone(),
198                status: t.status,
199                duration_us: t.duration_us,
200                step_count: t.steps.len(),
201                error_count: t.steps.last().map(|s| s.after.errors.len()).unwrap_or(0),
202                captured_bodies: t.captured_bodies,
203            })
204            .collect()
205    }
206
207    /// Fetches one trace. Clones an `Arc` under the lock so serialization
208    /// happens outside it.
209    pub fn get(&self, id: &str) -> Option<Arc<Trace>> {
210        let buf = self.traces.lock().unwrap_or_else(|e| e.into_inner());
211        buf.iter().find(|t| t.id == id).cloned()
212    }
213
214    /// Empties the buffer, returning how many traces were dropped.
215    pub fn clear(&self) -> usize {
216        let mut buf = self.traces.lock().unwrap_or_else(|e| e.into_inner());
217        let n = buf.len();
218        buf.clear();
219        n
220    }
221
222    /// Number of traces currently held.
223    pub fn len(&self) -> usize {
224        self.traces.lock().unwrap_or_else(|e| e.into_inner()).len()
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::context::{Context, GatewayRequest, GatewayResponse, Protocol};
232    use crate::debug::trace::{ContextSnapshot, PreviousBodies};
233    use bytes::Bytes;
234
235    fn ctx() -> Context {
236        Context {
237            request: GatewayRequest {
238                method: "GET".to_string(),
239                path: "/x".to_string(),
240                host: "h".to_string(),
241                scheme: "http".to_string(),
242                headers: HashMap::new(),
243                query_params: HashMap::new(),
244                body: Bytes::new(),
245                remote_addr: "1.2.3.4:5".to_string(),
246                protocol: Protocol::Http1,
247            },
248            response: GatewayResponse {
249                status_code: 200,
250                headers: HashMap::new(),
251                body: Bytes::new(),
252                stream: None,
253            },
254            message: HashMap::new(),
255            errors: Vec::new(),
256        }
257    }
258
259    fn trace(state: &DebugState, id: &str) -> Trace {
260        Trace {
261            id: id.to_string(),
262            seq: state.next_seq(),
263            source: TraceSource::Request,
264            started_ms: 0,
265            route: Some("r".to_string()),
266            policy: "p".to_string(),
267            method: "GET".to_string(),
268            path: "/x".to_string(),
269            status: 200,
270            duration_us: 1,
271            captured_bodies: false,
272            initial: ContextSnapshot::capture(
273                &ctx(),
274                &CaptureOptions::default(),
275                &PreviousBodies::default(),
276            ),
277            steps: Vec::new(),
278            notes: Vec::new(),
279        }
280    }
281
282    fn enabled_state(max_traces: usize) -> DebugState {
283        DebugState::new(&DebugConfig {
284            enabled: true,
285            max_traces,
286            ..Default::default()
287        })
288    }
289
290    #[test]
291    fn test_should_trace_requires_enabled() {
292        let off = DebugState::new(&DebugConfig::default());
293        let mut headers = HashMap::new();
294        headers.insert("x-featherbit-debug".to_string(), vec!["1".to_string()]);
295        // The header alone must never be enough.
296        assert!(!off.should_trace(&headers));
297    }
298
299    #[test]
300    fn test_should_trace_on_trigger_header() {
301        let s = enabled_state(10);
302        let mut headers = HashMap::new();
303        assert!(
304            !s.should_trace(&headers),
305            "untriggered request is not traced"
306        );
307        headers.insert("x-featherbit-debug".to_string(), vec!["1".to_string()]);
308        assert!(s.should_trace(&headers));
309    }
310
311    /// Presence is the trigger, not truthiness -- `: 0` still traces, which is
312    /// what a developer poking at it expects.
313    #[test]
314    fn test_trigger_is_presence_not_value() {
315        let s = enabled_state(10);
316        let mut headers = HashMap::new();
317        headers.insert("x-featherbit-debug".to_string(), vec!["0".to_string()]);
318        assert!(s.should_trace(&headers));
319    }
320
321    #[test]
322    fn test_trace_all_ignores_header() {
323        let s = DebugState::new(&DebugConfig {
324            enabled: true,
325            trace_all: true,
326            ..Default::default()
327        });
328        assert!(s.should_trace(&HashMap::new()));
329    }
330
331    /// The response trace-id header is only for callers who opted in — a
332    /// trace_all-captured request is traced but not `header_opt_in`, so its
333    /// response is not stamped with a debug id it never asked for.
334    #[test]
335    fn test_header_opt_in_distinguishes_from_trace_all() {
336        let s = DebugState::new(&DebugConfig {
337            enabled: true,
338            trace_all: true,
339            ..Default::default()
340        });
341        let empty = HashMap::new();
342        // trace_all sweeps it up...
343        assert!(s.should_trace(&empty));
344        // ...but it did not opt in, so no trace-id header.
345        assert!(!s.header_opt_in(&empty));
346
347        let mut with_header = HashMap::new();
348        with_header.insert("x-featherbit-debug".to_string(), vec!["1".to_string()]);
349        assert!(s.header_opt_in(&with_header));
350    }
351
352    #[test]
353    fn test_custom_trigger_header_is_lowercased() {
354        let s = DebugState::new(&DebugConfig {
355            enabled: true,
356            trigger_header: "X-My-Debug".to_string(),
357            ..Default::default()
358        });
359        assert_eq!(s.trigger_header, "x-my-debug");
360        let mut headers = HashMap::new();
361        headers.insert("x-my-debug".to_string(), vec!["1".to_string()]);
362        assert!(s.should_trace(&headers));
363    }
364
365    #[test]
366    fn test_ring_buffer_evicts_oldest() {
367        let s = enabled_state(3);
368        for i in 0..8 {
369            s.record(trace(&s, &format!("t{i}")));
370        }
371        assert_eq!(s.len(), 3);
372        // Oldest are gone, newest retained.
373        assert!(s.get("t0").is_none());
374        assert!(s.get("t4").is_none());
375        assert!(s.get("t7").is_some());
376        // Listing is newest-first.
377        let ids: Vec<String> = s.list().into_iter().map(|t| t.id).collect();
378        assert_eq!(ids, vec!["t7", "t6", "t5"]);
379    }
380
381    #[test]
382    fn test_clear_reports_count() {
383        let s = enabled_state(10);
384        s.record(trace(&s, "a"));
385        s.record(trace(&s, "b"));
386        assert_eq!(s.clear(), 2);
387        assert_eq!(s.len(), 0);
388        assert!(s.get("a").is_none());
389    }
390
391    #[test]
392    fn test_zero_capacity_disables_storage_without_panicking() {
393        let s = enabled_state(0);
394        s.record(trace(&s, "a"));
395        assert_eq!(s.len(), 0);
396        assert!(s.list().is_empty());
397    }
398
399    #[test]
400    fn test_seq_is_monotonic() {
401        let s = enabled_state(10);
402        assert_eq!(s.next_seq(), 0);
403        assert_eq!(s.next_seq(), 1);
404        assert_eq!(s.next_seq(), 2);
405    }
406
407    /// An untouched buffer has discarded nothing, so an empty listing means
408    /// exactly what it says.
409    #[test]
410    fn test_retention_on_an_empty_buffer_is_not_truncated() {
411        let s = enabled_state(4);
412        let r = s.retention();
413        assert_eq!(r.retained, 0);
414        assert_eq!(r.evicted, 0);
415        assert_eq!(r.oldest_seq, None);
416        assert!(!r.truncated);
417    }
418
419    /// Filling the buffer exactly is still not truncation: nothing has been
420    /// lost, so a caller must not be told history is missing when it is not.
421    #[test]
422    fn test_retention_at_capacity_without_eviction_is_not_truncated() {
423        let s = enabled_state(4);
424        for i in 0..4 {
425            s.record(trace(&s, &format!("t{i}")));
426        }
427        let r = s.retention();
428        assert_eq!(r.retained, 4);
429        assert_eq!(r.evicted, 0);
430        assert!(
431            !r.truncated,
432            "a full buffer that has dropped nothing is intact"
433        );
434    }
435
436    /// Once the buffer starts dropping, a listing has to say so -- this is the
437    /// signal that separates "nothing matched" from "the matches aged out".
438    #[test]
439    fn test_retention_reports_eviction_and_the_surviving_window() {
440        let s = enabled_state(3);
441        for i in 0..7 {
442            s.record(trace(&s, &format!("t{i}")));
443        }
444        let r = s.retention();
445        assert_eq!(r.retained, 3);
446        assert_eq!(r.evicted, 4, "seven recorded into three survives three");
447        assert!(r.truncated);
448
449        // The oldest surviving trace's seq bounds the window: anything below
450        // it is gone, which is what makes an empty filtered result readable.
451        let oldest = s.list().last().expect("a listed trace").seq;
452        assert_eq!(r.oldest_seq, Some(oldest));
453    }
454}