Skip to main content

qubit_http/client/
http_logger.rs

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