Skip to main content

cli_engine/transport/
debug_logger.rs

1//! Stderr sink for transport debug events.
2//!
3//! [`StderrTransportLogger`] renders [`TransportLogEvent`]s as a curl-style
4//! request/response trace on stderr. It is the logger the CLI installs when
5//! `--debug` selects the `transport` component. Sensitive headers are redacted,
6//! but URLs and request/response bodies are printed in full and may still
7//! contain secrets — treat the output as sensitive before sharing it.
8
9use std::io::Write;
10
11use super::client::{TransportLogEvent, TransportLogger};
12
13/// Header names whose values are replaced with `<redacted>` in the dump.
14const SENSITIVE_HEADERS: &[&str] = &[
15    "authorization",
16    "proxy-authorization",
17    "cookie",
18    "set-cookie",
19    "x-api-key",
20];
21
22const REDACTED: &str = "<redacted>";
23
24/// Transport logger that prints a redacted, curl-style HTTP trace to stderr.
25///
26/// Outbound requests are prefixed with `>` and responses with `<`:
27///
28/// ```text
29/// > POST https://api.example.com/v3/repos
30/// > authorization: <redacted>
31/// > content-type: application/json
32/// >
33/// > {"name":"foo"}
34///
35/// < 200 POST https://api.example.com/v3/repos
36/// < content-type: application/json
37/// <
38/// < {"id":"repo-1"}
39/// ```
40///
41/// Bodies are printed for JSON/decode paths; raw byte-download responses report
42/// only their size. Sensitive header values (`authorization`,
43/// `proxy-authorization`, `cookie`, `set-cookie`, `x-api-key`) are redacted.
44#[derive(Clone, Debug, Default)]
45pub struct StderrTransportLogger {
46    /// Extra header names to redact, in addition to the built-in set. Stored
47    /// lowercased; matching is case-insensitive.
48    extra_redacted: Vec<String>,
49}
50
51impl StderrTransportLogger {
52    /// Creates a stderr transport logger that redacts the built-in sensitive
53    /// header set.
54    #[must_use]
55    pub fn new() -> Self {
56        Self::default()
57    }
58
59    /// Adds header names to redact on top of the built-in set.
60    ///
61    /// Use this for CLI-specific secret-bearing headers that are not standard
62    /// auth headers — for example a custom API-key header an auth injector sets.
63    /// Names are matched case-insensitively. Additive only: the built-in set
64    /// (`authorization`, `proxy-authorization`, `cookie`, `set-cookie`,
65    /// `x-api-key`) is always redacted. Names are trimmed and empty entries are
66    /// dropped, so a stray-whitespace config value cannot silently fail to
67    /// match (which would leak the header).
68    #[must_use]
69    pub fn with_redacted_headers(
70        mut self,
71        names: impl IntoIterator<Item = impl Into<String>>,
72    ) -> Self {
73        self.extra_redacted
74            .extend(names.into_iter().filter_map(|name| {
75                let name = name.into().trim().to_ascii_lowercase();
76                (!name.is_empty()).then_some(name)
77            }));
78        self
79    }
80
81    fn is_sensitive(&self, name: &str) -> bool {
82        SENSITIVE_HEADERS
83            .iter()
84            .any(|candidate| name.eq_ignore_ascii_case(candidate))
85            || self
86                .extra_redacted
87                .iter()
88                .any(|candidate| name.eq_ignore_ascii_case(candidate))
89    }
90
91    /// Renders a single transport event into its stderr representation.
92    ///
93    /// Kept private (not `pub`) so unit tests can assert on the formatted text
94    /// without capturing the process stderr stream.
95    fn format_event(&self, event: &TransportLogEvent) -> String {
96        match event.message {
97            "http request" => {
98                let method = field(event, "method").unwrap_or("?");
99                let url = field(event, "url").unwrap_or("?");
100                let mut out = format!("> {method} {url}\n");
101                self.append_headers(&mut out, ">", event);
102                append_body(&mut out, ">", event);
103                out
104            }
105            "http response" => {
106                // Method/url are absent on responses logged via the
107                // reqwest-direct helper, so omit them rather than rendering
108                // trailing spaces (`< 200  `).
109                let status = field(event, "status").unwrap_or("?");
110                let suffix = match (field(event, "method"), field(event, "url")) {
111                    (Some(method), Some(url)) => format!(" {method} {url}"),
112                    (Some(value), None) | (None, Some(value)) => format!(" {value}"),
113                    (None, None) => String::new(),
114                };
115                let mut out = format!("< {status}{suffix}\n");
116                self.append_headers(&mut out, "<", event);
117                append_body(&mut out, "<", event);
118                out
119            }
120            "retrying request" => {
121                let attempt = field(event, "attempt").unwrap_or("?");
122                let backoff = field(event, "backoff").unwrap_or("?");
123                format!("* retrying (attempt {attempt}, backoff {backoff})\n")
124            }
125            other => {
126                let mut out = format!("* {other}");
127                for (key, value) in &event.fields {
128                    out.push_str(&format!(" {key}={value}"));
129                }
130                out.push('\n');
131                out
132            }
133        }
134    }
135
136    fn append_headers(&self, out: &mut String, prefix: &str, event: &TransportLogEvent) {
137        if let Some(headers) = &event.headers {
138            for (name, value) in headers {
139                let shown = if self.is_sensitive(name) {
140                    REDACTED
141                } else {
142                    value
143                };
144                out.push_str(&format!("{prefix} {name}: {shown}\n"));
145            }
146        }
147    }
148}
149
150impl TransportLogger for StderrTransportLogger {
151    fn debug(&self, event: &TransportLogEvent) {
152        let rendered = self.format_event(event);
153        if rendered.is_empty() {
154            return;
155        }
156        // Write directly to a locked stderr handle (not `eprintln!`) so the
157        // whole event lands as one contiguous block under concurrency.
158        // Diagnostics are best-effort: ignore write failures rather than break
159        // the command. (`let _ =` would trip the crate's `let_underscore_drop`
160        // lint, so use `.ok()` to discard the result.)
161        let mut stderr = std::io::stderr().lock();
162        stderr.write_all(rendered.as_bytes()).ok();
163    }
164}
165
166fn field<'event>(event: &'event TransportLogEvent, key: &str) -> Option<&'event str> {
167    event.fields.get(key).map(String::as_str)
168}
169
170fn append_body(out: &mut String, prefix: &str, event: &TransportLogEvent) {
171    if let Some(body) = &event.body {
172        if body.is_empty() {
173            return;
174        }
175        out.push_str(&format!("{prefix}\n"));
176        for line in String::from_utf8_lossy(body).lines() {
177            out.push_str(&format!("{prefix} {line}\n"));
178        }
179    } else if let Some(size) = field(event, "body_bytes")
180        && size != "0"
181    {
182        out.push_str(&format!("{prefix} [body: {size} bytes not captured]\n"));
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use std::collections::BTreeMap;
189
190    use super::{REDACTED, StderrTransportLogger};
191    use crate::transport::client::TransportLogEvent;
192
193    fn fields(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
194        pairs
195            .iter()
196            .map(|(key, value)| ((*key).to_owned(), (*value).to_owned()))
197            .collect()
198    }
199
200    #[test]
201    fn request_event_redacts_sensitive_headers_and_prints_body() {
202        let event = TransportLogEvent {
203            message: "http request",
204            fields: fields(&[("method", "POST"), ("url", "https://api.example.com/repos")]),
205            headers: Some(vec![
206                ("authorization".to_owned(), "Bearer super-secret".to_owned()),
207                ("content-type".to_owned(), "application/json".to_owned()),
208            ]),
209            body: Some(br#"{"name":"foo"}"#.to_vec()),
210        };
211        let rendered = StderrTransportLogger::new().format_event(&event);
212        assert!(rendered.contains("> POST https://api.example.com/repos"));
213        assert!(rendered.contains(&format!("> authorization: {REDACTED}")));
214        assert!(!rendered.contains("super-secret"));
215        assert!(rendered.contains("> content-type: application/json"));
216        assert!(rendered.contains(r#"> {"name":"foo"}"#));
217    }
218
219    #[test]
220    fn response_event_with_size_only_reports_byte_count() {
221        let event = TransportLogEvent {
222            message: "http response",
223            fields: fields(&[
224                ("status", "200"),
225                ("method", "GET"),
226                ("url", "https://api.example.com/blob"),
227                ("body_bytes", "2048"),
228            ]),
229            headers: Some(vec![("set-cookie".to_owned(), "session=abc123".to_owned())]),
230            body: None,
231        };
232        let rendered = StderrTransportLogger::new().format_event(&event);
233        assert!(rendered.contains("< 200 GET https://api.example.com/blob"));
234        assert!(rendered.contains(&format!("< set-cookie: {REDACTED}")));
235        assert!(!rendered.contains("abc123"));
236        assert!(rendered.contains("< [body: 2048 bytes not captured]"));
237    }
238
239    #[test]
240    fn status_only_response_has_no_trailing_space() {
241        // The reqwest-direct helper logs responses with only a status (no
242        // method/url); the line must not render as `< 200  `.
243        let event = TransportLogEvent {
244            message: "http response",
245            fields: fields(&[("status", "204")]),
246            headers: Some(vec![("content-length".to_owned(), "0".to_owned())]),
247            body: None,
248        };
249        let rendered = StderrTransportLogger::new().format_event(&event);
250        assert!(
251            rendered.starts_with("< 204\n"),
252            "status-only response should be `< 204` with no trailing space, got: {rendered:?}"
253        );
254    }
255
256    #[test]
257    fn extra_redacted_headers_are_redacted_case_insensitively() {
258        let event = TransportLogEvent {
259            message: "http request",
260            fields: fields(&[("method", "GET"), ("url", "https://api.example.com/m")]),
261            headers: Some(vec![
262                ("x-litellm-api-key".to_owned(), "sk-leak-me".to_owned()),
263                ("content-type".to_owned(), "application/json".to_owned()),
264            ]),
265            body: None,
266        };
267        // Mixed case + stray whitespace + an empty entry, to prove names are
268        // trimmed (so they still match) and empties are dropped.
269        let logger = StderrTransportLogger::new().with_redacted_headers([
270            "  X-LiteLLM-API-Key  ",
271            "   ",
272            "",
273        ]);
274        let rendered = logger.format_event(&event);
275        assert!(rendered.contains(&format!("> x-litellm-api-key: {REDACTED}")));
276        assert!(!rendered.contains("sk-leak-me"));
277        // Non-configured headers are untouched.
278        assert!(rendered.contains("> content-type: application/json"));
279
280        // Without configuring it, the same header is shown verbatim.
281        let plain = StderrTransportLogger::new().format_event(&event);
282        assert!(plain.contains("> x-litellm-api-key: sk-leak-me"));
283    }
284
285    #[test]
286    fn retry_event_renders_a_note_line() {
287        let event = TransportLogEvent {
288            message: "retrying request",
289            fields: fields(&[("attempt", "2"), ("backoff", "500ms")]),
290            headers: None,
291            body: None,
292        };
293        assert_eq!(
294            StderrTransportLogger::new().format_event(&event),
295            "* retrying (attempt 2, backoff 500ms)\n"
296        );
297    }
298}