1use std::sync::{
43 Arc,
44 atomic::{AtomicU32, AtomicUsize, Ordering},
45};
46use std::time::{Duration, SystemTime, UNIX_EPOCH};
47
48use serde::Serialize;
49use tokio::io::AsyncWriteExt;
50use tokio::sync::broadcast;
51
52pub const TRACE_CHANNEL_CAPACITY: usize = 1_024;
54pub const MAX_SUBSCRIBERS: usize = 4;
56
57#[derive(Clone, Debug, Serialize)]
61pub struct MatchTraceEvent {
62 pub event_id: u64,
64 pub schema_version: u8,
66 pub received_at_ms: u64,
68 pub duration_ms: u32,
70 pub request: RequestSummary,
72 pub outcome: Outcome,
74 pub dropped_count: u32,
76}
77
78#[derive(Clone, Debug, Serialize)]
80#[non_exhaustive]
81pub struct RequestSummary {
82 pub method: String,
83 pub url_path: String,
84 pub headers: Vec<(String, String)>,
89 #[serde(skip_serializing_if = "Option::is_none")]
92 pub body_json: Option<serde_json::Value>,
93 #[serde(skip_serializing_if = "std::ops::Not::not")]
95 pub body_truncated: bool,
96 #[serde(skip_serializing_if = "Option::is_none")]
110 pub body_len: Option<usize>,
111}
112
113impl RequestSummary {
114 pub fn new(
125 method: String,
126 url_path: String,
127 headers: Vec<(String, String)>,
128 body_len: Option<usize>,
129 config: &TraceConfig,
130 ) -> Self {
131 Self {
132 method,
133 url_path,
134 headers: config.redact_headers(headers),
135 body_json: None,
136 body_truncated: false,
137 body_len,
138 }
139 }
140}
141
142pub const REDACTED_HEADER_VALUE: &str = "[redacted]";
146
147pub const DEFAULT_HEADER_DENYLIST: &[&str] = &[
156 "authorization",
157 "cookie",
158 "set-cookie",
159 "proxy-authorization",
160 "x-api-key",
161];
162
163#[derive(Clone, Debug, PartialEq, Eq)]
166pub enum HeaderRedactionMode {
167 Denylist,
171 Allowlist,
174}
175
176#[derive(Clone, Debug)]
183#[non_exhaustive]
184pub struct TraceConfig {
185 pub capture_body: bool,
187 pub max_body_bytes: usize,
190 pub header_redaction: HeaderRedactionMode,
192 pub header_denylist: Vec<String>,
195 pub header_allowlist: Vec<String>,
200}
201
202impl Default for TraceConfig {
203 fn default() -> Self {
204 Self {
205 capture_body: false,
206 max_body_bytes: 8_192,
207 header_redaction: HeaderRedactionMode::Denylist,
208 header_denylist: DEFAULT_HEADER_DENYLIST
209 .iter()
210 .map(|s| s.to_string())
211 .collect(),
212 header_allowlist: Vec::new(),
213 }
214 }
215}
216
217impl TraceConfig {
218 pub(crate) fn is_header_redacted(&self, name: &str) -> bool {
225 match self.header_redaction {
226 HeaderRedactionMode::Denylist => self
227 .header_denylist
228 .iter()
229 .any(|denied| denied.eq_ignore_ascii_case(name)),
230 HeaderRedactionMode::Allowlist => !self
231 .header_allowlist
232 .iter()
233 .any(|allowed| allowed.eq_ignore_ascii_case(name)),
234 }
235 }
236
237 fn redact_headers(&self, headers: Vec<(String, String)>) -> Vec<(String, String)> {
241 headers
242 .into_iter()
243 .map(|(name, value)| {
244 if self.is_header_redacted(&name) {
245 (name, REDACTED_HEADER_VALUE.to_string())
246 } else {
247 (name, value)
248 }
249 })
250 .collect()
251 }
252}
253
254#[derive(Clone, Debug, Serialize)]
256#[serde(tag = "type", rename_all = "snake_case")]
257pub enum Outcome {
258 Matched {
259 rule_set_index: usize,
260 rule_index: usize,
261 },
262 Fallback {
263 file_path: String,
264 status: u16,
265 },
266 Miss {
267 status: u16,
268 },
269 Error {
270 kind: String,
271 message: String,
272 },
273}
274
275#[derive(Clone)]
281pub struct TraceEmitter {
282 sender: broadcast::Sender<MatchTraceEvent>,
283 event_counter: Arc<AtomicU32>,
284 dropped_counter: Arc<AtomicU32>,
285 pub config: Arc<TraceConfig>,
287}
288
289impl TraceEmitter {
290 pub fn new() -> Self {
291 Self::with_config(TraceConfig::default())
292 }
293
294 pub fn with_config(config: TraceConfig) -> Self {
295 let (sender, _) = broadcast::channel(TRACE_CHANNEL_CAPACITY);
296 Self {
297 sender,
298 event_counter: Arc::new(AtomicU32::new(0)),
299 dropped_counter: Arc::new(AtomicU32::new(0)),
300 config: Arc::new(config),
301 }
302 }
303
304 pub fn subscribe(&self) -> broadcast::Receiver<MatchTraceEvent> {
306 self.sender.subscribe()
307 }
308
309 pub fn enrich_with_body(
312 &self,
313 summary: &mut RequestSummary,
314 body_json: Option<&serde_json::Value>,
315 ) {
316 if !self.config.capture_body {
317 return;
318 }
319 match body_json {
320 None => {} Some(v) => {
322 match serde_json::to_string(v) {
324 Ok(s) if s.len() <= self.config.max_body_bytes => {
325 summary.body_json = Some(v.clone());
326 }
327 Ok(_) => {
328 summary.body_truncated = true;
329 }
330 Err(_) => {} }
332 }
333 }
334 }
335
336 pub fn emit(
339 &self,
340 received_at_ms: u64,
341 duration_ms: u32,
342 request: RequestSummary,
343 outcome: Outcome,
344 ) {
345 let event_id = self.event_counter.fetch_add(1, Ordering::Relaxed) as u64;
346 let dropped_count = self.dropped_counter.swap(0, Ordering::Relaxed);
347
348 let event = MatchTraceEvent {
349 event_id,
350 schema_version: 1,
351 received_at_ms,
352 duration_ms,
353 request,
354 outcome,
355 dropped_count,
356 };
357
358 if self.sender.send(event).is_err() {
359 self.dropped_counter.fetch_add(1, Ordering::Relaxed);
360 }
361 }
362
363 pub fn has_subscribers(&self) -> bool {
365 self.sender.receiver_count() > 0
366 }
367}
368
369impl Default for TraceEmitter {
370 fn default() -> Self {
371 Self::new()
372 }
373}
374
375#[derive(Clone, Debug, Default)]
379pub enum TraceTransportConfig {
380 #[cfg(unix)]
382 Uds { path: String },
383 Tcp { addr: String },
385 #[default]
387 Disabled,
388}
389
390pub struct TraceTransport;
393
394impl TraceTransport {
395 pub async fn accept_loop(config: TraceTransportConfig, emitter: TraceEmitter) {
407 match config {
408 #[cfg(unix)]
409 TraceTransportConfig::Uds { path } => Self::uds_accept_loop(path, emitter).await,
410 TraceTransportConfig::Tcp { addr } => Self::tcp_accept_loop(addr, emitter).await,
411 TraceTransportConfig::Disabled => {
412 }
414 }
415 }
416
417 async fn tcp_accept_loop(addr: String, emitter: TraceEmitter) {
420 let listener = match tokio::net::TcpListener::bind(&addr).await {
421 Ok(l) => {
422 let bound = l
423 .local_addr()
424 .map(|a| a.to_string())
425 .unwrap_or_else(|_| addr.clone());
426 log::info!("trace transport: TCP listening on {}", bound);
427 l
428 }
429 Err(e) => {
430 log::error!("trace transport: failed to bind TCP {}: {}", addr, e);
431 return;
432 }
433 };
434
435 let active = Arc::new(AtomicUsize::new(0));
436 loop {
437 match listener.accept().await {
438 Ok((stream, peer)) => {
439 let count = active.fetch_add(1, Ordering::Relaxed) + 1;
440 if count > MAX_SUBSCRIBERS {
441 active.fetch_sub(1, Ordering::Relaxed);
442 let active_clone = active.clone();
443 tokio::spawn(async move {
444 let (_, mut writer) = tokio::io::split(stream);
445 let _ = writer
446 .write_all(b"{\"error\":\"max_subscribers_reached\"}\n")
447 .await;
448 drop(active_clone);
449 });
450 continue;
451 }
452 log::debug!("trace: TCP subscriber connected from {}", peer);
453 let rx = emitter.subscribe();
454 let active_clone = active.clone();
455 tokio::spawn(async move {
456 let (_, writer) = tokio::io::split(stream);
457 Self::forward_events(writer, rx).await;
458 active_clone.fetch_sub(1, Ordering::Relaxed);
459 log::debug!("trace: TCP subscriber {} disconnected", peer);
460 });
461 }
462 Err(e) => {
463 log::error!("trace: TCP accept error: {}", e);
464 tokio::time::sleep(Duration::from_millis(100)).await;
465 }
466 }
467 }
468 }
469
470 #[cfg(unix)]
473 async fn uds_accept_loop(path: String, emitter: TraceEmitter) {
474 let _ = std::fs::remove_file(&path);
476
477 let listener = match tokio::net::UnixListener::bind(&path) {
478 Ok(l) => {
479 log::info!("trace transport: UDS listening at {}", path);
480 l
481 }
482 Err(e) => {
483 log::error!("trace transport: failed to bind UDS {}: {}", path, e);
484 return;
485 }
486 };
487
488 let active = Arc::new(AtomicUsize::new(0));
489 loop {
490 match listener.accept().await {
491 Ok((stream, _)) => {
492 let count = active.fetch_add(1, Ordering::Relaxed) + 1;
493 if count > MAX_SUBSCRIBERS {
494 active.fetch_sub(1, Ordering::Relaxed);
495 tokio::spawn(async move {
496 let (_, mut writer) = tokio::io::split(stream);
497 let _ = writer
498 .write_all(b"{\"error\":\"max_subscribers_reached\"}\n")
499 .await;
500 });
501 continue;
502 }
503 log::debug!("trace: UDS subscriber connected");
504 let rx = emitter.subscribe();
505 let active_clone = active.clone();
506 tokio::spawn(async move {
507 let (_, writer) = tokio::io::split(stream);
508 Self::forward_events(writer, rx).await;
509 active_clone.fetch_sub(1, Ordering::Relaxed);
510 log::debug!("trace: UDS subscriber disconnected");
511 });
512 }
513 Err(e) => {
514 log::error!("trace: UDS accept error: {}", e);
515 tokio::time::sleep(Duration::from_millis(100)).await;
516 }
517 }
518 }
519 }
520
521 async fn forward_events<W>(mut writer: W, mut rx: broadcast::Receiver<MatchTraceEvent>)
526 where
527 W: tokio::io::AsyncWrite + Unpin,
528 {
529 loop {
530 let event = match rx.recv().await {
531 Ok(e) => e,
532 Err(broadcast::error::RecvError::Lagged(n)) => {
533 log::debug!("trace: subscriber lagged, {} events dropped", n);
537 continue;
538 }
539 Err(broadcast::error::RecvError::Closed) => break,
540 };
541
542 let mut line = match serde_json::to_string(&event) {
543 Ok(s) => s,
544 Err(e) => {
545 log::error!("trace: serialise error: {}", e);
546 continue;
547 }
548 };
549 line.push('\n');
550
551 if writer.write_all(line.as_bytes()).await.is_err() {
552 break; }
554 }
555 }
556}
557
558pub fn now_ms() -> u64 {
562 SystemTime::now()
563 .duration_since(UNIX_EPOCH)
564 .unwrap_or(Duration::ZERO)
565 .as_millis() as u64
566}
567
568#[cfg(test)]
571mod tests {
572 use super::*;
573
574 #[tokio::test]
575 async fn emit_received_by_subscriber() {
576 let emitter = TraceEmitter::new();
577 let mut rx = emitter.subscribe();
578
579 emitter.emit(
580 1_000_000,
581 5,
582 RequestSummary {
583 method: "GET".into(),
584 url_path: "/api/test".into(),
585 headers: vec![],
586 body_json: None,
587 body_truncated: false,
588 body_len: None,
589 },
590 Outcome::Miss { status: 404 },
591 );
592
593 let event = rx.try_recv().expect("event in channel");
594 assert_eq!(event.event_id, 0);
595 assert_eq!(event.schema_version, 1);
596 assert_eq!(event.request.method, "GET");
597 assert_eq!(event.duration_ms, 5);
598 assert_eq!(event.dropped_count, 0);
599 assert!(matches!(event.outcome, Outcome::Miss { status: 404 }));
600 }
601
602 #[tokio::test]
603 async fn emit_no_subscriber_increments_dropped() {
604 let emitter = TraceEmitter::new();
605 emitter.emit(
606 0,
607 0,
608 RequestSummary {
609 method: "GET".into(),
610 url_path: "/".into(),
611 headers: vec![],
612 body_json: None,
613 body_truncated: false,
614 body_len: None,
615 },
616 Outcome::Miss { status: 404 },
617 );
618 let mut rx = emitter.subscribe();
619 emitter.emit(
620 0,
621 0,
622 RequestSummary {
623 method: "GET".into(),
624 url_path: "/".into(),
625 headers: vec![],
626 body_json: None,
627 body_truncated: false,
628 body_len: None,
629 },
630 Outcome::Miss { status: 200 },
631 );
632 let event = rx.try_recv().expect("second event visible");
633 assert_eq!(
634 event.dropped_count, 1,
635 "first event should be counted dropped"
636 );
637 }
638
639 #[test]
640 fn has_subscribers_reflects_state() {
641 let emitter = TraceEmitter::new();
642 assert!(!emitter.has_subscribers());
643 let _rx = emitter.subscribe();
644 assert!(emitter.has_subscribers());
645 }
646
647 #[tokio::test]
648 async fn outcome_serialises_correctly() {
649 let event = MatchTraceEvent {
650 event_id: 7,
651 schema_version: 1,
652 received_at_ms: 0,
653 duration_ms: 0,
654 request: RequestSummary {
655 method: "POST".into(),
656 url_path: "/x".into(),
657 headers: vec![],
658 body_json: None,
659 body_truncated: false,
660 body_len: None,
661 },
662 outcome: Outcome::Matched {
663 rule_set_index: 0,
664 rule_index: 2,
665 },
666 dropped_count: 0,
667 };
668 let json = serde_json::to_string(&event).unwrap();
669 assert!(json.contains("\"type\":\"matched\""));
670 assert!(json.contains("\"rule_index\":2"));
671 assert!(json.contains("\"schema_version\":1"));
672 }
673
674 #[tokio::test]
675 async fn tcp_transport_delivers_events() {
676 let emitter = TraceEmitter::new();
677 let emitter_clone = emitter.clone();
678
679 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
683 let bound_addr = listener.local_addr().unwrap();
684
685 tokio::spawn(async move {
687 let (stream, _) = listener.accept().await.unwrap();
688 let rx = emitter_clone.subscribe();
689 let (_, writer) = tokio::io::split(stream);
690 TraceTransport::forward_events(writer, rx).await;
691 });
692
693 let mut client = tokio::net::TcpStream::connect(bound_addr).await.unwrap();
695
696 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
698
699 emitter.emit(
700 42,
701 3,
702 RequestSummary {
703 method: "GET".into(),
704 url_path: "/ping".into(),
705 headers: vec![],
706 body_json: None,
707 body_truncated: false,
708 body_len: None,
709 },
710 Outcome::Miss { status: 404 },
711 );
712
713 use tokio::io::AsyncBufReadExt;
715 let mut reader = tokio::io::BufReader::new(&mut client);
716 let mut line = String::new();
717 tokio::time::timeout(
718 std::time::Duration::from_secs(2),
719 reader.read_line(&mut line),
720 )
721 .await
722 .expect("timeout")
723 .expect("read ok");
724
725 let value: serde_json::Value = serde_json::from_str(line.trim()).expect("valid JSON");
726 assert_eq!(value["request"]["url_path"], "/ping");
727 assert_eq!(value["outcome"]["type"], "miss");
728 assert_eq!(value["schema_version"], 1);
729 }
730
731 #[test]
734 fn enrich_with_body_disabled_by_default() {
735 let emitter = TraceEmitter::new(); let mut summary = RequestSummary {
737 method: "POST".into(),
738 url_path: "/".into(),
739 headers: vec![],
740 body_json: None,
741 body_truncated: false,
742 body_len: None,
743 };
744 let body = serde_json::json!({"action": "create"});
745 emitter.enrich_with_body(&mut summary, Some(&body));
746 assert!(
747 summary.body_json.is_none(),
748 "body should not be captured when disabled"
749 );
750 assert!(!summary.body_truncated);
751 }
752
753 #[test]
754 fn enrich_with_body_enabled_captures_small_body() {
755 let emitter = TraceEmitter::with_config(TraceConfig {
756 capture_body: true,
757 max_body_bytes: 8_192,
758 ..Default::default()
759 });
760 let mut summary = RequestSummary {
761 method: "POST".into(),
762 url_path: "/".into(),
763 headers: vec![],
764 body_json: None,
765 body_truncated: false,
766 body_len: None,
767 };
768 let body = serde_json::json!({"action": "create", "user_id": 42});
769 emitter.enrich_with_body(&mut summary, Some(&body));
770 assert!(
771 summary.body_json.is_some(),
772 "body should be captured when enabled"
773 );
774 assert_eq!(summary.body_json.unwrap()["action"], "create");
775 assert!(!summary.body_truncated);
776 }
777
778 #[test]
779 fn enrich_with_body_truncates_oversized_body() {
780 let emitter = TraceEmitter::with_config(TraceConfig {
781 capture_body: true,
782 max_body_bytes: 10,
783 ..Default::default()
784 });
785 let mut summary = RequestSummary {
786 method: "POST".into(),
787 url_path: "/".into(),
788 headers: vec![],
789 body_json: None,
790 body_truncated: false,
791 body_len: None,
792 };
793 let body = serde_json::json!({"data": "this is longer than 10 bytes"});
794 emitter.enrich_with_body(&mut summary, Some(&body));
795 assert!(
796 summary.body_json.is_none(),
797 "oversized body should be omitted"
798 );
799 assert!(summary.body_truncated, "body_truncated flag should be set");
800 }
801
802 #[test]
803 fn request_summary_body_json_not_in_serialised_output_when_none() {
804 let summary = RequestSummary {
805 method: "GET".into(),
806 url_path: "/api".into(),
807 headers: vec![],
808 body_json: None,
809 body_truncated: false,
810 body_len: None,
811 };
812 let json = serde_json::to_string(&summary).unwrap();
813 assert!(
814 !json.contains("body_json"),
815 "absent body_json must be skipped"
816 );
817 assert!(
818 !json.contains("body_truncated"),
819 "false body_truncated must be skipped"
820 );
821 }
822
823 fn headers_with_credentials() -> Vec<(String, String)> {
826 vec![
827 ("authorization".into(), "Bearer secret-token".into()),
828 ("cookie".into(), "session=abc123".into()),
829 ("x-api-key".into(), "sk-live-very-secret".into()),
830 ("content-type".into(), "application/json".into()),
831 ]
832 }
833
834 #[test]
838 fn default_config_redacts_credential_headers_in_serialised_output() {
839 let config = TraceConfig::default();
840 let summary = RequestSummary::new(
841 "POST".into(),
842 "/login".into(),
843 headers_with_credentials(),
844 None,
845 &config,
846 );
847
848 let json = serde_json::to_string(&summary).unwrap();
849 assert!(!json.contains("Bearer secret-token"), "json was: {json}");
850 assert!(!json.contains("session=abc123"), "json was: {json}");
851 assert!(!json.contains("sk-live-very-secret"), "json was: {json}");
852 assert!(
853 json.contains("application/json"),
854 "a non-credential header must survive: {json}"
855 );
856 }
857
858 #[test]
861 fn redacted_headers_are_present_and_marked_not_absent() {
862 let config = TraceConfig::default();
863 let summary = RequestSummary::new(
864 "POST".into(),
865 "/login".into(),
866 headers_with_credentials(),
867 None,
868 &config,
869 );
870
871 assert_eq!(summary.headers.len(), 4, "no header should be dropped");
872 let authorization = summary
873 .headers
874 .iter()
875 .find(|(name, _)| name == "authorization")
876 .expect("authorization header must still be present");
877 assert_eq!(authorization.1, REDACTED_HEADER_VALUE);
878
879 let json = serde_json::to_string(&summary).unwrap();
880 assert!(
881 json.contains("\"authorization\""),
882 "redacted header name must still appear: {json}"
883 );
884 assert!(json.contains(REDACTED_HEADER_VALUE), "json was: {json}");
885 }
886
887 #[test]
890 fn denylist_matches_case_insensitively() {
891 let config = TraceConfig::default();
892 let headers = vec![
893 ("Authorization".into(), "Bearer secret-token".into()),
894 ("COOKIE".into(), "session=abc123".into()),
895 ];
896 let summary = RequestSummary::new("GET".into(), "/".into(), headers, None, &config);
897
898 let json = serde_json::to_string(&summary).unwrap();
899 assert!(!json.contains("Bearer secret-token"), "json was: {json}");
900 assert!(!json.contains("session=abc123"), "json was: {json}");
901 assert!(json.contains(REDACTED_HEADER_VALUE), "json was: {json}");
902 }
903
904 #[test]
908 fn allowlist_mode_redacts_everything_not_listed() {
909 let config = TraceConfig {
910 header_redaction: HeaderRedactionMode::Allowlist,
911 header_allowlist: vec!["content-type".into()],
912 ..Default::default()
913 };
914 let headers = vec![
915 ("content-type".into(), "application/json".into()),
916 ("authorization".into(), "Bearer secret-token".into()),
917 ("x-request-id".into(), "not-a-credential".into()),
918 ];
919 let summary = RequestSummary::new("GET".into(), "/".into(), headers, None, &config);
920
921 let by_name = |name: &str| {
922 summary
923 .headers
924 .iter()
925 .find(|(n, _)| n == name)
926 .map(|(_, v)| v.as_str())
927 };
928 assert_eq!(by_name("content-type"), Some("application/json"));
929 assert_eq!(by_name("authorization"), Some(REDACTED_HEADER_VALUE));
930 assert_eq!(
931 by_name("x-request-id"),
932 Some(REDACTED_HEADER_VALUE),
933 "an unlisted, non-credential header must still be redacted in allowlist mode"
934 );
935 }
936
937 #[test]
941 fn allowlist_mode_with_no_entries_redacts_everything() {
942 let config = TraceConfig {
943 header_redaction: HeaderRedactionMode::Allowlist,
944 ..Default::default()
945 };
946 let summary = RequestSummary::new(
947 "GET".into(),
948 "/".into(),
949 vec![("content-type".into(), "application/json".into())],
950 None,
951 &config,
952 );
953 assert_eq!(summary.headers[0].1, REDACTED_HEADER_VALUE);
954 }
955
956 #[test]
966 fn three_body_states_are_distinguishable_in_the_serialised_form() {
967 let config = TraceConfig::default();
968
969 let no_body = RequestSummary::new("GET".into(), "/".into(), vec![], None, &config);
970 let no_body_json = serde_json::to_string(&no_body).unwrap();
971 assert!(!no_body_json.contains("body_json"), "{no_body_json}");
972 assert!(!no_body_json.contains("body_len"), "{no_body_json}");
973
974 let mut json_captured =
975 RequestSummary::new("POST".into(), "/".into(), vec![], Some(11), &config);
976 let emitter = TraceEmitter::with_config(TraceConfig {
977 capture_body: true,
978 ..Default::default()
979 });
980 emitter.enrich_with_body(&mut json_captured, Some(&serde_json::json!({"a": 1})));
981 let json_captured_str = serde_json::to_string(&json_captured).unwrap();
982 assert!(
983 json_captured_str.contains("\"body_json\""),
984 "{json_captured_str}"
985 );
986 assert!(
987 json_captured_str.contains("\"body_len\":11"),
988 "a JSON-captured body must still report its length: {json_captured_str}"
989 );
990
991 let body_present_not_captured =
992 RequestSummary::new("POST".into(), "/".into(), vec![], Some(27), &config);
993 let not_captured_str = serde_json::to_string(&body_present_not_captured).unwrap();
994 assert!(
995 !not_captured_str.contains("body_json"),
996 "{not_captured_str}"
997 );
998 assert!(
999 not_captured_str.contains("\"body_len\":27"),
1000 "{not_captured_str}"
1001 );
1002 }
1003
1004 #[test]
1008 fn non_json_body_reports_length_but_never_content() {
1009 let config = TraceConfig::default();
1010 let summary = RequestSummary::new("POST".into(), "/".into(), vec![], Some(32), &config);
1011 let json = serde_json::to_string(&summary).unwrap();
1012
1013 assert!(json.contains("\"body_len\":32"), "json was: {json}");
1014 assert!(
1015 !json.contains("username") && !json.contains("hunter2"),
1016 "no fragment of a body — captured or not — should appear: {json}"
1017 );
1018 }
1019}