Skip to main content

qubit_http/client/
http_logger.rs

1/*******************************************************************************
2 *
3 *    Copyright (c) 2025 - 2026 Haixing Hu.
4 *
5 *    SPDX-License-Identifier: Apache-2.0
6 *
7 *    Licensed under the Apache License, Version 2.0.
8 *
9 ******************************************************************************/
10//! # HTTP Logger
11//!
12//! Encapsulates request and response logging behavior.
13//!
14
15use crate::constants::{
16    SENSITIVE_HEADER_MASK_EDGE_CHARS,
17    SENSITIVE_HEADER_MASK_PLACEHOLDER,
18    SENSITIVE_HEADER_MASK_SHORT_LEN,
19};
20use crate::{
21    HttpClientOptions,
22    HttpLoggingOptions,
23    HttpRequest,
24    HttpRequestBody,
25    HttpResponse,
26    HttpResponseMeta,
27    SensitiveHttpHeaders,
28};
29use bytes::Bytes;
30
31/// HTTP logger bound to one pair of logging options and sensitive header policy.
32#[derive(Debug, Clone, Copy)]
33pub struct HttpLogger<'a> {
34    options: &'a HttpLoggingOptions,
35    sensitive_headers: &'a SensitiveHttpHeaders,
36}
37
38impl<'a> HttpLogger<'a> {
39    /// Creates a logger view from one client option object.
40    ///
41    /// # Parameters
42    /// - `options`: Client options that carry logging switches and sensitive
43    ///   header policies.
44    ///
45    /// # Returns
46    /// A logger that emits TRACE records according to the provided options.
47    pub fn new(options: &'a HttpClientOptions) -> Self {
48        Self {
49            options: &options.logging,
50            sensitive_headers: &options.sensitive_headers,
51        }
52    }
53
54    /// Emits TRACE logs for an outbound request when logging is enabled and TRACE is active.
55    ///
56    /// # Parameters
57    /// - `request`: Prepared request snapshot; expected to carry resolved URL
58    ///   and attempt-level merged headers.
59    ///
60    /// # Returns
61    /// Nothing; no-op when disabled or TRACE off.
62    pub fn log_request(&self, request: &HttpRequest) {
63        if !self.is_trace_enabled() {
64            return;
65        }
66
67        let url = Self::request_log_url(request);
68        tracing::trace!("--> {} {}", request.method(), url);
69
70        let headers = request
71            .effective_headers_cached()
72            .unwrap_or_else(|| request.headers());
73
74        if self.options.log_request_header {
75            for (name, value) in headers {
76                let value = value.to_str().unwrap_or("<non-utf8>");
77                let masked = self.mask_header_value(name.as_str(), value);
78                tracing::trace!("{}: {}", name.as_str(), masked);
79            }
80        }
81
82        if self.options.log_request_body {
83            match Self::clone_request_body_for_log(request.body()) {
84                Some(bytes) => tracing::trace!("Request body: {}", self.render_body(&bytes)),
85                None => tracing::trace!("Request body: <empty>"),
86            }
87        }
88    }
89
90    /// Emits TRACE logs for a completed response (headers and optional body preview).
91    ///
92    /// # Parameters
93    /// - `response`: Response object (status/url/headers/body cache).
94    ///
95    /// # Returns
96    /// `Ok(())` on success; no-op when disabled or TRACE off.
97    ///
98    /// # Errors
99    /// Returns [`crate::HttpError`] when reading the response body for logging fails.
100    pub async fn log_response(&self, response: &mut HttpResponse) -> crate::HttpResult<()> {
101        if !self.is_trace_enabled() {
102            return Ok(());
103        }
104
105        tracing::trace!("<-- {} {}", response.status().as_u16(), response.url());
106
107        if self.options.log_response_header {
108            for (name, value) in response.headers() {
109                let value = value.to_str().unwrap_or("<non-utf8>");
110                let masked = self.mask_header_value(name.as_str(), value);
111                tracing::trace!("{}: {}", name.as_str(), masked);
112            }
113        }
114
115        if self.options.log_response_body {
116            if let Some(body) = response.buffered_body_for_logging() {
117                tracing::trace!("Response body: {}", self.render_body(body));
118            } else if response.can_buffer_body_for_logging(self.options.body_size_limit) {
119                let body = response.bytes().await?;
120                tracing::trace!("Response body: {}", self.render_body(&body));
121            } else {
122                tracing::trace!("Response body: <skipped: streaming or unknown-size body>");
123            }
124        }
125        Ok(())
126    }
127
128    /// Logs response line and headers for a streaming call without reading the body stream.
129    ///
130    /// # Parameters
131    /// - `response_meta`: Response metadata (status/url/headers).
132    ///
133    /// # Returns
134    /// Nothing; no-op when disabled or TRACE off.
135    pub fn log_stream_response_headers(&self, response_meta: &HttpResponseMeta) {
136        if !self.is_trace_enabled() {
137            return;
138        }
139
140        tracing::trace!(
141            "<-- {} {} (stream)",
142            response_meta.status.as_u16(),
143            &response_meta.url
144        );
145
146        if self.options.log_response_header {
147            for (name, value) in &response_meta.headers {
148                let value = value.to_str().unwrap_or("<non-utf8>");
149                let masked = self.mask_header_value(name.as_str(), value);
150                tracing::trace!("{}: {}", name.as_str(), masked);
151            }
152        }
153    }
154
155    /// Returns whether TRACE logs should be emitted under current options and subscriber state.
156    ///
157    /// # Returns
158    /// `true` when logging is enabled and TRACE is active.
159    pub fn is_trace_enabled(&self) -> bool {
160        self.options.enabled && tracing::enabled!(tracing::Level::TRACE)
161    }
162
163    /// Returns the URL text used by request logging.
164    ///
165    /// # Parameters
166    /// - `request`: Request whose resolved URL should be rendered.
167    ///
168    /// # Returns
169    /// Resolved URL including builder query parameters, or the raw request path
170    /// when URL resolution fails before send.
171    fn request_log_url(request: &HttpRequest) -> String {
172        request
173            .resolved_url_with_query()
174            .map(|url| url.to_string())
175            .unwrap_or_else(|_| request.path().to_string())
176    }
177
178    /// Returns a masked representation of a header value according to sensitivity rules.
179    ///
180    /// # Parameters
181    /// - `name`: Header name.
182    /// - `value`: Raw header value.
183    ///
184    /// # Returns
185    /// A log-safe string when the header is sensitive; otherwise the original value.
186    fn mask_header_value(&self, name: &str, value: &str) -> String {
187        if value.is_empty() {
188            return String::new();
189        }
190        if !self.sensitive_headers.contains(name) {
191            return value.to_string();
192        }
193
194        let chars: Vec<char> = value.chars().collect();
195        if chars.len() <= SENSITIVE_HEADER_MASK_SHORT_LEN {
196            SENSITIVE_HEADER_MASK_PLACEHOLDER.to_string()
197        } else {
198            let edge = SENSITIVE_HEADER_MASK_EDGE_CHARS;
199            let prefix: String = chars[..edge].iter().collect();
200            let suffix: String = chars[chars.len() - edge..].iter().collect();
201            format!("{prefix}{SENSITIVE_HEADER_MASK_PLACEHOLDER}{suffix}")
202        }
203    }
204
205    /// Formats up to configured `body_size_limit` bytes of `body` for TRACE output.
206    ///
207    /// # Parameters
208    /// - `body`: Raw bytes.
209    ///
210    /// # Returns
211    /// Human-readable body preview string.
212    fn render_body(&self, body: &Bytes) -> String {
213        if body.is_empty() {
214            return "<empty>".to_string();
215        }
216
217        let max_bytes = self.options.body_size_limit;
218        let limit = body.len().min(max_bytes);
219        let prefix = &body[..limit];
220        let suffix = if body.len() > max_bytes {
221            format!("...<truncated {} bytes>", body.len() - max_bytes)
222        } else {
223            String::new()
224        };
225
226        match std::str::from_utf8(prefix) {
227            Ok(text) => format!("{}{}", text, suffix),
228            Err(_) => format!("<binary {} bytes>{}", body.len(), suffix),
229        }
230    }
231
232    /// Clones request body content only when body logging is needed.
233    ///
234    /// # Parameters
235    /// - `body`: Request body variant.
236    ///
237    /// # Returns
238    /// Optional byte payload for logger previewing.
239    fn clone_request_body_for_log(body: &HttpRequestBody) -> Option<Bytes> {
240        match body {
241            HttpRequestBody::Bytes(bytes)
242            | HttpRequestBody::Json(bytes)
243            | HttpRequestBody::Form(bytes)
244            | HttpRequestBody::Multipart(bytes)
245            | HttpRequestBody::Ndjson(bytes) => Some(bytes.clone()),
246            HttpRequestBody::Text(text) => Some(Bytes::from(text.clone())),
247            HttpRequestBody::Stream(_) => None,
248            HttpRequestBody::Empty => None,
249        }
250    }
251}