use http::header::CONTENT_TYPE;
use crate::{
BodyLogContext,
BodyPreview,
HttpClientOptions,
HttpLoggingOptions,
HttpRequest,
HttpRequestBody,
HttpResponse,
HttpResponseMeta,
LogSanitizer,
};
const UNRESOLVED_REQUEST_URL: &str = "<unresolved request URL>";
const STREAMING_REQUEST_BODY_SKIPPED: &str = "<skipped: streaming request body>";
#[derive(Debug, Clone)]
pub struct HttpLogger<'a> {
options: &'a HttpLoggingOptions,
sanitizer: LogSanitizer,
}
enum RequestBodyLogPreview<'a> {
Bytes(&'a [u8]),
Empty,
Skipped(&'static str),
}
impl<'a> HttpLogger<'a> {
pub fn new(options: &'a HttpClientOptions) -> Self {
Self {
options: &options.logging,
sanitizer: LogSanitizer::new(options.log_sanitize_policy.clone()),
}
}
pub fn log_request(&self, request: &HttpRequest) {
if !self.is_trace_enabled() {
return;
}
let url = self.request_log_url(request);
tracing::trace!("--> {} {}", request.method(), url);
let headers = request
.effective_headers_cached()
.unwrap_or_else(|| request.headers());
if self.options.log_request_header {
for (name, value) in headers {
let masked = self.sanitizer.sanitize_header_value(name, value);
tracing::trace!("{}: {}", name.as_str(), masked);
}
}
if self.options.log_request_body {
match Self::request_body_for_log(request) {
RequestBodyLogPreview::Bytes(bytes) => {
let content_type = Self::content_type(headers);
tracing::trace!(
"Request body: {}",
self.render_body(bytes, BodyLogContext::Request, content_type)
);
}
RequestBodyLogPreview::Empty => tracing::trace!("Request body: <empty>"),
RequestBodyLogPreview::Skipped(reason) => tracing::trace!("Request body: {reason}"),
}
}
}
pub async fn log_response(&self, response: &mut HttpResponse) -> crate::HttpResult<()> {
if !self.is_trace_enabled() {
return Ok(());
}
tracing::trace!("<-- {} {}", response.status().as_u16(), response.url());
if self.options.log_response_header {
for (name, value) in response.headers() {
let masked = self.sanitizer.sanitize_header_value(name, value);
tracing::trace!("{}: {}", name.as_str(), masked);
}
}
if self.options.log_response_body {
let content_type = Self::content_type(response.headers()).map(str::to_string);
if let Some(body) = response.buffered_body_for_logging() {
tracing::trace!(
"Response body: {}",
self.render_body(
body.as_ref(),
BodyLogContext::Response,
content_type.as_deref()
)
);
} else if response.can_buffer_body_for_logging(self.options.body_size_limit) {
let body = response.bytes().await?;
tracing::trace!(
"Response body: {}",
self.render_body(
body.as_ref(),
BodyLogContext::Response,
content_type.as_deref()
)
);
} else {
tracing::trace!("Response body: <skipped: streaming or unknown-size body>");
}
}
Ok(())
}
pub fn log_stream_response_headers(&self, response_meta: &HttpResponseMeta) {
if !self.is_trace_enabled() {
return;
}
tracing::trace!(
"<-- {} {} (stream)",
response_meta.status().as_u16(),
response_meta.url()
);
if self.options.log_response_header {
for (name, value) in response_meta.headers() {
let masked = self.sanitizer.sanitize_header_value(name, value);
tracing::trace!("{}: {}", name.as_str(), masked);
}
}
}
pub fn is_trace_enabled(&self) -> bool {
self.options.enabled && tracing::enabled!(tracing::Level::TRACE)
}
fn request_log_url(&self, request: &HttpRequest) -> String {
request
.resolved_url()
.map(|url| self.sanitizer.sanitize_url(&url))
.unwrap_or_else(|_| UNRESOLVED_REQUEST_URL.to_string())
}
fn render_body(
&self,
body: &[u8],
context: BodyLogContext,
content_type: Option<&str>,
) -> String {
let preview = BodyPreview::new(body, self.options.body_size_limit, context);
let preview = if let Some(content_type) = content_type {
preview.with_content_type(content_type)
} else {
preview
};
self.sanitizer.sanitize_body_preview(&preview)
}
fn request_body_for_log(request: &HttpRequest) -> RequestBodyLogPreview<'_> {
if request.has_streaming_body() {
return RequestBodyLogPreview::Skipped(STREAMING_REQUEST_BODY_SKIPPED);
}
match request.body() {
HttpRequestBody::Bytes(bytes)
| HttpRequestBody::Json(bytes)
| HttpRequestBody::Form(bytes)
| HttpRequestBody::Multipart(bytes)
| HttpRequestBody::Ndjson(bytes) => RequestBodyLogPreview::Bytes(bytes.as_ref()),
HttpRequestBody::Text(text) => RequestBodyLogPreview::Bytes(text.as_bytes()),
HttpRequestBody::Stream(_) => {
RequestBodyLogPreview::Skipped(STREAMING_REQUEST_BODY_SKIPPED)
}
HttpRequestBody::Empty => RequestBodyLogPreview::Empty,
}
}
fn content_type(headers: &http::HeaderMap) -> Option<&str> {
headers
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
}
}