Skip to main content

featherbit/debug/
trace.rs

1//! Trace records and context snapshots.
2//!
3//! A [`Trace`] is one policy execution — a live request or a sandbox run —
4//! recorded as an initial [`ContextSnapshot`] plus one [`NodeStep`] per node
5//! the engine walked.
6//!
7//! # Why one snapshot per step, not before/after pairs
8//!
9//! The [`Context`] flows strictly linearly: every plugin takes it by value and
10//! hands it back in both the `Ok` and `Err` arms of [`PluginResult`], so
11//! `before(step N) == after(step N - 1)` and `before(step 0) == Trace::initial`
12//! are guaranteed by the plugin contract itself. Storing explicit pairs would
13//! double memory for exactly zero information.
14//!
15//! The "what did this plugin change" view is therefore *derived*, not stored:
16//! see [`crate::debug::diff`], which is computed at read time so the traced
17//! request path stays cheap.
18//!
19//! [`PluginResult`]: crate::plugins::PluginResult
20
21use std::collections::{BTreeMap, HashSet};
22
23use bytes::Bytes;
24use serde::Serialize;
25
26use crate::context::{Context, GatewayError};
27
28/// Placeholder substituted for any value the redaction policy matches.
29pub const REDACTED: &str = "<redacted>";
30
31/// Header names always redacted, regardless of configuration.
32const 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
47/// Query parameters always redacted. OAuth/OIDC put codes and tokens in the
48/// query string, so omitting these would leak on every `openid-connect` trace.
49const 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
61/// Substrings that mark a `context.message` key as secret-bearing.
62///
63/// `message` is free-form (plugins and Lua scripts write whatever they like),
64/// so substring matching is the only defence that scales. Deliberately **not**
65/// bare `key`: that would redact `consumer.key_id`, which is exactly what a
66/// developer needs to see.
67const 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/// Case-insensitive denylists used when capturing a snapshot.
79///
80/// Built-ins are always applied; configured names *extend* them rather than
81/// replacing them, so an operator cannot accidentally widen exposure by
82/// setting a short custom list.
83#[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    /// Builds the policy from the configured extra names.
99    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/// How a captured body was handled.
137#[derive(Debug, Clone, Default, Serialize, PartialEq)]
138pub struct BodyCapture {
139    /// Byte length of the real body. Always recorded — it is free, and answers
140    /// "did the body change size?" without capturing the content.
141    pub len: usize,
142    /// The body text, present only when body capture is enabled, the body
143    /// changed since the previous step, and the content is valid UTF-8.
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub text: Option<String>,
146    /// True when `text` was clipped to the configured limit.
147    #[serde(skip_serializing_if = "std::ops::Not::not", default)]
148    pub truncated: bool,
149    /// True when the body is byte-identical to the previous step's, so the
150    /// text was deliberately not repeated.
151    #[serde(skip_serializing_if = "std::ops::Not::not", default)]
152    pub unchanged: bool,
153    /// True when the body is not valid UTF-8 (an image, blob, …). `text` is
154    /// omitted rather than rendered as replacement-character mojibake; `len`
155    /// still reports the true size.
156    #[serde(skip_serializing_if = "std::ops::Not::not", default)]
157    pub binary: bool,
158    /// True when the response body was streamed to the client unbuffered
159    /// (`ctx.response.stream` was set) rather than buffered in `ctx.response.body`.
160    /// The stream itself is never read to produce this snapshot — the client is
161    /// its only legitimate consumer — so `len`/`text`/`truncated`/`binary` above
162    /// are all left at their defaults in this case.
163    #[serde(skip_serializing_if = "std::ops::Not::not", default)]
164    pub streamed: bool,
165}
166
167/// Redacted view of `Context.request`.
168#[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/// Redacted view of `Context.response`.
180#[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/// A redacted point-in-time view of the whole [`Context`].
188///
189/// `BTreeMap` throughout (not `HashMap`) so key order is deterministic: stable
190/// UI rendering, stable diffs, stable test assertions.
191#[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/// Knobs for capturing a snapshot, resolved from `DebugConfig`.
200#[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/// The bodies seen at the previous step, used to avoid storing an unchanged
218/// body once per node. Cloning `Bytes` is a refcount bump, not a copy.
219#[derive(Debug, Clone, Default)]
220pub struct PreviousBodies {
221    pub request: Option<Bytes>,
222    pub response: Option<Bytes>,
223}
224
225impl ContextSnapshot {
226    /// Captures `ctx`, applying redaction **at capture time**.
227    ///
228    /// Redaction is never deferred to read time: a secret must not enter the
229    /// trace buffer at all, because the buffer outlives the request.
230    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                    // Never read the stream to snapshot it: the client is the
250                    // only legitimate consumer, and reading here would
251                    // swallow the events. Record only that it streamed.
252                    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
277/// Redacts a header/query map into a deterministic `BTreeMap`, preserving the
278/// arity of multi-valued entries so the shape of the request is still visible.
279fn 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
295/// Records the body length always, and the text only when capture is on and the
296/// bytes differ from the previous step.
297fn 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    // Classify the content. A valid-UTF-8 body is shown as text; a genuinely
322    // binary one (image, blob, ...) is not rendered — mojibake helps no one —
323    // only its size is reported. The subtlety: a *text* body clipped by the
324    // limit can end mid-multibyte-char, which is not binary. `Utf8Error`
325    // distinguishes the two: `error_len() == None` means "unexpected end" (our
326    // truncation split a char), while `Some(_)` means an invalid byte in the
327    // middle (real binary).
328    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            // Valid text, cut mid-char: keep the valid prefix.
337            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/// Whether a node returned success or routed through its error port.
355#[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/// Which edge the engine followed after a node — including the cases where
363/// there was none, which is what makes an unwired `error` port visible.
364#[derive(Debug, Clone, Copy, Serialize, PartialEq)]
365#[serde(rename_all = "snake_case")]
366pub enum EdgeKind {
367    /// Followed the node's success edge.
368    Success,
369    /// Followed a named outcome port (see [`NodeStep::port`]).
370    Outcome,
371    /// Followed the node's own `error` edge.
372    Error,
373    /// Fell through to the policy-level `error_handler`.
374    CatchAll,
375    /// Stopped at a terminal `client` node.
376    Terminal,
377    /// Succeeded but had no success edge to follow.
378    EndOfChain,
379    /// Errored with neither an error edge nor a catch-all — the engine wrote a
380    /// generic 500 and stopped.
381    Unhandled,
382    /// The edge pointed at a node id that does not exist.
383    NodeNotFound,
384}
385
386/// One node execution.
387#[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    /// Set when the step left on a named outcome port (e.g. "denied").
396    #[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    /// The context as it stood *after* this node ran.
401    pub after: ContextSnapshot,
402}
403
404/// Whether a trace came from live traffic or a sandbox run.
405#[derive(Debug, Clone, Copy, Serialize, PartialEq)]
406#[serde(rename_all = "snake_case")]
407pub enum TraceSource {
408    Request,
409    Sandbox,
410}
411
412/// One complete policy execution.
413#[derive(Debug, Clone, Serialize)]
414pub struct Trace {
415    pub id: String,
416    /// Monotonic sequence number, giving stable newest-first ordering
417    /// independent of clock skew.
418    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    /// Human-readable notes about the recording itself (limits hit, etc.).
432    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    /// A streamed response must be traceable without consuming the stream:
489    /// headers and status are recorded, the body is flagged rather than read.
490    #[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        // The size is always useful and always free; the content is not captured.
527        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        // The true length is still reported, not the truncated one.
552        assert_eq!(s.request.body.len, 12);
553    }
554
555    /// A binary body (e.g. a PNG) must be labelled, never rendered as mojibake,
556    /// and must never break capture.
557    #[test]
558    fn test_binary_body_is_flagged_not_rendered() {
559        let mut c = ctx();
560        // PNG magic bytes + a NUL — not valid UTF-8.
561        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    /// Valid UTF-8 text that happens to be clipped mid-multibyte-char is text,
575    /// not binary — the truncation must not be mistaken for corruption.
576    #[test]
577    fn test_multibyte_text_truncated_midchar_is_still_text() {
578        let mut c = ctx();
579        // "é" is two bytes (0xc3 0xa9); a 3-byte limit cuts the second one.
580        c.request.body = Bytes::from_static("aéb".as_bytes()); // a, é(2 bytes), b = 4 bytes
581        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        // Byte 2 splits 'é', so only "a" is valid — kept as text, flagged truncated.
588        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        // The response body did change, so it is captured.
611        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        // OAuth codes live in the query string -- redacting headers alone is not enough.
620        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    /// The substring rule must not swallow `consumer.key_id` -- that is exactly
626    /// the value a developer is trying to see.
627    #[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        // Two cookies were set: that shape is visible, the values are not.
658        assert_eq!(s.response.headers["set-cookie"], vec![REDACTED, REDACTED]);
659    }
660
661    /// Configured names must *extend* the built-ins, never replace them.
662    #[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        // ...and the built-ins still apply.
682        assert_eq!(s.request.headers["authorization"], vec![REDACTED]);
683    }
684}