doido_controller/logging.rs
1//! Request/response logging middleware.
2//!
3//! [`log_requests`] logs each incoming request and its response through the
4//! framework's centralized logger (`doido_core::logger`), so every HTTP exchange
5//! flows through the same `tracing` subscriber as jobs, mail and ORM queries. It
6//! is wired into the always-on [`MiddlewareStack`] (`crate::stack`).
7//!
8//! A per-request UUID (`request_id`) ties the `request` and `response` log lines
9//! together; it is taken from an inbound `x-request-id` header when present (so
10//! an upstream proxy's id is preserved) or generated otherwise, and echoed back
11//! on the response so clients can correlate too. The `request` line carries the
12//! method, path, query and request headers; the `response` line carries the
13//! status, latency and response headers.
14
15use crate::axum::{extract::Request, middleware::Next, response::Response};
16use doido_core::tracing::Instrument;
17use http::{HeaderMap, HeaderName, HeaderValue};
18use std::time::Instant;
19use uuid::Uuid;
20
21/// Header carrying the request correlation id, in/out.
22const REQUEST_ID_HEADER: &str = "x-request-id";
23
24/// Logs an incoming request and its response through the doido logger.
25///
26/// Two events flow through the global `tracing` subscriber per exchange, sharing
27/// a `request_id`: a `request` line when it arrives (method, path, query, request
28/// headers), and a `response` line once the response is ready (status, latency,
29/// response headers). Both run inside a `request` span so nested events (e.g. SQL
30/// queries) correlate back to the originating request.
31pub async fn log_requests(request: Request, next: Next) -> Response {
32 let request_id = resolve_request_id(request.headers());
33 let method = request.method().clone();
34 let path = request.uri().path().to_owned();
35 let query = request.uri().query().map(str::to_owned);
36 let request_headers = format_headers(request.headers());
37
38 // Span carrying the request identity; nested events inherit it.
39 let span = doido_core::tracing::info_span!(
40 "request",
41 request_id = %request_id,
42 method = %method,
43 path = %path,
44 );
45 doido_core::tracing::info!(
46 target: doido_core::logger::REQUEST_TARGET,
47 parent: &span,
48 request_id = %request_id,
49 method = %method,
50 path = %path,
51 query = query.as_deref().unwrap_or(""),
52 headers = %request_headers,
53 "request"
54 );
55
56 let start = Instant::now();
57 // `instrument` enters the span for the whole handler, across `.await`s.
58 let mut response = next.run(request).instrument(span.clone()).await;
59 let latency_ms = start.elapsed().as_millis() as u64;
60
61 // Echo the correlation id back so clients/proxies can trace it; do this
62 // before logging so the logged response headers reflect what's sent.
63 if let Ok(value) = HeaderValue::from_str(&request_id) {
64 response
65 .headers_mut()
66 .insert(HeaderName::from_static(REQUEST_ID_HEADER), value);
67 }
68
69 let status = response.status().as_u16();
70 let response_headers = format_headers(response.headers());
71 {
72 // Emit the response event inside the span; no `.await` follows.
73 let _guard = span.enter();
74 doido_core::tracing::info!(
75 target: doido_core::logger::RESPONSE_TARGET,
76 request_id = %request_id,
77 method = %method,
78 path = %path,
79 status = status,
80 latency_ms = latency_ms,
81 headers = %response_headers,
82 "response"
83 );
84 }
85
86 response
87}
88
89/// Returns the inbound `x-request-id` when present and non-empty, otherwise a
90/// freshly generated UUID v4.
91fn resolve_request_id(headers: &HeaderMap) -> String {
92 headers
93 .get(REQUEST_ID_HEADER)
94 .and_then(|value| value.to_str().ok())
95 .map(str::trim)
96 .filter(|id| !id.is_empty())
97 .map(str::to_owned)
98 .unwrap_or_else(|| Uuid::new_v4().to_string())
99}
100
101/// Renders request headers as `name: value` pairs for a single log field.
102/// Sensitive headers (auth, cookies) are redacted so secrets never reach logs.
103fn format_headers(headers: &HeaderMap) -> String {
104 headers
105 .iter()
106 .map(|(name, value)| {
107 let rendered = if is_sensitive(name.as_str()) {
108 "[redacted]"
109 } else {
110 value.to_str().unwrap_or("[non-utf8]")
111 };
112 format!("{}: {}", name.as_str(), rendered)
113 })
114 .collect::<Vec<_>>()
115 .join(", ")
116}
117
118/// Whether a header's value must be redacted from logs. Header names compare
119/// lowercase (the `http` crate normalizes them).
120fn is_sensitive(name: &str) -> bool {
121 matches!(
122 name,
123 "authorization" | "proxy-authorization" | "cookie" | "set-cookie"
124 )
125}