Skip to main content

qubit_http/
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 bytes::Bytes;
18use http::{HeaderMap, Method, StatusCode};
19use url::Url;
20
21use crate::constants::{
22    SENSITIVE_HEADER_MASK_EDGE_CHARS, SENSITIVE_HEADER_MASK_PLACEHOLDER,
23    SENSITIVE_HEADER_MASK_SHORT_LEN,
24};
25use crate::{HttpLoggingOptions, SensitiveHeaders};
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 SensitiveHeaders,
32}
33
34impl<'a> HttpLogger<'a> {
35    /// Creates a logger view over logging options and sensitive header rules.
36    ///
37    /// # Parameters
38    /// - `options`: Logging switches and body size limits.
39    /// - `sensitive_headers`: Header names requiring masking.
40    ///
41    /// # Returns
42    /// A logger that emits TRACE records according to `options`.
43    pub fn new(options: &'a HttpLoggingOptions, sensitive_headers: &'a SensitiveHeaders) -> Self {
44        Self {
45            options,
46            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    /// - `method`: HTTP method.
54    /// - `url`: Full request URL.
55    /// - `headers`: Outgoing headers (values may be masked).
56    /// - `body`: Optional body preview source.
57    ///
58    /// # Returns
59    /// Nothing; no-op when disabled or TRACE off.
60    pub fn log_request(
61        &self,
62        method: &Method,
63        url: &Url,
64        headers: &HeaderMap,
65        body: Option<&Bytes>,
66    ) {
67        if !self.is_trace_enabled() {
68            return;
69        }
70
71        tracing::trace!("--> {} {}", method, url);
72
73        if self.options.log_request_header {
74            for (name, value) in headers {
75                let value = value.to_str().unwrap_or("<non-utf8>");
76                let masked = self.mask_header_value(name.as_str(), value);
77                tracing::trace!("{}: {}", name.as_str(), masked);
78            }
79        }
80
81        if self.options.log_request_body {
82            match body {
83                Some(bytes) => tracing::trace!("Request body: {}", self.render_body(bytes)),
84                None => tracing::trace!("Request body: <empty>"),
85            }
86        }
87    }
88
89    /// Emits TRACE logs for a completed response (headers and optional body preview).
90    ///
91    /// # Parameters
92    /// - `status`: Response status.
93    /// - `url`: Response URL.
94    /// - `headers`: Response headers (masked per policy).
95    /// - `body`: Full body bytes for optional preview.
96    ///
97    /// # Returns
98    /// Nothing; no-op when disabled or TRACE off.
99    pub fn log_response(&self, status: StatusCode, url: &Url, headers: &HeaderMap, body: &Bytes) {
100        if !self.is_trace_enabled() {
101            return;
102        }
103
104        tracing::trace!("<-- {} {}", status.as_u16(), url);
105
106        if self.options.log_response_header {
107            for (name, value) in headers {
108                let value = value.to_str().unwrap_or("<non-utf8>");
109                let masked = self.mask_header_value(name.as_str(), value);
110                tracing::trace!("{}: {}", name.as_str(), masked);
111            }
112        }
113
114        if self.options.log_response_body {
115            tracing::trace!("Response body: {}", self.render_body(body));
116        }
117    }
118
119    /// Logs response line and headers for a streaming call without reading the body stream.
120    ///
121    /// # Parameters
122    /// - `status`: Response status.
123    /// - `url`: Response URL.
124    /// - `headers`: Response headers.
125    ///
126    /// # Returns
127    /// Nothing; no-op when disabled or TRACE off.
128    pub fn log_stream_response_headers(&self, status: StatusCode, url: &Url, headers: &HeaderMap) {
129        if !self.is_trace_enabled() {
130            return;
131        }
132
133        tracing::trace!("<-- {} {} (stream)", status.as_u16(), url);
134
135        if self.options.log_response_header {
136            for (name, value) in headers {
137                let value = value.to_str().unwrap_or("<non-utf8>");
138                let masked = self.mask_header_value(name.as_str(), value);
139                tracing::trace!("{}: {}", name.as_str(), masked);
140            }
141        }
142    }
143
144    /// Returns whether TRACE logs should be emitted under current options and subscriber state.
145    ///
146    /// # Returns
147    /// `true` when logging is enabled and TRACE is active.
148    fn is_trace_enabled(&self) -> bool {
149        self.options.enabled && tracing::enabled!(tracing::Level::TRACE)
150    }
151
152    /// Returns a masked representation of a header value according to sensitivity rules.
153    ///
154    /// # Parameters
155    /// - `name`: Header name.
156    /// - `value`: Raw header value.
157    ///
158    /// # Returns
159    /// A log-safe string when the header is sensitive; otherwise the original value.
160    fn mask_header_value(&self, name: &str, value: &str) -> String {
161        if value.is_empty() {
162            return String::new();
163        }
164        if !self.sensitive_headers.contains(name) {
165            return value.to_string();
166        }
167
168        let chars: Vec<char> = value.chars().collect();
169        if chars.len() <= SENSITIVE_HEADER_MASK_SHORT_LEN {
170            SENSITIVE_HEADER_MASK_PLACEHOLDER.to_string()
171        } else {
172            let edge = SENSITIVE_HEADER_MASK_EDGE_CHARS;
173            let prefix: String = chars[..edge].iter().collect();
174            let suffix: String = chars[chars.len() - edge..].iter().collect();
175            format!("{prefix}{SENSITIVE_HEADER_MASK_PLACEHOLDER}{suffix}")
176        }
177    }
178
179    /// Formats up to configured `body_size_limit` bytes of `body` for TRACE output.
180    ///
181    /// # Parameters
182    /// - `body`: Raw bytes.
183    ///
184    /// # Returns
185    /// Human-readable body preview string.
186    fn render_body(&self, body: &Bytes) -> String {
187        if body.is_empty() {
188            return "<empty>".to_string();
189        }
190
191        let max_bytes = self.options.body_size_limit;
192        let limit = body.len().min(max_bytes);
193        let prefix = &body[..limit];
194        let suffix = if body.len() > max_bytes {
195            format!("...<truncated {} bytes>", body.len() - max_bytes)
196        } else {
197            String::new()
198        };
199
200        match std::str::from_utf8(prefix) {
201            Ok(text) => format!("{}{}", text, suffix),
202            Err(_) => format!("<binary {} bytes>{}", body.len(), suffix),
203        }
204    }
205}