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