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}
43
44#[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 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 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 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 pub fn header_opt_in(&self, headers: &HashMap<String, Vec<String>>) -> bool {
115 self.enabled && headers.contains_key(&self.trigger_header)
116 }
117
118 pub fn next_seq(&self) -> u64 {
120 self.seq.fetch_add(1, Ordering::Relaxed)
121 }
122
123 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 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 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 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 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 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 #[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 #[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 assert!(s.should_trace(&empty));
294 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 assert!(s.get("t0").is_none());
324 assert!(s.get("t4").is_none());
325 assert!(s.get("t7").is_some());
326 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}