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.effective_headers_cached().unwrap_or_else(|| request.headers());
83
84        if self.options.log_request_header {
85            for (name, value) in headers {
86                let masked = self.sanitized_logger.header_value(name, value);
87                tracing::trace!("{}: {}", name.as_str(), masked);
88            }
89        }
90
91        if self.options.log_request_body {
92            match Self::request_body_for_log(request) {
93                RequestBodyLogPreview::Bytes(bytes) => {
94                    let content_type = Self::content_type(headers);
95                    tracing::trace!(
96                        "Request body: {}",
97                        self.sanitized_logger.body(bytes, BodyLogContext::Request, content_type)
98                    );
99                }
100                RequestBodyLogPreview::Empty => tracing::trace!("Request body: <empty>"),
101                RequestBodyLogPreview::Skipped(reason) => tracing::trace!("Request body: {reason}"),
102            }
103        }
104    }
105
106    /// Emits TRACE logs for a completed response (headers and optional body preview).
107    ///
108    /// # Parameters
109    /// - `response`: Response object (status/url/headers/body cache).
110    ///
111    /// # Returns
112    /// `Ok(())` on success; no-op when disabled or TRACE off.
113    ///
114    /// # Errors
115    /// Returns [`crate::HttpError`] when reading the response body for logging fails.
116    pub async fn log_response(&self, response: &mut HttpResponse) -> crate::HttpResult<()> {
117        if !self.is_trace_enabled() {
118            return Ok(());
119        }
120
121        tracing::trace!(
122            "<-- {} {}",
123            response.status().as_u16(),
124            self.sanitized_logger.url(response.url())
125        );
126
127        if self.options.log_response_header {
128            for (name, value) in response.headers() {
129                let masked = self.sanitized_logger.header_value(name, value);
130                tracing::trace!("{}: {}", name.as_str(), masked);
131            }
132        }
133
134        if self.options.log_response_body {
135            let content_type = Self::content_type(response.headers()).map(str::to_string);
136            if let Some(body) = response.buffered_body_for_logging() {
137                tracing::trace!(
138                    "Response body: {}",
139                    self.sanitized_logger
140                        .body(body.as_ref(), BodyLogContext::Response, content_type.as_deref())
141                );
142            } else if response.can_buffer_body_for_logging(self.options.body_size_limit) {
143                let body = response.bytes().await?;
144                tracing::trace!(
145                    "Response body: {}",
146                    self.sanitized_logger
147                        .body(body.as_ref(), BodyLogContext::Response, content_type.as_deref())
148                );
149            } else {
150                tracing::trace!("Response body: <skipped: streaming or unknown-size body>");
151            }
152        }
153        Ok(())
154    }
155
156    /// Logs response line and headers for a streaming call without reading the body stream.
157    ///
158    /// # Parameters
159    /// - `response_meta`: Response metadata (status/url/headers).
160    ///
161    /// # Returns
162    /// Nothing; no-op when disabled or TRACE off.
163    pub fn log_stream_response_headers(&self, response_meta: &HttpResponseMeta) {
164        if !self.is_trace_enabled() {
165            return;
166        }
167
168        tracing::trace!(
169            "<-- {} {} (stream)",
170            response_meta.status().as_u16(),
171            self.sanitized_logger.url(response_meta.url())
172        );
173
174        if self.options.log_response_header {
175            for (name, value) in response_meta.headers() {
176                let masked = self.sanitized_logger.header_value(name, value);
177                tracing::trace!("{}: {}", name.as_str(), masked);
178            }
179        }
180    }
181
182    /// Returns whether TRACE logs should be emitted under current options and subscriber state.
183    ///
184    /// # Returns
185    /// `true` when logging is enabled and TRACE is active.
186    pub fn is_trace_enabled(&self) -> bool {
187        self.options.enabled && tracing::enabled!(tracing::Level::TRACE)
188    }
189
190    /// Returns the URL text used by request logging.
191    ///
192    /// # Parameters
193    /// - `request`: Request whose resolved URL should be rendered.
194    ///
195    /// # Returns
196    /// Resolved URL including builder query parameters, or a fixed placeholder
197    /// when URL resolution fails before send.
198    fn request_log_url(&self, request: &HttpRequest) -> String {
199        request
200            .resolved_url()
201            .map(|url| self.sanitized_logger.url(&url))
202            .unwrap_or_else(|_| UNRESOLVED_REQUEST_URL.to_string())
203    }
204
205    /// Borrows request body content only when body logging is safe.
206    ///
207    /// # Parameters
208    /// - `request`: Prepared request snapshot.
209    ///
210    /// # Returns
211    /// Body preview category for logger rendering.
212    fn request_body_for_log(request: &HttpRequest) -> RequestBodyLogPreview<'_> {
213        if request.has_streaming_body() {
214            return RequestBodyLogPreview::Skipped(STREAMING_REQUEST_BODY_SKIPPED);
215        }
216        match request.body() {
217            HttpRequestBody::Bytes(bytes)
218            | HttpRequestBody::Json(bytes)
219            | HttpRequestBody::Form(bytes)
220            | HttpRequestBody::Multipart(bytes)
221            | HttpRequestBody::Ndjson(bytes) => RequestBodyLogPreview::Bytes(bytes.as_ref()),
222            HttpRequestBody::Text(text) => RequestBodyLogPreview::Bytes(text.as_bytes()),
223            HttpRequestBody::Stream(_) => RequestBodyLogPreview::Skipped(STREAMING_REQUEST_BODY_SKIPPED),
224            HttpRequestBody::Empty => RequestBodyLogPreview::Empty,
225        }
226    }
227
228    /// Extracts a UTF-8 Content-Type header value from a header map.
229    ///
230    /// # Parameters
231    /// - `headers`: Headers to inspect.
232    ///
233    /// # Returns
234    /// `Some` with UTF-8 Content-Type, otherwise `None`.
235    fn content_type(headers: &http::HeaderMap) -> Option<&str> {
236        headers.get(CONTENT_TYPE).and_then(|value| value.to_str().ok())
237    }
238}