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