qubit_http/client/
http_logger.rs1use 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#[derive(Debug, Clone)]
34pub struct HttpLogger<'a> {
35 options: &'a HttpLoggingOptions,
36 sanitizer: LogSanitizer,
37}
38
39enum RequestBodyLogPreview<'a> {
41 Bytes(&'a [u8]),
43 Empty,
45 Skipped(&'static str),
47}
48
49impl<'a> HttpLogger<'a> {
50 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 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 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 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 pub fn is_trace_enabled(&self) -> bool {
190 self.options.enabled && tracing::enabled!(tracing::Level::TRACE)
191 }
192
193 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 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 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 fn content_type(headers: &http::HeaderMap) -> Option<&str> {
265 headers
266 .get(CONTENT_TYPE)
267 .and_then(|value| value.to_str().ok())
268 }
269}