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 http::header::CONTENT_TYPE;
16
17use crate::sanitize::{
18    BodyLogContext,
19    SanitizedLogger,
20};
21use crate::{
22    HttpClientOptions,
23    HttpLoggingOptions,
24    HttpRequest,
25    HttpRequestBody,
26    HttpResponse,
27    HttpResponseMeta,
28};
29
30const UNRESOLVED_REQUEST_URL: &str = "<unresolved request URL>";
31const STREAMING_REQUEST_BODY_SKIPPED: &str = "<skipped: streaming request body>";
32
33/// HTTP logger bound to one pair of logging options and a sanitizer policy.
34#[derive(Debug, Clone)]
35pub struct HttpLogger<'a> {
36    options: &'a HttpLoggingOptions,
37    sanitized_logger: SanitizedLogger,
38}
39
40/// Request body preview category used by TRACE logging.
41enum RequestBodyLogPreview<'a> {
42    /// Borrowed bytes that can be safely previewed without consuming a stream.
43    Bytes(&'a [u8]),
44    /// No request body is present.
45    Empty,
46    /// Body logging is intentionally skipped.
47    Skipped(&'static str),
48}
49
50impl<'a> HttpLogger<'a> {
51    /// Creates a logger view from one client option object.
52    ///
53    /// # Parameters
54    /// - `options`: Client options that carry logging switches and sensitive
55    ///   header policies.
56    ///
57    /// # Returns
58    /// A logger that emits TRACE records according to the provided options.
59    pub fn new(options: &'a HttpClientOptions) -> Self {
60        Self {
61            options: &options.logging,
62            sanitized_logger: SanitizedLogger::from_options(options),
63        }
64    }
65
66    /// Emits TRACE logs for an outbound request when logging is enabled and TRACE is active.
67    ///
68    /// # Parameters
69    /// - `request`: Prepared request snapshot; expected to carry resolved URL
70    ///   and attempt-level merged headers.
71    ///
72    /// # Returns
73    /// Nothing; no-op when disabled or TRACE off.
74    pub fn log_request(&self, request: &HttpRequest) {
75        if !self.is_trace_enabled() {
76            return;
77        }
78
79        let url = self.request_log_url(request);
80        tracing::trace!("--> {} {}", request.method(), url);
81
82        let headers = request
83            .effective_headers_cached()
84            .unwrap_or_else(|| request.headers());
85
86        if self.options.log_request_header {
87            for (name, value) in headers {
88                let masked = self.sanitized_logger.header_value(name, value);
89                tracing::trace!("{}: {}", name.as_str(), masked);
90            }
91        }
92
93        if self.options.log_request_body {
94            match Self::request_body_for_log(request) {
95                RequestBodyLogPreview::Bytes(bytes) => {
96                    let content_type = Self::content_type(headers);
97                    tracing::trace!(
98                        "Request body: {}",
99                        self.sanitized_logger
100                            .body(bytes, BodyLogContext::Request, content_type)
101                    );
102                }
103                RequestBodyLogPreview::Empty => tracing::trace!("Request body: <empty>"),
104                RequestBodyLogPreview::Skipped(reason) => tracing::trace!("Request body: {reason}"),
105            }
106        }
107    }
108
109    /// Emits TRACE logs for a completed response (headers and optional body preview).
110    ///
111    /// # Parameters
112    /// - `response`: Response object (status/url/headers/body cache).
113    ///
114    /// # Returns
115    /// `Ok(())` on success; no-op when disabled or TRACE off.
116    ///
117    /// # Errors
118    /// Returns [`crate::HttpError`] when reading the response body for logging fails.
119    pub async fn log_response(&self, response: &mut HttpResponse) -> crate::HttpResult<()> {
120        if !self.is_trace_enabled() {
121            return Ok(());
122        }
123
124        tracing::trace!(
125            "<-- {} {}",
126            response.status().as_u16(),
127            self.sanitized_logger.url(response.url())
128        );
129
130        if self.options.log_response_header {
131            for (name, value) in response.headers() {
132                let masked = self.sanitized_logger.header_value(name, value);
133                tracing::trace!("{}: {}", name.as_str(), masked);
134            }
135        }
136
137        if self.options.log_response_body {
138            let content_type = Self::content_type(response.headers()).map(str::to_string);
139            if let Some(body) = response.buffered_body_for_logging() {
140                tracing::trace!(
141                    "Response body: {}",
142                    self.sanitized_logger.body(
143                        body.as_ref(),
144                        BodyLogContext::Response,
145                        content_type.as_deref()
146                    )
147                );
148            } else if response.can_buffer_body_for_logging(self.options.body_size_limit) {
149                let body = response.bytes().await?;
150                tracing::trace!(
151                    "Response body: {}",
152                    self.sanitized_logger.body(
153                        body.as_ref(),
154                        BodyLogContext::Response,
155                        content_type.as_deref()
156                    )
157                );
158            } else {
159                tracing::trace!("Response body: <skipped: streaming or unknown-size body>");
160            }
161        }
162        Ok(())
163    }
164
165    /// Logs response line and headers for a streaming call without reading the body stream.
166    ///
167    /// # Parameters
168    /// - `response_meta`: Response metadata (status/url/headers).
169    ///
170    /// # Returns
171    /// Nothing; no-op when disabled or TRACE off.
172    pub fn log_stream_response_headers(&self, response_meta: &HttpResponseMeta) {
173        if !self.is_trace_enabled() {
174            return;
175        }
176
177        tracing::trace!(
178            "<-- {} {} (stream)",
179            response_meta.status().as_u16(),
180            self.sanitized_logger.url(response_meta.url())
181        );
182
183        if self.options.log_response_header {
184            for (name, value) in response_meta.headers() {
185                let masked = self.sanitized_logger.header_value(name, value);
186                tracing::trace!("{}: {}", name.as_str(), masked);
187            }
188        }
189    }
190
191    /// Returns whether TRACE logs should be emitted under current options and subscriber state.
192    ///
193    /// # Returns
194    /// `true` when logging is enabled and TRACE is active.
195    pub fn is_trace_enabled(&self) -> bool {
196        self.options.enabled && tracing::enabled!(tracing::Level::TRACE)
197    }
198
199    /// Returns the URL text used by request logging.
200    ///
201    /// # Parameters
202    /// - `request`: Request whose resolved URL should be rendered.
203    ///
204    /// # Returns
205    /// Resolved URL including builder query parameters, or a fixed placeholder
206    /// when URL resolution fails before send.
207    fn request_log_url(&self, request: &HttpRequest) -> String {
208        request
209            .resolved_url()
210            .map(|url| self.sanitized_logger.url(&url))
211            .unwrap_or_else(|_| UNRESOLVED_REQUEST_URL.to_string())
212    }
213
214    /// Borrows request body content only when body logging is safe.
215    ///
216    /// # Parameters
217    /// - `request`: Prepared request snapshot.
218    ///
219    /// # Returns
220    /// Body preview category for logger rendering.
221    fn request_body_for_log(request: &HttpRequest) -> RequestBodyLogPreview<'_> {
222        if request.has_streaming_body() {
223            return RequestBodyLogPreview::Skipped(STREAMING_REQUEST_BODY_SKIPPED);
224        }
225        match request.body() {
226            HttpRequestBody::Bytes(bytes)
227            | HttpRequestBody::Json(bytes)
228            | HttpRequestBody::Form(bytes)
229            | HttpRequestBody::Multipart(bytes)
230            | HttpRequestBody::Ndjson(bytes) => RequestBodyLogPreview::Bytes(bytes.as_ref()),
231            HttpRequestBody::Text(text) => RequestBodyLogPreview::Bytes(text.as_bytes()),
232            HttpRequestBody::Stream(_) => {
233                RequestBodyLogPreview::Skipped(STREAMING_REQUEST_BODY_SKIPPED)
234            }
235            HttpRequestBody::Empty => RequestBodyLogPreview::Empty,
236        }
237    }
238
239    /// Extracts a UTF-8 Content-Type header value from a header map.
240    ///
241    /// # Parameters
242    /// - `headers`: Headers to inspect.
243    ///
244    /// # Returns
245    /// `Some` with UTF-8 Content-Type, otherwise `None`.
246    fn content_type(headers: &http::HeaderMap) -> Option<&str> {
247        headers
248            .get(CONTENT_TYPE)
249            .and_then(|value| value.to_str().ok())
250    }
251}