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}
43
44/// Row shape for the trace list view.
45#[derive(Debug, Clone, Serialize)]
46pub struct TraceSummary {
47    pub id: String,
48    pub seq: u64,
49    pub source: TraceSource,
50    pub started_ms: u64,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub route: Option<String>,
53    pub policy: String,
54    pub method: String,
55    pub path: String,
56    pub status: u16,
57    pub duration_us: u64,
58    pub step_count: usize,
59    pub error_count: usize,
60    pub captured_bodies: bool,
61}
62
63impl DebugState {
64    /// Builds the runtime state from config.
65    pub fn new(cfg: &DebugConfig) -> Self {
66        Self {
67            enabled: cfg.enabled,
68            sandbox_enabled: cfg.sandbox,
69            trigger_header: cfg.trigger_header.to_lowercase(),
70            trace_all: cfg.trace_all,
71            capture_bodies: cfg.capture_bodies,
72            max_body_bytes: cfg.max_body_bytes,
73            max_traces: cfg.max_traces,
74            max_steps: cfg.max_steps,
75            sandbox_timeout_seconds: cfg.sandbox_timeout_seconds,
76            redaction: RedactionPolicy::new(
77                &cfg.redact_headers,
78                &cfg.redact_query_params,
79                &cfg.redact_message_keys,
80            ),
81            traces: Mutex::new(VecDeque::new()),
82            seq: AtomicU64::new(0),
83        }
84    }
85
86    /// Capture knobs for a snapshot.
87    pub fn capture_options(&self) -> CaptureOptions {
88        CaptureOptions {
89            capture_bodies: self.capture_bodies,
90            max_body_bytes: self.max_body_bytes,
91            redaction: self.redaction.clone(),
92        }
93    }
94
95    /// Whether this request should be traced.
96    ///
97    /// The whole cost on an untraced request: one `HashMap::get` against the
98    /// already-built header map, and only when debug is enabled at all.
99    pub fn should_trace(&self, headers: &HashMap<String, Vec<String>>) -> bool {
100        if !self.enabled {
101            return false;
102        }
103        self.trace_all || headers.contains_key(&self.trigger_header)
104    }
105
106    /// Whether the request explicitly asked to be traced via the trigger
107    /// header, as opposed to being swept up by `trace_all`.
108    ///
109    /// The `x-featherbit-trace-id` response header is returned only in this
110    /// case: the id is a reply to a caller who opted in. Under `trace_all` the
111    /// traffic is anonymous, so stamping every response with a debug id would
112    /// leak it to clients that never asked — those traces are found in the
113    /// panel/list instead.
114    pub fn header_opt_in(&self, headers: &HashMap<String, Vec<String>>) -> bool {
115        self.enabled && headers.contains_key(&self.trigger_header)
116    }
117
118    /// Next monotonic sequence number.
119    pub fn next_seq(&self) -> u64 {
120        self.seq.fetch_add(1, Ordering::Relaxed)
121    }
122
123    /// Stores a trace, evicting the oldest entries beyond `max_traces`.
124    pub fn record(&self, trace: Trace) {
125        if self.max_traces == 0 {
126            return;
127        }
128        let mut buf = self.traces.lock().unwrap_or_else(|e| e.into_inner());
129        while buf.len() >= self.max_traces {
130            buf.pop_front();
131        }
132        buf.push_back(Arc::new(trace));
133    }
134
135    /// Summaries, newest first.
136    pub fn list(&self) -> Vec<TraceSummary> {
137        let buf = self.traces.lock().unwrap_or_else(|e| e.into_inner());
138        buf.iter()
139            .rev()
140            .map(|t| TraceSummary {
141                id: t.id.clone(),
142                seq: t.seq,
143                source: t.source,
144                started_ms: t.started_ms,
145                route: t.route.clone(),
146                policy: t.policy.clone(),
147                method: t.method.clone(),
148                path: t.path.clone(),
149                status: t.status,
150                duration_us: t.duration_us,
151                step_count: t.steps.len(),
152                error_count: t.steps.last().map(|s| s.after.errors.len()).unwrap_or(0),
153                captured_bodies: t.captured_bodies,
154            })
155            .collect()
156    }
157
158    /// Fetches one trace. Clones an `Arc` under the lock so serialization
159    /// happens outside it.
160    pub fn get(&self, id: &str) -> Option<Arc<Trace>> {
161        let buf = self.traces.lock().unwrap_or_else(|e| e.into_inner());
162        buf.iter().find(|t| t.id == id).cloned()
163    }
164
165    /// Empties the buffer, returning how many traces were dropped.
166    pub fn clear(&self) -> usize {
167        let mut buf = self.traces.lock().unwrap_or_else(|e| e.into_inner());
168        let n = buf.len();
169        buf.clear();
170        n
171    }
172
173    /// Number of traces currently held.
174    pub fn len(&self) -> usize {
175        self.traces.lock().unwrap_or_else(|e| e.into_inner()).len()
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::context::{Context, GatewayRequest, GatewayResponse, Protocol};
183    use crate::debug::trace::{ContextSnapshot, PreviousBodies};
184    use bytes::Bytes;
185
186    fn ctx() -> Context {
187        Context {
188            request: GatewayRequest {
189                method: "GET".to_string(),
190                path: "/x".to_string(),
191                host: "h".to_string(),
192                scheme: "http".to_string(),
193                headers: HashMap::new(),
194                query_params: HashMap::new(),
195                body: Bytes::new(),
196                remote_addr: "1.2.3.4:5".to_string(),
197                protocol: Protocol::Http1,
198            },
199            response: GatewayResponse {
200                status_code: 200,
201                headers: HashMap::new(),
202                body: Bytes::new(),
203            },
204            message: HashMap::new(),
205            errors: Vec::new(),
206        }
207    }
208
209    fn trace(state: &DebugState, id: &str) -> Trace {
210        Trace {
211            id: id.to_string(),
212            seq: state.next_seq(),
213            source: TraceSource::Request,
214            started_ms: 0,
215            route: Some("r".to_string()),
216            policy: "p".to_string(),
217            method: "GET".to_string(),
218            path: "/x".to_string(),
219            status: 200,
220            duration_us: 1,
221            captured_bodies: false,
222            initial: ContextSnapshot::capture(
223                &ctx(),
224                &CaptureOptions::default(),
225                &PreviousBodies::default(),
226            ),
227            steps: Vec::new(),
228            notes: Vec::new(),
229        }
230    }
231
232    fn enabled_state(max_traces: usize) -> DebugState {
233        DebugState::new(&DebugConfig {
234            enabled: true,
235            max_traces,
236            ..Default::default()
237        })
238    }
239
240    #[test]
241    fn test_should_trace_requires_enabled() {
242        let off = DebugState::new(&DebugConfig::default());
243        let mut headers = HashMap::new();
244        headers.insert("x-featherbit-debug".to_string(), vec!["1".to_string()]);
245        // The header alone must never be enough.
246        assert!(!off.should_trace(&headers));
247    }
248
249    #[test]
250    fn test_should_trace_on_trigger_header() {
251        let s = enabled_state(10);
252        let mut headers = HashMap::new();
253        assert!(
254            !s.should_trace(&headers),
255            "untriggered request is not traced"
256        );
257        headers.insert("x-featherbit-debug".to_string(), vec!["1".to_string()]);
258        assert!(s.should_trace(&headers));
259    }
260
261    /// Presence is the trigger, not truthiness -- `: 0` still traces, which is
262    /// what a developer poking at it expects.
263    #[test]
264    fn test_trigger_is_presence_not_value() {
265        let s = enabled_state(10);
266        let mut headers = HashMap::new();
267        headers.insert("x-featherbit-debug".to_string(), vec!["0".to_string()]);
268        assert!(s.should_trace(&headers));
269    }
270
271    #[test]
272    fn test_trace_all_ignores_header() {
273        let s = DebugState::new(&DebugConfig {
274            enabled: true,
275            trace_all: true,
276            ..Default::default()
277        });
278        assert!(s.should_trace(&HashMap::new()));
279    }
280
281    /// The response trace-id header is only for callers who opted in — a
282    /// trace_all-captured request is traced but not `header_opt_in`, so its
283    /// response is not stamped with a debug id it never asked for.
284    #[test]
285    fn test_header_opt_in_distinguishes_from_trace_all() {
286        let s = DebugState::new(&DebugConfig {
287            enabled: true,
288            trace_all: true,
289            ..Default::default()
290        });
291        let empty = HashMap::new();
292        // trace_all sweeps it up...
293        assert!(s.should_trace(&empty));
294        // ...but it did not opt in, so no trace-id header.
295        assert!(!s.header_opt_in(&empty));
296
297        let mut with_header = HashMap::new();
298        with_header.insert("x-featherbit-debug".to_string(), vec!["1".to_string()]);
299        assert!(s.header_opt_in(&with_header));
300    }
301
302    #[test]
303    fn test_custom_trigger_header_is_lowercased() {
304        let s = DebugState::new(&DebugConfig {
305            enabled: true,
306            trigger_header: "X-My-Debug".to_string(),
307            ..Default::default()
308        });
309        assert_eq!(s.trigger_header, "x-my-debug");
310        let mut headers = HashMap::new();
311        headers.insert("x-my-debug".to_string(), vec!["1".to_string()]);
312        assert!(s.should_trace(&headers));
313    }
314
315    #[test]
316    fn test_ring_buffer_evicts_oldest() {
317        let s = enabled_state(3);
318        for i in 0..8 {
319            s.record(trace(&s, &format!("t{i}")));
320        }
321        assert_eq!(s.len(), 3);
322        // Oldest are gone, newest retained.
323        assert!(s.get("t0").is_none());
324        assert!(s.get("t4").is_none());
325        assert!(s.get("t7").is_some());
326        // Listing is newest-first.
327        let ids: Vec<String> = s.list().into_iter().map(|t| t.id).collect();
328        assert_eq!(ids, vec!["t7", "t6", "t5"]);
329    }
330
331    #[test]
332    fn test_clear_reports_count() {
333        let s = enabled_state(10);
334        s.record(trace(&s, "a"));
335        s.record(trace(&s, "b"));
336        assert_eq!(s.clear(), 2);
337        assert_eq!(s.len(), 0);
338        assert!(s.get("a").is_none());
339    }
340
341    #[test]
342    fn test_zero_capacity_disables_storage_without_panicking() {
343        let s = enabled_state(0);
344        s.record(trace(&s, "a"));
345        assert_eq!(s.len(), 0);
346        assert!(s.list().is_empty());
347    }
348
349    #[test]
350    fn test_seq_is_monotonic() {
351        let s = enabled_state(10);
352        assert_eq!(s.next_seq(), 0);
353        assert_eq!(s.next_seq(), 1);
354        assert_eq!(s.next_seq(), 2);
355    }
356}