use crate::client::http_middleware::{
HttpMiddleware, HttpMiddlewareContext, HttpRequest, HttpResponse,
};
use crate::error::Result;
use async_trait::async_trait;
use http::header::HeaderName;
use std::collections::HashSet;
#[derive(Debug, Clone)]
pub struct HttpLoggingMiddleware {
level: tracing::Level,
redact_headers: HashSet<HeaderName>,
show_auth_scheme: bool,
max_header_value_len: Option<usize>,
max_body_bytes: Option<usize>,
redact_query: bool,
log_body_content_types: HashSet<String>,
}
impl Default for HttpLoggingMiddleware {
fn default() -> Self {
Self::new()
}
}
impl HttpLoggingMiddleware {
pub fn new() -> Self {
let mut redact_headers = HashSet::new();
redact_headers.insert(HeaderName::from_static("authorization"));
redact_headers.insert(HeaderName::from_static("proxy-authorization"));
redact_headers.insert(HeaderName::from_static("cookie"));
redact_headers.insert(HeaderName::from_static("set-cookie"));
redact_headers.insert(HeaderName::from_static("x-api-key"));
redact_headers.insert(HeaderName::from_static("x-auth-token"));
redact_headers.insert(HeaderName::from_static("x-amz-security-token"));
redact_headers.insert(HeaderName::from_static("x-goog-api-key"));
let mut log_body_content_types = HashSet::new();
log_body_content_types.insert("application/json".to_string());
log_body_content_types.insert("text/plain".to_string());
log_body_content_types.insert("text/html".to_string());
log_body_content_types.insert("text/xml".to_string());
Self {
level: tracing::Level::INFO,
redact_headers,
show_auth_scheme: true,
max_header_value_len: None,
max_body_bytes: None,
redact_query: false,
log_body_content_types,
}
}
pub fn with_level(mut self, level: tracing::Level) -> Self {
self.level = level;
self
}
pub fn redact_header(mut self, name: HeaderName) -> Self {
self.redact_headers.insert(name);
self
}
pub fn allow_header(mut self, name: &HeaderName) -> Self {
self.redact_headers.remove(name);
self
}
pub fn with_show_auth_scheme(mut self, show: bool) -> Self {
self.show_auth_scheme = show;
self
}
pub fn with_max_header_value_len(mut self, max_len: usize) -> Self {
self.max_header_value_len = Some(max_len);
self
}
pub fn with_max_body_bytes(mut self, max_bytes: usize) -> Self {
self.max_body_bytes = Some(max_bytes);
self
}
pub fn with_redact_query(mut self, redact: bool) -> Self {
self.redact_query = redact;
self
}
pub fn allow_body_content_type(mut self, content_type: impl Into<String>) -> Self {
self.log_body_content_types.insert(content_type.into());
self
}
pub fn max_body_bytes(&self) -> Option<usize> {
self.max_body_bytes
}
pub fn redact_header_value(&self, name: &HeaderName, value: &str) -> String {
if !self.redact_headers.contains(name) {
return self.truncate_value(value.to_string());
}
if name == "authorization" && self.show_auth_scheme {
if let Some(space_idx) = value.find(' ') {
let scheme = &value[..space_idx];
format!("{} [REDACTED]", scheme)
} else {
"[REDACTED]".to_string()
}
} else {
"[REDACTED]".to_string()
}
}
fn truncate_value(&self, value: String) -> String {
if let Some(max_len) = self.max_header_value_len {
if value.len() > max_len {
format!("{}...", &value[..max_len.min(value.len())])
} else {
value
}
} else {
value
}
}
fn redact_url(&self, url: &str) -> String {
if !self.redact_query {
return url.to_string();
}
if let Some(query_start) = url.find('?') {
format!("{}?[REDACTED]", &url[..query_start])
} else {
url.to_string()
}
}
fn should_log_body(&self, content_type: Option<&str>) -> bool {
if self.max_body_bytes.is_none() {
return false;
}
let Some(ct) = content_type else {
return false;
};
let base_ct = ct.split(';').next().unwrap_or(ct).trim();
self.log_body_content_types.contains(base_ct)
|| (base_ct.starts_with("text/")
&& self
.log_body_content_types
.iter()
.any(|t| t == "text/plain"))
}
pub fn format_headers(&self, headers: &http::HeaderMap) -> String {
let mut header_strs = Vec::new();
for (name, _value) in headers {
let values: Vec<String> = headers
.get_all(name)
.iter()
.map(|v| {
let value_str = v.to_str().unwrap_or("<invalid-utf8>");
self.redact_header_value(name, value_str)
})
.collect();
if values.len() == 1 {
header_strs.push(format!("{}: {}", name.as_str(), values[0]));
} else {
for (idx, val) in values.iter().enumerate() {
header_strs.push(format!("{}[{}]: {}", name.as_str(), idx, val));
}
}
}
if header_strs.is_empty() {
"(no headers)".to_string()
} else {
header_strs.join(", ")
}
}
#[allow(clippy::cognitive_complexity)]
fn log_request(&self, request: &HttpRequest, context: &HttpMiddlewareContext) {
let url = self.redact_url(&request.url);
let headers_str = self.format_headers(&request.headers);
let content_type = request.get_header("content-type");
let should_log = self.should_log_body(content_type);
let body_info = if let Some(max_bytes) = self.max_body_bytes.filter(|_| should_log) {
let body_len = request.body.len();
if body_len > 0 {
let preview_len = max_bytes.min(body_len);
let preview = String::from_utf8_lossy(&request.body[..preview_len]);
if body_len > max_bytes {
format!(
" body={}B (showing {}B): {}...",
body_len, preview_len, preview
)
} else {
format!(" body={}B: {}", body_len, preview)
}
} else {
" body=0B".to_string()
}
} else {
format!(" body={}B", request.body.len())
};
match self.level {
tracing::Level::TRACE => tracing::trace!(
request_id = ?context.request_id,
"HTTP {} {} | headers: [{}]{}",
request.method,
url,
headers_str,
body_info
),
tracing::Level::DEBUG => tracing::debug!(
request_id = ?context.request_id,
"HTTP {} {} | headers: [{}]{}",
request.method,
url,
headers_str,
body_info
),
tracing::Level::INFO => tracing::info!(
request_id = ?context.request_id,
"HTTP {} {}{}",
request.method,
url,
if should_log { body_info.as_str() } else { "" }
),
tracing::Level::WARN => tracing::warn!(
request_id = ?context.request_id,
"HTTP {} {}",
request.method,
url
),
tracing::Level::ERROR => tracing::error!(
request_id = ?context.request_id,
"HTTP {} {}",
request.method,
url
),
}
}
#[allow(clippy::cognitive_complexity)]
fn log_response(&self, response: &HttpResponse, context: &HttpMiddlewareContext) {
let headers_str = self.format_headers(&response.headers);
let content_type = response.get_header("content-type");
let should_log = self.should_log_body(content_type);
let body_info = if let Some(max_bytes) = self.max_body_bytes.filter(|_| should_log) {
let body_len = response.body.len();
if body_len > 0 {
let preview_len = max_bytes.min(body_len);
let preview = String::from_utf8_lossy(&response.body[..preview_len]);
if body_len > max_bytes {
format!(
" body={}B (showing {}B): {}...",
body_len, preview_len, preview
)
} else {
format!(" body={}B: {}", body_len, preview)
}
} else {
" body=0B".to_string()
}
} else {
format!(" body={}B", response.body.len())
};
let status_emoji = if response.is_success() {
"✓"
} else if response.is_client_error() {
"âš "
} else if response.is_server_error() {
"✗"
} else {
"→"
};
match self.level {
tracing::Level::TRACE => tracing::trace!(
request_id = ?context.request_id,
"{} HTTP {} | headers: [{}]{}",
status_emoji,
response.status,
headers_str,
body_info
),
tracing::Level::DEBUG => tracing::debug!(
request_id = ?context.request_id,
"{} HTTP {} | headers: [{}]{}",
status_emoji,
response.status,
headers_str,
body_info
),
tracing::Level::INFO => tracing::info!(
request_id = ?context.request_id,
"{} HTTP {}{}",
status_emoji,
response.status,
if self.max_body_bytes.is_some() { body_info.as_str() } else { "" }
),
tracing::Level::WARN => tracing::warn!(
request_id = ?context.request_id,
"{} HTTP {}",
status_emoji,
response.status
),
tracing::Level::ERROR => tracing::error!(
request_id = ?context.request_id,
"{} HTTP {}",
status_emoji,
response.status
),
}
}
}
#[async_trait]
impl HttpMiddleware for HttpLoggingMiddleware {
async fn on_request(
&self,
request: &mut HttpRequest,
context: &HttpMiddlewareContext,
) -> Result<()> {
self.log_request(request, context);
Ok(())
}
async fn on_response(
&self,
response: &mut HttpResponse,
context: &HttpMiddlewareContext,
) -> Result<()> {
self.log_response(response, context);
Ok(())
}
fn priority(&self) -> i32 {
100 }
}
#[cfg(test)]
mod tests {
use super::*;
use http::HeaderMap;
#[test]
fn test_default_redaction_list() {
let middleware = HttpLoggingMiddleware::new();
assert!(middleware
.redact_headers
.contains(&HeaderName::from_static("authorization")));
assert!(middleware
.redact_headers
.contains(&HeaderName::from_static("cookie")));
assert!(middleware
.redact_headers
.contains(&HeaderName::from_static("set-cookie")));
assert!(middleware
.redact_headers
.contains(&HeaderName::from_static("x-api-key")));
}
#[test]
fn test_authorization_redaction_with_scheme() {
let middleware = HttpLoggingMiddleware::new();
let name = HeaderName::from_static("authorization");
let redacted = middleware.redact_header_value(&name, "Bearer my-secret-token");
assert_eq!(redacted, "Bearer [REDACTED]");
let redacted2 = middleware.redact_header_value(&name, "Basic dXNlcjpwYXNz");
assert_eq!(redacted2, "Basic [REDACTED]");
}
#[test]
fn test_authorization_redaction_without_scheme() {
let middleware = HttpLoggingMiddleware::new().with_show_auth_scheme(false);
let name = HeaderName::from_static("authorization");
let redacted = middleware.redact_header_value(&name, "Bearer my-secret-token");
assert_eq!(redacted, "[REDACTED]");
}
#[test]
fn test_cookie_redaction() {
let middleware = HttpLoggingMiddleware::new();
let name = HeaderName::from_static("cookie");
let redacted = middleware.redact_header_value(&name, "session=abc123; user=john");
assert_eq!(redacted, "[REDACTED]");
}
#[test]
fn test_non_sensitive_header() {
let middleware = HttpLoggingMiddleware::new();
let name = HeaderName::from_static("content-type");
let redacted = middleware.redact_header_value(&name, "application/json");
assert_eq!(redacted, "application/json");
}
#[test]
fn test_header_truncation() {
let middleware = HttpLoggingMiddleware::new().with_max_header_value_len(10);
let name = HeaderName::from_static("content-type");
let redacted = middleware.redact_header_value(&name, "application/json; charset=utf-8");
assert_eq!(redacted, "applicatio...");
}
#[test]
fn test_allow_header_override() {
let middleware =
HttpLoggingMiddleware::new().allow_header(&HeaderName::from_static("x-api-key"));
assert!(!middleware
.redact_headers
.contains(&HeaderName::from_static("x-api-key")));
let name = HeaderName::from_static("x-api-key");
let redacted = middleware.redact_header_value(&name, "my-api-key-12345");
assert_eq!(redacted, "my-api-key-12345");
}
#[test]
fn test_format_headers_multivalue() {
let middleware = HttpLoggingMiddleware::new();
let mut headers = HeaderMap::new();
headers.append("set-cookie", "session1=abc".parse().unwrap());
headers.append("set-cookie", "session2=def".parse().unwrap());
headers.insert("content-type", "application/json".parse().unwrap());
let formatted = middleware.format_headers(&headers);
assert!(formatted.contains("[REDACTED]"));
assert!(formatted.contains("application/json"));
}
#[test]
fn test_query_redaction_enabled() {
let middleware = HttpLoggingMiddleware::new().with_redact_query(true);
let url_with_query = "http://example.com/api/users?token=secret&id=123";
let redacted = middleware.redact_url(url_with_query);
assert_eq!(redacted, "http://example.com/api/users?[REDACTED]");
let url_without_query = "http://example.com/api/users";
let redacted2 = middleware.redact_url(url_without_query);
assert_eq!(redacted2, "http://example.com/api/users");
}
#[test]
fn test_query_redaction_disabled() {
let middleware = HttpLoggingMiddleware::new();
let url_with_query = "http://example.com/api/users?token=secret&id=123";
let redacted = middleware.redact_url(url_with_query);
assert_eq!(redacted, url_with_query);
}
#[test]
fn test_cloud_provider_headers_redacted() {
let middleware = HttpLoggingMiddleware::new();
let aws_header = HeaderName::from_static("x-amz-security-token");
assert!(middleware.redact_headers.contains(&aws_header));
let redacted = middleware.redact_header_value(&aws_header, "aws-session-token-12345");
assert_eq!(redacted, "[REDACTED]");
let gcp_header = HeaderName::from_static("x-goog-api-key");
assert!(middleware.redact_headers.contains(&gcp_header));
let redacted2 = middleware.redact_header_value(&gcp_header, "gcp-api-key-67890");
assert_eq!(redacted2, "[REDACTED]");
}
#[test]
fn test_body_logging_content_type_gating() {
let middleware = HttpLoggingMiddleware::new().with_max_body_bytes(1024);
assert!(middleware.should_log_body(Some("application/json")));
assert!(middleware.should_log_body(Some("application/json; charset=utf-8")));
assert!(middleware.should_log_body(Some("text/plain")));
assert!(middleware.should_log_body(Some("text/html")));
assert!(middleware.should_log_body(Some("text/xml")));
assert!(middleware.should_log_body(Some("text/csv")));
assert!(!middleware.should_log_body(Some("application/octet-stream")));
assert!(!middleware.should_log_body(Some("image/png")));
assert!(!middleware.should_log_body(Some("video/mp4")));
assert!(!middleware.should_log_body(None));
}
#[test]
fn test_body_logging_disabled_by_default() {
let middleware = HttpLoggingMiddleware::new();
assert!(!middleware.should_log_body(Some("application/json")));
assert!(!middleware.should_log_body(Some("text/plain")));
}
#[test]
fn test_allow_custom_body_content_type() {
let middleware = HttpLoggingMiddleware::new()
.with_max_body_bytes(1024)
.allow_body_content_type("application/xml");
assert!(middleware.should_log_body(Some("application/xml")));
assert!(middleware.should_log_body(Some("application/json")));
}
}