1use std::collections::{BTreeMap, HashSet};
22
23use bytes::Bytes;
24use serde::Serialize;
25
26use crate::context::{Context, GatewayError};
27
28pub const REDACTED: &str = "<redacted>";
30
31const DEFAULT_REDACT_HEADERS: &[&str] = &[
33 "authorization",
34 "proxy-authorization",
35 "cookie",
36 "set-cookie",
37 "x-api-key",
38 "api-key",
39 "apikey",
40 "x-auth-token",
41 "x-access-token",
42 "x-csrf-token",
43 "x-amz-security-token",
44 "x-forwarded-client-cert",
45];
46
47const DEFAULT_REDACT_QUERY: &[&str] = &[
50 "access_token",
51 "id_token",
52 "refresh_token",
53 "token",
54 "api_key",
55 "apikey",
56 "code",
57 "client_secret",
58 "state",
59];
60
61const DEFAULT_REDACT_MESSAGE_SUBSTRINGS: &[&str] = &[
68 "secret",
69 "password",
70 "passwd",
71 "token",
72 "credential",
73 "private_key",
74 "authorization",
75 "jwt",
76];
77
78#[derive(Debug, Clone)]
84pub struct RedactionPolicy {
85 headers: HashSet<String>,
86 query_params: HashSet<String>,
87 message_keys: HashSet<String>,
88 message_substrings: Vec<String>,
89}
90
91impl Default for RedactionPolicy {
92 fn default() -> Self {
93 Self::new(&[], &[], &[])
94 }
95}
96
97impl RedactionPolicy {
98 pub fn new(headers: &[String], query_params: &[String], message_keys: &[String]) -> Self {
100 let lower = |extra: &[String], builtin: &[&str]| -> HashSet<String> {
101 builtin
102 .iter()
103 .map(|s| s.to_string())
104 .chain(extra.iter().map(|s| s.to_lowercase()))
105 .collect()
106 };
107 Self {
108 headers: lower(headers, DEFAULT_REDACT_HEADERS),
109 query_params: lower(query_params, DEFAULT_REDACT_QUERY),
110 message_keys: message_keys.iter().map(|s| s.to_lowercase()).collect(),
111 message_substrings: DEFAULT_REDACT_MESSAGE_SUBSTRINGS
112 .iter()
113 .map(|s| s.to_string())
114 .collect(),
115 }
116 }
117
118 fn header_is_secret(&self, name: &str) -> bool {
119 self.headers.contains(&name.to_lowercase())
120 }
121
122 fn query_is_secret(&self, name: &str) -> bool {
123 self.query_params.contains(&name.to_lowercase())
124 }
125
126 fn message_is_secret(&self, key: &str) -> bool {
127 let lower = key.to_lowercase();
128 self.message_keys.contains(&lower)
129 || self
130 .message_substrings
131 .iter()
132 .any(|s| lower.contains(s.as_str()))
133 }
134}
135
136#[derive(Debug, Clone, Default, Serialize, PartialEq)]
138pub struct BodyCapture {
139 pub len: usize,
142 #[serde(skip_serializing_if = "Option::is_none")]
145 pub text: Option<String>,
146 #[serde(skip_serializing_if = "std::ops::Not::not", default)]
148 pub truncated: bool,
149 #[serde(skip_serializing_if = "std::ops::Not::not", default)]
152 pub unchanged: bool,
153 #[serde(skip_serializing_if = "std::ops::Not::not", default)]
157 pub binary: bool,
158 #[serde(skip_serializing_if = "std::ops::Not::not", default)]
164 pub streamed: bool,
165}
166
167#[derive(Debug, Clone, Serialize, PartialEq)]
169pub struct RequestSnapshot {
170 pub method: String,
171 pub path: String,
172 pub host: String,
173 pub scheme: String,
174 pub headers: BTreeMap<String, Vec<String>>,
175 pub query_params: BTreeMap<String, Vec<String>>,
176 pub body: BodyCapture,
177}
178
179#[derive(Debug, Clone, Serialize, PartialEq)]
181pub struct ResponseSnapshot {
182 pub status_code: u16,
183 pub headers: BTreeMap<String, Vec<String>>,
184 pub body: BodyCapture,
185}
186
187#[derive(Debug, Clone, Serialize, PartialEq)]
192pub struct ContextSnapshot {
193 pub request: RequestSnapshot,
194 pub response: ResponseSnapshot,
195 pub message: BTreeMap<String, serde_json::Value>,
196 pub errors: Vec<GatewayError>,
197}
198
199#[derive(Debug, Clone)]
201pub struct CaptureOptions {
202 pub capture_bodies: bool,
203 pub max_body_bytes: usize,
204 pub redaction: RedactionPolicy,
205}
206
207impl Default for CaptureOptions {
208 fn default() -> Self {
209 Self {
210 capture_bodies: false,
211 max_body_bytes: 8192,
212 redaction: RedactionPolicy::default(),
213 }
214 }
215}
216
217#[derive(Debug, Clone, Default)]
220pub struct PreviousBodies {
221 pub request: Option<Bytes>,
222 pub response: Option<Bytes>,
223}
224
225impl ContextSnapshot {
226 pub fn capture(ctx: &Context, opts: &CaptureOptions, prev: &PreviousBodies) -> Self {
231 Self {
232 request: RequestSnapshot {
233 method: ctx.request.method.clone(),
234 path: ctx.request.path.clone(),
235 host: ctx.request.host.clone(),
236 scheme: ctx.request.scheme.clone(),
237 headers: redact_map(&ctx.request.headers, |k| opts.redaction.header_is_secret(k)),
238 query_params: redact_map(&ctx.request.query_params, |k| {
239 opts.redaction.query_is_secret(k)
240 }),
241 body: capture_body(&ctx.request.body, prev.request.as_ref(), opts),
242 },
243 response: ResponseSnapshot {
244 status_code: ctx.response.status_code,
245 headers: redact_map(&ctx.response.headers, |k| {
246 opts.redaction.header_is_secret(k)
247 }),
248 body: if ctx.response.stream.is_some() {
249 BodyCapture {
253 streamed: true,
254 ..Default::default()
255 }
256 } else {
257 capture_body(&ctx.response.body, prev.response.as_ref(), opts)
258 },
259 },
260 message: ctx
261 .message
262 .iter()
263 .map(|(k, v)| {
264 let value = if opts.redaction.message_is_secret(k) {
265 serde_json::Value::String(REDACTED.to_string())
266 } else {
267 v.clone()
268 };
269 (k.clone(), value)
270 })
271 .collect(),
272 errors: ctx.errors.clone(),
273 }
274 }
275}
276
277fn redact_map(
280 src: &std::collections::HashMap<String, Vec<String>>,
281 is_secret: impl Fn(&str) -> bool,
282) -> BTreeMap<String, Vec<String>> {
283 src.iter()
284 .map(|(k, values)| {
285 let out = if is_secret(k) {
286 values.iter().map(|_| REDACTED.to_string()).collect()
287 } else {
288 values.clone()
289 };
290 (k.clone(), out)
291 })
292 .collect()
293}
294
295fn capture_body(body: &Bytes, prev: Option<&Bytes>, opts: &CaptureOptions) -> BodyCapture {
298 let len = body.len();
299 if !opts.capture_bodies {
300 return BodyCapture {
301 len,
302 ..Default::default()
303 };
304 }
305 if let Some(p) = prev {
306 if p == body {
307 return BodyCapture {
308 len,
309 unchanged: true,
310 ..Default::default()
311 };
312 }
313 }
314 let truncated = len > opts.max_body_bytes;
315 let slice = if truncated {
316 &body[..opts.max_body_bytes]
317 } else {
318 &body[..]
319 };
320
321 match std::str::from_utf8(slice) {
329 Ok(s) => BodyCapture {
330 len,
331 text: Some(s.to_string()),
332 truncated,
333 ..Default::default()
334 },
335 Err(e) if e.error_len().is_none() => {
336 let valid = std::str::from_utf8(&slice[..e.valid_up_to()]).unwrap_or("");
338 BodyCapture {
339 len,
340 text: Some(valid.to_string()),
341 truncated: true,
342 ..Default::default()
343 }
344 }
345 Err(_) => BodyCapture {
346 len,
347 binary: true,
348 truncated,
349 ..Default::default()
350 },
351 }
352}
353
354#[derive(Debug, Clone, Serialize, PartialEq)]
356#[serde(tag = "kind", rename_all = "snake_case")]
357pub enum StepOutcome {
358 Success,
359 Error { code: String, message: String },
360}
361
362#[derive(Debug, Clone, Copy, Serialize, PartialEq)]
365#[serde(rename_all = "snake_case")]
366pub enum EdgeKind {
367 Success,
369 Outcome,
371 Error,
373 CatchAll,
375 Terminal,
377 EndOfChain,
379 Unhandled,
382 NodeNotFound,
384}
385
386#[derive(Debug, Clone, Serialize)]
388pub struct NodeStep {
389 pub index: usize,
390 pub node_id: String,
391 pub node_type: String,
392 pub outcome: StepOutcome,
393 pub duration_us: u64,
394 pub edge: EdgeKind,
395 #[serde(skip_serializing_if = "Option::is_none")]
397 pub port: Option<String>,
398 #[serde(skip_serializing_if = "Option::is_none")]
399 pub next_node_id: Option<String>,
400 pub after: ContextSnapshot,
402}
403
404#[derive(Debug, Clone, Copy, Serialize, PartialEq)]
406#[serde(rename_all = "snake_case")]
407pub enum TraceSource {
408 Request,
409 Sandbox,
410}
411
412#[derive(Debug, Clone, Serialize)]
414pub struct Trace {
415 pub id: String,
416 pub seq: u64,
419 pub source: TraceSource,
420 pub started_ms: u64,
421 #[serde(skip_serializing_if = "Option::is_none")]
422 pub route: Option<String>,
423 pub policy: String,
424 pub method: String,
425 pub path: String,
426 pub status: u16,
427 pub duration_us: u64,
428 pub captured_bodies: bool,
429 pub initial: ContextSnapshot,
430 pub steps: Vec<NodeStep>,
431 pub notes: Vec<String>,
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
439 use std::collections::HashMap;
440
441 fn ctx() -> Context {
442 let mut headers = HashMap::new();
443 headers.insert(
444 "authorization".to_string(),
445 vec!["Bearer supersecret".to_string()],
446 );
447 headers.insert("accept".to_string(), vec!["application/json".to_string()]);
448 let mut query = HashMap::new();
449 query.insert("code".to_string(), vec!["oauth-code-xyz".to_string()]);
450 query.insert("page".to_string(), vec!["2".to_string()]);
451 let mut message = HashMap::new();
452 message.insert(
453 "consumer.key_id".to_string(),
454 serde_json::json!("alice-key"),
455 );
456 message.insert("access_token".to_string(), serde_json::json!("tok-123"));
457 message.insert("user_id".to_string(), serde_json::json!("alice"));
458 Context {
459 request: GatewayRequest {
460 method: "POST".to_string(),
461 path: "/api/hello".to_string(),
462 host: "h".to_string(),
463 scheme: "http".to_string(),
464 headers,
465 query_params: query,
466 body: Bytes::from_static(b"request-body"),
467 remote_addr: "1.2.3.4:5".to_string(),
468 protocol: Protocol::Http1,
469 },
470 response: GatewayResponse {
471 status_code: 200,
472 headers: HashMap::new(),
473 body: Bytes::from_static(b"response-body"),
474 stream: None,
475 },
476 message,
477 errors: Vec::new(),
478 }
479 }
480
481 fn opts(capture_bodies: bool) -> CaptureOptions {
482 CaptureOptions {
483 capture_bodies,
484 ..Default::default()
485 }
486 }
487
488 #[test]
491 fn test_trace_marks_streamed_body_without_consuming_it() {
492 use crate::context::stream::ResponseStream;
493 use http_body_util::{BodyExt, Full};
494
495 let mut c = ctx();
496 c.response.status_code = 200;
497 let boxed = Full::new(Bytes::from_static(b"data: one\n\n"))
498 .map_err(|never| match never {})
499 .boxed();
500 c.response.stream = Some(ResponseStream::new(boxed));
501
502 let snapshot = ContextSnapshot::capture(&c, &opts(true), &PreviousBodies::default());
503
504 assert_eq!(snapshot.response.status_code, 200);
505 assert!(
506 snapshot.response.body.streamed,
507 "streamed body must be flagged"
508 );
509 assert_eq!(
510 snapshot.response.body.len, 0,
511 "a streamed body is never captured"
512 );
513 assert!(
514 snapshot.response.body.text.is_none(),
515 "a streamed body's text must never be captured"
516 );
517 assert!(
518 c.response.stream.is_some(),
519 "snapshotting must not take the stream"
520 );
521 }
522
523 #[test]
524 fn test_body_length_recorded_but_text_withheld_by_default() {
525 let s = ContextSnapshot::capture(&ctx(), &opts(false), &PreviousBodies::default());
526 assert_eq!(s.request.body.len, 12);
528 assert_eq!(s.response.body.len, 13);
529 assert!(s.request.body.text.is_none());
530 assert!(s.response.body.text.is_none());
531 }
532
533 #[test]
534 fn test_bodies_captured_when_enabled() {
535 let s = ContextSnapshot::capture(&ctx(), &opts(true), &PreviousBodies::default());
536 assert_eq!(s.request.body.text.as_deref(), Some("request-body"));
537 assert_eq!(s.response.body.text.as_deref(), Some("response-body"));
538 assert!(!s.request.body.truncated);
539 }
540
541 #[test]
542 fn test_body_truncated_at_limit() {
543 let o = CaptureOptions {
544 capture_bodies: true,
545 max_body_bytes: 4,
546 ..Default::default()
547 };
548 let s = ContextSnapshot::capture(&ctx(), &o, &PreviousBodies::default());
549 assert_eq!(s.request.body.text.as_deref(), Some("requ"));
550 assert!(s.request.body.truncated);
551 assert_eq!(s.request.body.len, 12);
553 }
554
555 #[test]
558 fn test_binary_body_is_flagged_not_rendered() {
559 let mut c = ctx();
560 c.response.body = Bytes::from_static(&[0x89, b'P', b'N', b'G', 0x0d, 0x00, 0xff, 0xfe]);
562 let s = ContextSnapshot::capture(&c, &opts(true), &PreviousBodies::default());
563 assert!(
564 s.response.body.binary,
565 "non-UTF-8 body should be flagged binary"
566 );
567 assert!(
568 s.response.body.text.is_none(),
569 "binary body must not be rendered as text"
570 );
571 assert_eq!(s.response.body.len, 8, "true size still reported");
572 }
573
574 #[test]
577 fn test_multibyte_text_truncated_midchar_is_still_text() {
578 let mut c = ctx();
579 c.request.body = Bytes::from_static("aéb".as_bytes()); let o = CaptureOptions {
582 capture_bodies: true,
583 max_body_bytes: 2,
584 ..Default::default()
585 };
586 let s = ContextSnapshot::capture(&c, &o, &PreviousBodies::default());
587 assert!(
589 !s.request.body.binary,
590 "truncated text must not be called binary"
591 );
592 assert_eq!(s.request.body.text.as_deref(), Some("a"));
593 assert!(s.request.body.truncated);
594 }
595
596 #[test]
597 fn test_unchanged_body_is_not_repeated() {
598 let c = ctx();
599 let prev = PreviousBodies {
600 request: Some(c.request.body.clone()),
601 response: Some(Bytes::from_static(b"different")),
602 };
603 let s = ContextSnapshot::capture(&c, &opts(true), &prev);
604 assert!(
605 s.request.body.unchanged,
606 "identical body should not be stored again"
607 );
608 assert!(s.request.body.text.is_none());
609 assert_eq!(s.request.body.len, 12, "length still recorded when deduped");
610 assert_eq!(s.response.body.text.as_deref(), Some("response-body"));
612 }
613
614 #[test]
615 fn test_default_denylists_redact() {
616 let s = ContextSnapshot::capture(&ctx(), &opts(false), &PreviousBodies::default());
617 assert_eq!(s.request.headers["authorization"], vec![REDACTED]);
618 assert_eq!(s.request.headers["accept"], vec!["application/json"]);
619 assert_eq!(s.request.query_params["code"], vec![REDACTED]);
621 assert_eq!(s.request.query_params["page"], vec!["2"]);
622 assert_eq!(s.message["access_token"], serde_json::json!(REDACTED));
623 }
624
625 #[test]
628 fn test_key_id_survives_redaction() {
629 let s = ContextSnapshot::capture(&ctx(), &opts(false), &PreviousBodies::default());
630 assert_eq!(s.message["consumer.key_id"], serde_json::json!("alice-key"));
631 assert_eq!(s.message["user_id"], serde_json::json!("alice"));
632 }
633
634 #[test]
635 fn test_redaction_is_case_insensitive() {
636 let mut c = ctx();
637 c.request.headers.clear();
638 c.request
639 .headers
640 .insert("Authorization".to_string(), vec!["x".to_string()]);
641 c.request
642 .headers
643 .insert("X-API-Key".to_string(), vec!["y".to_string()]);
644 let s = ContextSnapshot::capture(&c, &opts(false), &PreviousBodies::default());
645 assert_eq!(s.request.headers["Authorization"], vec![REDACTED]);
646 assert_eq!(s.request.headers["X-API-Key"], vec![REDACTED]);
647 }
648
649 #[test]
650 fn test_multi_valued_header_arity_preserved() {
651 let mut c = ctx();
652 c.response.headers.insert(
653 "set-cookie".to_string(),
654 vec!["a=1".to_string(), "b=2".to_string()],
655 );
656 let s = ContextSnapshot::capture(&c, &opts(false), &PreviousBodies::default());
657 assert_eq!(s.response.headers["set-cookie"], vec![REDACTED, REDACTED]);
659 }
660
661 #[test]
663 fn test_config_extends_builtin_denylist() {
664 let o = CaptureOptions {
665 redaction: RedactionPolicy::new(
666 &["x-custom-secret".to_string()],
667 &[],
668 &["tenant".to_string()],
669 ),
670 ..Default::default()
671 };
672 let mut c = ctx();
673 c.request
674 .headers
675 .insert("x-custom-secret".to_string(), vec!["s".to_string()]);
676 c.message
677 .insert("tenant".to_string(), serde_json::json!("acme"));
678 let s = ContextSnapshot::capture(&c, &o, &PreviousBodies::default());
679 assert_eq!(s.request.headers["x-custom-secret"], vec![REDACTED]);
680 assert_eq!(s.message["tenant"], serde_json::json!(REDACTED));
681 assert_eq!(s.request.headers["authorization"], vec![REDACTED]);
683 }
684}