1use 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
17pub struct DebugState {
24 pub enabled: bool,
25 pub sandbox_enabled: bool,
26 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 traces: Mutex<VecDeque<Arc<Trace>>>,
41 seq: AtomicU64,
42 evicted: AtomicU64,
50}
51
52#[derive(Debug, Clone, Serialize)]
54pub struct Retention {
55 pub max_traces: usize,
57 pub retained: usize,
59 pub evicted: u64,
61 pub oldest_seq: Option<u64>,
64 pub truncated: bool,
67}
68
69#[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 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 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 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 pub fn header_opt_in(&self, headers: &HashMap<String, Vec<String>>) -> bool {
141 self.enabled && headers.contains_key(&self.trigger_header)
142 }
143
144 pub fn next_seq(&self) -> u64 {
146 self.seq.fetch_add(1, Ordering::Relaxed)
147 }
148
149 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 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 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 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 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 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 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 #[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 #[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 assert!(s.should_trace(&empty));
344 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 assert!(s.get("t0").is_none());
374 assert!(s.get("t4").is_none());
375 assert!(s.get("t7").is_some());
376 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 #[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 #[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 #[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 let oldest = s.list().last().expect("a listed trace").seq;
452 assert_eq!(r.oldest_seq, Some(oldest));
453 }
454}