siderite_core/middleware/
observe.rs1use super::adapt::{BoxService, Next, from_fn, impl_layer};
4use crate::error::ApiError;
5use crate::extract::{FromRequestParts, Request};
6use crate::response::Response;
7use http::request::Parts;
8use http::{HeaderName, HeaderValue};
9use std::fmt;
10use std::sync::{Arc, Mutex, PoisonError};
11use std::time::Instant;
12use tower::Layer;
13use tracing::Instrument;
14
15const DEFAULT_HEADER: &str = "x-request-id";
16const MAX_ID_LEN: usize = 128;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct RequestId(pub String);
23
24impl fmt::Display for RequestId {
25 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26 f.write_str(&self.0)
27 }
28}
29
30impl FromRequestParts for RequestId {
31 async fn from_request_parts(parts: &mut Parts) -> Result<Self, ApiError> {
32 parts
33 .extensions
34 .get::<RequestId>()
35 .cloned()
36 .ok_or_else(|| ApiError::internal("RequestId used without the request_id middleware"))
37 }
38}
39
40#[derive(Debug, Clone)]
45pub struct RequestIdLayer {
46 header: HeaderName,
47}
48
49impl Default for RequestIdLayer {
50 fn default() -> Self {
51 Self {
52 header: HeaderName::from_static(DEFAULT_HEADER),
53 }
54 }
55}
56
57impl RequestIdLayer {
58 pub fn new() -> Self {
60 Self::default()
61 }
62
63 #[must_use]
65 pub fn header(mut self, name: HeaderName) -> Self {
66 self.header = name;
67 self
68 }
69
70 fn wrap(&self, inner: BoxService) -> BoxService {
71 let header = self.header.clone();
72 from_fn(move |mut req: Request, next: Next| {
73 let header = header.clone();
74 async move {
75 let id = req
76 .headers()
77 .get(&header)
78 .and_then(|v| v.to_str().ok())
79 .filter(|v| valid_id(v))
80 .map_or_else(|| uuid::Uuid::new_v4().to_string(), str::to_owned);
81 let value = HeaderValue::from_str(&id).ok();
82 if let Some(value) = &value {
83 req.headers_mut().insert(header.clone(), value.clone());
84 }
85 req.extensions_mut().insert(RequestId(id));
86 let mut response = next.run(req).await;
87 if let Some(value) = value {
88 response.headers_mut().insert(header, value);
89 }
90 response
91 }
92 })
93 .layer(inner)
94 }
95}
96
97fn valid_id(id: &str) -> bool {
98 !id.is_empty() && id.len() <= MAX_ID_LEN && id.bytes().all(|b| b.is_ascii_graphic())
99}
100
101#[derive(Clone, Default)]
104pub(crate) struct RouteSlot(Arc<Mutex<Option<String>>>);
105
106impl RouteSlot {
107 fn set(&self, route: String) {
108 *self.0.lock().unwrap_or_else(PoisonError::into_inner) = Some(route);
109 }
110
111 fn get(&self) -> Option<String> {
112 self.0
113 .lock()
114 .unwrap_or_else(PoisonError::into_inner)
115 .clone()
116 }
117}
118
119pub(crate) fn note_matched_path(parts: &Parts) {
121 if let (Some(slot), Some(path)) = (
122 parts.extensions.get::<RouteSlot>(),
123 parts.extensions.get::<axum::extract::MatchedPath>(),
124 ) {
125 slot.set(path.as_str().to_owned());
126 }
127}
128
129#[derive(Debug, Clone, Default)]
137pub struct RequestLogging;
138
139impl RequestLogging {
140 pub fn new() -> Self {
142 Self
143 }
144
145 fn wrap(&self, inner: BoxService) -> BoxService {
146 from_fn(|mut req: Request, next: Next| async move {
147 let start = Instant::now();
148 let slot = RouteSlot::default();
149 req.extensions_mut().insert(slot.clone());
150 let request_id = req
151 .extensions()
152 .get::<RequestId>()
153 .map_or_else(|| "-".to_owned(), |id| id.0.clone());
154 let method = req.method().clone();
155 let span = tracing::info_span!(
156 "http.request",
157 request_id = %request_id,
158 method = %method,
159 route = tracing::field::Empty,
160 status = tracing::field::Empty,
161 latency_ms = tracing::field::Empty,
162 );
163 let response: Response = next.run(req).instrument(span.clone()).await;
164 let status = response.status().as_u16();
165 let latency_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
166 let route = slot.get().unwrap_or_else(|| "<unmatched>".to_owned());
167 span.record("route", tracing::field::display(&route));
168 span.record("status", status);
169 span.record("latency_ms", latency_ms);
170 tracing::info!(parent: &span, %route, status, latency_ms, "request completed");
171 response
172 })
173 .layer(inner)
174 }
175}
176
177impl_layer!(RequestIdLayer, RequestLogging);
178
179#[cfg(test)]
180#[allow(clippy::unwrap_used)]
181mod tests {
182 use crate::app::App;
183 use crate::body::Body;
184 use crate::routing::get;
185 use http::Request;
186 use std::sync::atomic::{AtomicUsize, Ordering};
187 use std::sync::{Arc, Mutex};
188 use tower::ServiceExt;
189
190 #[derive(Clone, Default)]
191 struct Capture {
192 lines: Arc<Mutex<Vec<String>>>,
193 next_id: Arc<AtomicUsize>,
194 }
195
196 struct Fields(String);
197
198 impl tracing::field::Visit for Fields {
199 fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
200 use std::fmt::Write as _;
201 let _ = write!(self.0, "{}={value:?} ", field.name());
202 }
203
204 fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
205 use std::fmt::Write as _;
206 let _ = write!(self.0, "{}={value} ", field.name());
207 }
208
209 fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
210 use std::fmt::Write as _;
211 let _ = write!(self.0, "{}={value} ", field.name());
212 }
213
214 fn record_i64(&mut self, field: &tracing::field::Field, value: i64) {
215 use std::fmt::Write as _;
216 let _ = write!(self.0, "{}={value} ", field.name());
217 }
218
219 fn record_u128(&mut self, field: &tracing::field::Field, value: u128) {
220 use std::fmt::Write as _;
221 let _ = write!(self.0, "{}={value} ", field.name());
222 }
223 }
224
225 impl tracing::Subscriber for Capture {
226 fn enabled(&self, _: &tracing::Metadata<'_>) -> bool {
227 true
228 }
229
230 fn new_span(&self, attrs: &tracing::span::Attributes<'_>) -> tracing::span::Id {
231 let mut fields = Fields(format!("span {} ", attrs.metadata().name()));
232 attrs.record(&mut fields);
233 self.lines.lock().unwrap().push(fields.0);
234 let id = self.next_id.fetch_add(1, Ordering::SeqCst) as u64 + 1;
235 tracing::span::Id::from_u64(id)
236 }
237
238 fn record(&self, _: &tracing::span::Id, values: &tracing::span::Record<'_>) {
239 let mut fields = Fields("record ".to_owned());
240 values.record(&mut fields);
241 self.lines.lock().unwrap().push(fields.0);
242 }
243
244 fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {}
245
246 fn event(&self, event: &tracing::Event<'_>) {
247 let mut fields = Fields("event ".to_owned());
248 event.record(&mut fields);
249 self.lines.lock().unwrap().push(fields.0);
250 }
251
252 fn enter(&self, _: &tracing::span::Id) {}
253
254 fn exit(&self, _: &tracing::span::Id) {}
255 }
256
257 async fn send(app: App, path: &str, headers: &[(&str, &str)]) -> crate::http::StatusCode {
258 let mut req = Request::builder().method("GET").uri(path);
259 for (k, v) in headers {
260 req = req.header(*k, *v);
261 }
262 let service = app.into_router_service().unwrap();
263 service
264 .oneshot(req.body(Body::from("")).unwrap())
265 .await
266 .unwrap()
267 .status()
268 }
269
270 #[tokio::test]
271 async fn http_request_span_records_fields_and_never_secrets() {
272 let capture = Capture::default();
273 let _guard = tracing::subscriber::set_default(capture.clone());
274 let app = App::new()
275 .route("/users/{id}", get(|| async { "u" }))
276 .request_id()
277 .request_logging();
278 let status = send(
279 app,
280 "/users/42?api_key=QUERYSECRET",
281 &[
282 ("authorization", "Bearer TOPSECRET"),
283 ("cookie", "session=COOKIESECRET"),
284 ("x-api-key", "KEYSECRET"),
285 ("x-request-id", "req-1"),
286 ],
287 )
288 .await;
289 assert_eq!(status, crate::http::StatusCode::OK);
290 let logged = capture.lines.lock().unwrap().join("\n");
291 assert!(logged.contains("span http.request"), "span name:\n{logged}");
292 for secret in [
293 "TOPSECRET",
294 "COOKIESECRET",
295 "KEYSECRET",
296 "QUERYSECRET",
297 "api_key=",
298 ] {
299 assert!(
300 !logged.contains(secret),
301 "{secret} leaked into logs:\n{logged}"
302 );
303 }
304 assert!(
305 logged.contains("request_id=req-1") || logged.contains("request_id=\"req-1\""),
306 "{logged}"
307 );
308 assert!(logged.contains("/users/{id}"), "matched route:\n{logged}");
309 assert!(logged.contains("status=200"), "{logged}");
310 assert!(logged.contains("latency_ms="), "{logged}");
311 assert!(
312 logged.contains("method=GET") || logged.contains("method=\"GET\""),
313 "{logged}"
314 );
315 assert!(!logged.contains("/users/42?"), "{logged}");
316 assert!(
317 !logged.contains("Authorization") && !logged.contains("Bearer "),
318 "{logged}"
319 );
320 }
321
322 #[tokio::test]
323 async fn unmatched_route_is_recorded() {
324 let capture = Capture::default();
325 let _guard = tracing::subscriber::set_default(capture.clone());
326 let app = App::new()
327 .route("/hello", get(|| async { "h" }))
328 .request_id()
329 .request_logging();
330 let status = send(
331 app,
332 "/missing?token=QUERYSECRET",
333 &[("authorization", "Bearer TOPSECRET")],
334 )
335 .await;
336 assert_eq!(status, crate::http::StatusCode::NOT_FOUND);
337 let logged = capture.lines.lock().unwrap().join("\n");
338 assert!(logged.contains("<unmatched>"), "{logged}");
339 assert!(logged.contains("status=404"), "{logged}");
340 assert!(!logged.contains("QUERYSECRET"), "{logged}");
341 assert!(!logged.contains("TOPSECRET"), "{logged}");
342 }
343}