qubit_http/client/
http_logger.rs1use crate::constants::{
18 SENSITIVE_HEADER_MASK_EDGE_CHARS, SENSITIVE_HEADER_MASK_PLACEHOLDER,
19 SENSITIVE_HEADER_MASK_SHORT_LEN,
20};
21use crate::{
22 HttpClientOptions, HttpLoggingOptions, HttpRequest, HttpRequestBody, HttpResponse,
23 HttpResponseMeta, SensitiveHttpHeaders,
24};
25use bytes::Bytes;
26
27#[derive(Debug, Clone, Copy)]
29pub struct HttpLogger<'a> {
30 options: &'a HttpLoggingOptions,
31 sensitive_headers: &'a SensitiveHttpHeaders,
32}
33
34impl<'a> HttpLogger<'a> {
35 pub fn new(options: &'a HttpClientOptions) -> Self {
44 Self {
45 options: &options.logging,
46 sensitive_headers: &options.sensitive_headers,
47 }
48 }
49
50 pub fn log_request(&self, request: &HttpRequest) {
59 if !self.is_trace_enabled() {
60 return;
61 }
62
63 let url = Self::request_log_url(request);
64 tracing::trace!("--> {} {}", request.method(), url);
65
66 let headers = request
67 .effective_headers_cached()
68 .unwrap_or_else(|| request.headers());
69
70 if self.options.log_request_header {
71 for (name, value) in headers {
72 let value = value.to_str().unwrap_or("<non-utf8>");
73 let masked = self.mask_header_value(name.as_str(), value);
74 tracing::trace!("{}: {}", name.as_str(), masked);
75 }
76 }
77
78 if self.options.log_request_body {
79 match Self::clone_request_body_for_log(request.body()) {
80 Some(bytes) => tracing::trace!("Request body: {}", self.render_body(&bytes)),
81 None => tracing::trace!("Request body: <empty>"),
82 }
83 }
84 }
85
86 pub async fn log_response(&self, response: &mut HttpResponse) -> crate::HttpResult<()> {
97 if !self.is_trace_enabled() {
98 return Ok(());
99 }
100
101 tracing::trace!("<-- {} {}", response.status().as_u16(), response.url());
102
103 if self.options.log_response_header {
104 for (name, value) in response.headers() {
105 let value = value.to_str().unwrap_or("<non-utf8>");
106 let masked = self.mask_header_value(name.as_str(), value);
107 tracing::trace!("{}: {}", name.as_str(), masked);
108 }
109 }
110
111 if self.options.log_response_body {
112 if let Some(body) = response.buffered_body_for_logging() {
113 tracing::trace!("Response body: {}", self.render_body(body));
114 } else if response.can_buffer_body_for_logging(self.options.body_size_limit) {
115 let body = response.bytes().await?;
116 tracing::trace!("Response body: {}", self.render_body(&body));
117 } else {
118 tracing::trace!("Response body: <skipped: streaming or unknown-size body>");
119 }
120 }
121 Ok(())
122 }
123
124 pub fn log_stream_response_headers(&self, response_meta: &HttpResponseMeta) {
132 if !self.is_trace_enabled() {
133 return;
134 }
135
136 tracing::trace!(
137 "<-- {} {} (stream)",
138 response_meta.status.as_u16(),
139 &response_meta.url
140 );
141
142 if self.options.log_response_header {
143 for (name, value) in &response_meta.headers {
144 let value = value.to_str().unwrap_or("<non-utf8>");
145 let masked = self.mask_header_value(name.as_str(), value);
146 tracing::trace!("{}: {}", name.as_str(), masked);
147 }
148 }
149 }
150
151 pub fn is_trace_enabled(&self) -> bool {
156 self.options.enabled && tracing::enabled!(tracing::Level::TRACE)
157 }
158
159 fn request_log_url(request: &HttpRequest) -> String {
168 request
169 .resolved_url_with_query()
170 .map(|url| url.to_string())
171 .unwrap_or_else(|_| request.path().to_string())
172 }
173
174 fn mask_header_value(&self, name: &str, value: &str) -> String {
183 if value.is_empty() {
184 return String::new();
185 }
186 if !self.sensitive_headers.contains(name) {
187 return value.to_string();
188 }
189
190 let chars: Vec<char> = value.chars().collect();
191 if chars.len() <= SENSITIVE_HEADER_MASK_SHORT_LEN {
192 SENSITIVE_HEADER_MASK_PLACEHOLDER.to_string()
193 } else {
194 let edge = SENSITIVE_HEADER_MASK_EDGE_CHARS;
195 let prefix: String = chars[..edge].iter().collect();
196 let suffix: String = chars[chars.len() - edge..].iter().collect();
197 format!("{prefix}{SENSITIVE_HEADER_MASK_PLACEHOLDER}{suffix}")
198 }
199 }
200
201 fn render_body(&self, body: &Bytes) -> String {
209 if body.is_empty() {
210 return "<empty>".to_string();
211 }
212
213 let max_bytes = self.options.body_size_limit;
214 let limit = body.len().min(max_bytes);
215 let prefix = &body[..limit];
216 let suffix = if body.len() > max_bytes {
217 format!("...<truncated {} bytes>", body.len() - max_bytes)
218 } else {
219 String::new()
220 };
221
222 match std::str::from_utf8(prefix) {
223 Ok(text) => format!("{}{}", text, suffix),
224 Err(_) => format!("<binary {} bytes>{}", body.len(), suffix),
225 }
226 }
227
228 fn clone_request_body_for_log(body: &HttpRequestBody) -> Option<Bytes> {
236 match body {
237 HttpRequestBody::Bytes(bytes)
238 | HttpRequestBody::Json(bytes)
239 | HttpRequestBody::Form(bytes)
240 | HttpRequestBody::Multipart(bytes)
241 | HttpRequestBody::Ndjson(bytes) => Some(bytes.clone()),
242 HttpRequestBody::Text(text) => Some(Bytes::from(text.clone())),
243 HttpRequestBody::Stream(_) => None,
244 HttpRequestBody::Empty => None,
245 }
246 }
247}
248
249#[cfg(coverage)]
254#[doc(hidden)]
255pub(crate) fn coverage_exercise_request_log_url_fallback() -> String {
256 let client = crate::HttpClientFactory::new()
257 .create_default()
258 .expect("coverage HTTP client should build");
259 let request = client.request(http::Method::GET, "/relative-only").build();
260 HttpLogger::request_log_url(&request)
261}