qubit_http/client/
http_logger.rs1use 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#[derive(Debug, Clone)]
35pub struct HttpLogger<'a> {
36 options: &'a HttpLoggingOptions,
37 sanitized_logger: SanitizedLogger,
38}
39
40enum RequestBodyLogPreview<'a> {
42 Bytes(&'a [u8]),
44 Empty,
46 Skipped(&'static str),
48}
49
50impl<'a> HttpLogger<'a> {
51 pub fn new(options: &'a HttpClientOptions) -> Self {
60 Self {
61 options: &options.logging,
62 sanitized_logger: SanitizedLogger::from_options(options),
63 }
64 }
65
66 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
83 .effective_headers_cached()
84 .unwrap_or_else(|| request.headers());
85
86 if self.options.log_request_header {
87 for (name, value) in headers {
88 let masked = self.sanitized_logger.header_value(name, value);
89 tracing::trace!("{}: {}", name.as_str(), masked);
90 }
91 }
92
93 if self.options.log_request_body {
94 match Self::request_body_for_log(request) {
95 RequestBodyLogPreview::Bytes(bytes) => {
96 let content_type = Self::content_type(headers);
97 tracing::trace!(
98 "Request body: {}",
99 self.sanitized_logger
100 .body(bytes, BodyLogContext::Request, content_type)
101 );
102 }
103 RequestBodyLogPreview::Empty => tracing::trace!("Request body: <empty>"),
104 RequestBodyLogPreview::Skipped(reason) => tracing::trace!("Request body: {reason}"),
105 }
106 }
107 }
108
109 pub async fn log_response(&self, response: &mut HttpResponse) -> crate::HttpResult<()> {
120 if !self.is_trace_enabled() {
121 return Ok(());
122 }
123
124 tracing::trace!(
125 "<-- {} {}",
126 response.status().as_u16(),
127 self.sanitized_logger.url(response.url())
128 );
129
130 if self.options.log_response_header {
131 for (name, value) in response.headers() {
132 let masked = self.sanitized_logger.header_value(name, value);
133 tracing::trace!("{}: {}", name.as_str(), masked);
134 }
135 }
136
137 if self.options.log_response_body {
138 let content_type = Self::content_type(response.headers()).map(str::to_string);
139 if let Some(body) = response.buffered_body_for_logging() {
140 tracing::trace!(
141 "Response body: {}",
142 self.sanitized_logger.body(
143 body.as_ref(),
144 BodyLogContext::Response,
145 content_type.as_deref()
146 )
147 );
148 } else if response.can_buffer_body_for_logging(self.options.body_size_limit) {
149 let body = response.bytes().await?;
150 tracing::trace!(
151 "Response body: {}",
152 self.sanitized_logger.body(
153 body.as_ref(),
154 BodyLogContext::Response,
155 content_type.as_deref()
156 )
157 );
158 } else {
159 tracing::trace!("Response body: <skipped: streaming or unknown-size body>");
160 }
161 }
162 Ok(())
163 }
164
165 pub fn log_stream_response_headers(&self, response_meta: &HttpResponseMeta) {
173 if !self.is_trace_enabled() {
174 return;
175 }
176
177 tracing::trace!(
178 "<-- {} {} (stream)",
179 response_meta.status().as_u16(),
180 self.sanitized_logger.url(response_meta.url())
181 );
182
183 if self.options.log_response_header {
184 for (name, value) in response_meta.headers() {
185 let masked = self.sanitized_logger.header_value(name, value);
186 tracing::trace!("{}: {}", name.as_str(), masked);
187 }
188 }
189 }
190
191 pub fn is_trace_enabled(&self) -> bool {
196 self.options.enabled && tracing::enabled!(tracing::Level::TRACE)
197 }
198
199 fn request_log_url(&self, request: &HttpRequest) -> String {
208 request
209 .resolved_url()
210 .map(|url| self.sanitized_logger.url(&url))
211 .unwrap_or_else(|_| UNRESOLVED_REQUEST_URL.to_string())
212 }
213
214 fn request_body_for_log(request: &HttpRequest) -> RequestBodyLogPreview<'_> {
222 if request.has_streaming_body() {
223 return RequestBodyLogPreview::Skipped(STREAMING_REQUEST_BODY_SKIPPED);
224 }
225 match request.body() {
226 HttpRequestBody::Bytes(bytes)
227 | HttpRequestBody::Json(bytes)
228 | HttpRequestBody::Form(bytes)
229 | HttpRequestBody::Multipart(bytes)
230 | HttpRequestBody::Ndjson(bytes) => RequestBodyLogPreview::Bytes(bytes.as_ref()),
231 HttpRequestBody::Text(text) => RequestBodyLogPreview::Bytes(text.as_bytes()),
232 HttpRequestBody::Stream(_) => {
233 RequestBodyLogPreview::Skipped(STREAMING_REQUEST_BODY_SKIPPED)
234 }
235 HttpRequestBody::Empty => RequestBodyLogPreview::Empty,
236 }
237 }
238
239 fn content_type(headers: &http::HeaderMap) -> Option<&str> {
247 headers
248 .get(CONTENT_TYPE)
249 .and_then(|value| value.to_str().ok())
250 }
251}