use std::io::Write;
use super::client::{TransportLogEvent, TransportLogger};
const SENSITIVE_HEADERS: &[&str] = &[
"authorization",
"proxy-authorization",
"cookie",
"set-cookie",
"x-api-key",
];
const REDACTED: &str = "<redacted>";
#[derive(Clone, Debug, Default)]
pub struct StderrTransportLogger {
extra_redacted: Vec<String>,
}
impl StderrTransportLogger {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_redacted_headers(
mut self,
names: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
self.extra_redacted
.extend(names.into_iter().filter_map(|name| {
let name = name.into().trim().to_ascii_lowercase();
(!name.is_empty()).then_some(name)
}));
self
}
fn is_sensitive(&self, name: &str) -> bool {
SENSITIVE_HEADERS
.iter()
.any(|candidate| name.eq_ignore_ascii_case(candidate))
|| self
.extra_redacted
.iter()
.any(|candidate| name.eq_ignore_ascii_case(candidate))
}
fn format_event(&self, event: &TransportLogEvent) -> String {
match event.message {
"http request" => {
let method = field(event, "method").unwrap_or("?");
let url = field(event, "url").unwrap_or("?");
let mut out = format!("> {method} {url}\n");
self.append_headers(&mut out, ">", event);
append_body(&mut out, ">", event);
out
}
"http response" => {
let status = field(event, "status").unwrap_or("?");
let suffix = match (field(event, "method"), field(event, "url")) {
(Some(method), Some(url)) => format!(" {method} {url}"),
(Some(value), None) | (None, Some(value)) => format!(" {value}"),
(None, None) => String::new(),
};
let mut out = format!("< {status}{suffix}\n");
self.append_headers(&mut out, "<", event);
append_body(&mut out, "<", event);
out
}
"retrying request" => {
let attempt = field(event, "attempt").unwrap_or("?");
let backoff = field(event, "backoff").unwrap_or("?");
format!("* retrying (attempt {attempt}, backoff {backoff})\n")
}
other => {
let mut out = format!("* {other}");
for (key, value) in &event.fields {
out.push_str(&format!(" {key}={value}"));
}
out.push('\n');
out
}
}
}
fn append_headers(&self, out: &mut String, prefix: &str, event: &TransportLogEvent) {
if let Some(headers) = &event.headers {
for (name, value) in headers {
let shown = if self.is_sensitive(name) {
REDACTED
} else {
value
};
out.push_str(&format!("{prefix} {name}: {shown}\n"));
}
}
}
}
impl TransportLogger for StderrTransportLogger {
fn debug(&self, event: &TransportLogEvent) {
let rendered = self.format_event(event);
if rendered.is_empty() {
return;
}
let mut stderr = std::io::stderr().lock();
stderr.write_all(rendered.as_bytes()).ok();
}
}
fn field<'event>(event: &'event TransportLogEvent, key: &str) -> Option<&'event str> {
event.fields.get(key).map(String::as_str)
}
fn append_body(out: &mut String, prefix: &str, event: &TransportLogEvent) {
if let Some(body) = &event.body {
if body.is_empty() {
return;
}
out.push_str(&format!("{prefix}\n"));
for line in String::from_utf8_lossy(body).lines() {
out.push_str(&format!("{prefix} {line}\n"));
}
} else if let Some(size) = field(event, "body_bytes")
&& size != "0"
{
out.push_str(&format!("{prefix} [body: {size} bytes not captured]\n"));
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::{REDACTED, StderrTransportLogger};
use crate::transport::client::TransportLogEvent;
fn fields(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(key, value)| ((*key).to_owned(), (*value).to_owned()))
.collect()
}
#[test]
fn request_event_redacts_sensitive_headers_and_prints_body() {
let event = TransportLogEvent {
message: "http request",
fields: fields(&[("method", "POST"), ("url", "https://api.example.com/repos")]),
headers: Some(vec![
("authorization".to_owned(), "Bearer super-secret".to_owned()),
("content-type".to_owned(), "application/json".to_owned()),
]),
body: Some(br#"{"name":"foo"}"#.to_vec()),
};
let rendered = StderrTransportLogger::new().format_event(&event);
assert!(rendered.contains("> POST https://api.example.com/repos"));
assert!(rendered.contains(&format!("> authorization: {REDACTED}")));
assert!(!rendered.contains("super-secret"));
assert!(rendered.contains("> content-type: application/json"));
assert!(rendered.contains(r#"> {"name":"foo"}"#));
}
#[test]
fn response_event_with_size_only_reports_byte_count() {
let event = TransportLogEvent {
message: "http response",
fields: fields(&[
("status", "200"),
("method", "GET"),
("url", "https://api.example.com/blob"),
("body_bytes", "2048"),
]),
headers: Some(vec![("set-cookie".to_owned(), "session=abc123".to_owned())]),
body: None,
};
let rendered = StderrTransportLogger::new().format_event(&event);
assert!(rendered.contains("< 200 GET https://api.example.com/blob"));
assert!(rendered.contains(&format!("< set-cookie: {REDACTED}")));
assert!(!rendered.contains("abc123"));
assert!(rendered.contains("< [body: 2048 bytes not captured]"));
}
#[test]
fn status_only_response_has_no_trailing_space() {
let event = TransportLogEvent {
message: "http response",
fields: fields(&[("status", "204")]),
headers: Some(vec![("content-length".to_owned(), "0".to_owned())]),
body: None,
};
let rendered = StderrTransportLogger::new().format_event(&event);
assert!(
rendered.starts_with("< 204\n"),
"status-only response should be `< 204` with no trailing space, got: {rendered:?}"
);
}
#[test]
fn extra_redacted_headers_are_redacted_case_insensitively() {
let event = TransportLogEvent {
message: "http request",
fields: fields(&[("method", "GET"), ("url", "https://api.example.com/m")]),
headers: Some(vec![
("x-litellm-api-key".to_owned(), "sk-leak-me".to_owned()),
("content-type".to_owned(), "application/json".to_owned()),
]),
body: None,
};
let logger = StderrTransportLogger::new().with_redacted_headers([
" X-LiteLLM-API-Key ",
" ",
"",
]);
let rendered = logger.format_event(&event);
assert!(rendered.contains(&format!("> x-litellm-api-key: {REDACTED}")));
assert!(!rendered.contains("sk-leak-me"));
assert!(rendered.contains("> content-type: application/json"));
let plain = StderrTransportLogger::new().format_event(&event);
assert!(plain.contains("> x-litellm-api-key: sk-leak-me"));
}
#[test]
fn retry_event_renders_a_note_line() {
let event = TransportLogEvent {
message: "retrying request",
fields: fields(&[("attempt", "2"), ("backoff", "500ms")]),
headers: None,
body: None,
};
assert_eq!(
StderrTransportLogger::new().format_event(&event),
"* retrying (attempt 2, backoff 500ms)\n"
);
}
}