Skip to main content

rustlavel_http/
request_id.rs

1//! One identifier per request, from the first log line to the response header.
2//!
3//! When a user reports "it failed around 3pm", the only thing that connects
4//! their screenshot to a line in the log is an identifier that appeared in
5//! both. This middleware assigns one — or keeps the one a load balancer already
6//! attached — puts it on the request for handlers to read, on the response for
7//! the client to quote, and on the `http.request` instrumentation event for
8//! Telescope, the debug bar and OTLP traces.
9//!
10//! ```ignore
11//! App::new()?.middleware(RequestId::default())
12//!
13//! // In a handler:
14//! let id = req.request_id().unwrap_or("-");
15//!
16//! // Anywhere inside the request, without a `Request` in hand:
17//! if let Some(id) = request_id::current() { … }
18//! ```
19//!
20//! Incoming identifiers are trusted by default, because the useful case — an
21//! edge proxy that stamps every request and logs it — is far more common than
22//! the harmful one, and a forged id can do nothing except confuse the forger's
23//! own log search. Anything that does not look like an identifier (too long,
24//! not printable ASCII) is replaced rather than passed on.
25
26use crate::handler::BoxFuture;
27use crate::middleware::{Middleware, Next};
28use crate::request::Request;
29use crate::response::Response;
30use std::hash::{BuildHasher, Hasher};
31use std::sync::atomic::{AtomicU64, Ordering};
32
33/// The header the identifier travels in, unless configured otherwise.
34pub const HEADER: &str = "x-request-id";
35
36/// The identifier assigned to the current request, attached as an extension.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct Assigned(pub String);
39
40tokio::task_local! {
41    static CURRENT: String;
42}
43
44/// The identifier of the request this task is serving, if any.
45///
46/// Available to code that has no `Request` to hand — a repository, a mailer,
47/// a log formatter — for as long as it runs inside the middleware's scope.
48pub fn current() -> Option<String> {
49    CURRENT.try_with(|id| id.clone()).ok()
50}
51
52#[derive(Debug, Clone)]
53pub struct RequestId {
54    header: String,
55    trust_incoming: bool,
56}
57
58impl Default for RequestId {
59    fn default() -> Self {
60        RequestId { header: HEADER.to_string(), trust_incoming: true }
61    }
62}
63
64impl RequestId {
65    pub fn new() -> Self {
66        Self::default()
67    }
68
69    /// Use a different header — `X-Correlation-Id`, `X-Amzn-Trace-Id`.
70    pub fn header(mut self, name: &str) -> Self {
71        self.header = name.to_ascii_lowercase();
72        self
73    }
74
75    /// Always mint a fresh identifier, ignoring whatever the client sent.
76    ///
77    /// For an application reached directly from the internet with no proxy in
78    /// front, where nothing upstream is trusted to have chosen one.
79    pub fn ignore_incoming(mut self) -> Self {
80        self.trust_incoming = false;
81        self
82    }
83
84    fn choose(&self, request: &Request) -> String {
85        if self.trust_incoming
86            && let Some(incoming) = request.header(&self.header)
87            && looks_like_an_id(incoming)
88        {
89            return incoming.to_string();
90        }
91        generate()
92    }
93}
94
95/// Printable ASCII, no whitespace, and short enough to be an identifier
96/// rather than a payload someone is trying to smuggle into the logs.
97fn looks_like_an_id(candidate: &str) -> bool {
98    !candidate.is_empty()
99        && candidate.len() <= 128
100        && candidate.bytes().all(|b| b.is_ascii_graphic())
101}
102
103/// A fresh identifier in the shape of a version-4 UUID.
104///
105/// The shape is borrowed because every log tool already knows how to spot and
106/// index it. The randomness is not cryptographic and does not pretend to be —
107/// the process-random seed the standard library hands every `RandomState`,
108/// stirred with a counter through SplitMix64 — which is exactly enough for two
109/// requests, on two machines, to never share an identifier by accident.
110pub fn generate() -> String {
111    static STATE: AtomicU64 = AtomicU64::new(0);
112
113    let mut state = STATE.load(Ordering::Relaxed);
114    if state == 0 {
115        let mut hasher = std::hash::RandomState::new().build_hasher();
116        hasher.write_u128(
117            std::time::SystemTime::now()
118                .duration_since(std::time::UNIX_EPOCH)
119                .map_or(0, |d| d.as_nanos()),
120        );
121        hasher.write_u32(std::process::id());
122        // Two racing initialisers both compute a seed; whichever loses the
123        // exchange simply uses the winner's, and both continue from it.
124        let seed = hasher.finish() | 1;
125        state = match STATE.compare_exchange(0, seed, Ordering::Relaxed, Ordering::Relaxed) {
126            Ok(_) => seed,
127            Err(existing) => existing,
128        };
129    }
130
131    // SplitMix64: each output is the mix of a distinct counter value, so two
132    // calls can only collide if the counter itself wraps 2^64 times.
133    let next = || {
134        let counter = STATE.fetch_add(0x9E37_79B9_7F4A_7C15, Ordering::Relaxed);
135        let mut z = counter.wrapping_add(state);
136        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
137        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
138        z ^ (z >> 31)
139    };
140    let (high, low) = (next(), next());
141
142    // RFC 9562 §5.4: version nibble 4, variant bits 10.
143    let high = (high & 0xFFFF_FFFF_FFFF_0FFF) | 0x0000_0000_0000_4000;
144    let low = (low & 0x3FFF_FFFF_FFFF_FFFF) | 0x8000_0000_0000_0000;
145    format!(
146        "{:08x}-{:04x}-{:04x}-{:04x}-{:012x}",
147        high >> 32,
148        (high >> 16) & 0xFFFF,
149        high & 0xFFFF,
150        low >> 48,
151        low & 0xFFFF_FFFF_FFFF
152    )
153}
154
155impl Middleware for RequestId {
156    fn handle(&self, mut request: Request, next: Next) -> BoxFuture<Response> {
157        let id = self.choose(&request);
158        let header = self.header.clone();
159        request.extend(Assigned(id.clone()));
160
161        Box::pin(CURRENT.scope(id.clone(), async move {
162            let mut response = next.run(request).await;
163            // Set rather than appended: a handler that echoed the id itself
164            // must not produce two copies, and nothing else may overwrite it.
165            response.headers.set(&header, id);
166            response
167        }))
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use crate::method::Method;
175    use crate::router::Router;
176    use crate::testing::TestClient;
177    use std::collections::HashSet;
178
179    fn client(middleware: RequestId) -> TestClient {
180        let mut router = Router::new();
181        router.middleware(middleware);
182        router.get("/", |req: Request| async move {
183            let from_request = req.request_id().unwrap_or("none").to_string();
184            let from_task = current().unwrap_or_else(|| "none".to_string());
185            Response::text(format!("{from_request}|{from_task}"))
186        });
187        TestClient::new(router)
188    }
189
190    #[test]
191    fn generated_ids_look_like_uuids_and_do_not_repeat() {
192        let ids: HashSet<String> = (0..10_000).map(|_| generate()).collect();
193        assert_eq!(ids.len(), 10_000, "ten thousand ids, ten thousand distinct values");
194
195        for id in ids.iter().take(50) {
196            assert_eq!(id.len(), 36, "{id}");
197            let parts: Vec<&str> = id.split('-').collect();
198            assert_eq!(parts.iter().map(|p| p.len()).collect::<Vec<_>>(), [8, 4, 4, 4, 12], "{id}");
199            assert!(parts[2].starts_with('4'), "version nibble: {id}");
200            assert!(matches!(parts[3].as_bytes()[0], b'8' | b'9' | b'a' | b'b'), "variant: {id}");
201        }
202    }
203
204    #[tokio::test]
205    async fn a_request_without_an_id_is_given_one_everywhere() {
206        let response = client(RequestId::new()).get("/").await;
207        let id = response.header(HEADER).expect("the response carries the id").to_string();
208        assert_eq!(id.len(), 36);
209        assert_eq!(response.body(), format!("{id}|{id}"), "request, task-local and header all agree");
210    }
211
212    #[tokio::test]
213    async fn an_incoming_id_is_kept_by_default() {
214        let request = Request::new(Method::Get, "/").with_header(HEADER, "edge-7f3a");
215        let response = client(RequestId::new()).send(request).await;
216        assert_eq!(response.header(HEADER), Some("edge-7f3a"));
217        assert_eq!(response.body(), "edge-7f3a|edge-7f3a");
218    }
219
220    #[tokio::test]
221    async fn an_incoming_id_can_be_ignored() {
222        let request = Request::new(Method::Get, "/").with_header(HEADER, "edge-7f3a");
223        let response = client(RequestId::new().ignore_incoming()).send(request).await;
224        assert_ne!(response.header(HEADER), Some("edge-7f3a"));
225        assert_eq!(response.header(HEADER).unwrap().len(), 36);
226    }
227
228    #[tokio::test]
229    async fn garbage_in_the_header_is_replaced_not_forwarded() {
230        for bad in ["", "has space", "tab\there", "x".repeat(129).as_str(), "ünïcödé"] {
231            let request = Request::new(Method::Get, "/").with_header(HEADER, bad);
232            let response = client(RequestId::new()).send(request).await;
233            let id = response.header(HEADER).unwrap();
234            assert_ne!(id, bad);
235            assert_eq!(id.len(), 36, "replaced with a generated one");
236        }
237    }
238
239    #[tokio::test]
240    async fn the_header_name_is_configurable() {
241        let request = Request::new(Method::Get, "/").with_header("x-correlation-id", "corr-1");
242        let response = client(RequestId::new().header("X-Correlation-Id")).send(request).await;
243        assert_eq!(response.header("x-correlation-id"), Some("corr-1"));
244        assert_eq!(response.header(HEADER), None);
245    }
246
247    #[tokio::test]
248    async fn outside_a_request_there_is_no_current_id() {
249        assert_eq!(current(), None);
250    }
251
252    #[tokio::test]
253    async fn the_event_for_the_request_carries_the_id() {
254        // The subscriber list is process-wide and this test only ever adds to
255        // it, filtering for its own id, so it cannot disturb a neighbour.
256        let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
257        let sink = seen.clone();
258        rustlavel_core::events::subscribe(move |event: &rustlavel_core::Event| {
259            if event.kind == "http.request" {
260                sink.lock().unwrap().push(event.field("request_id").and_then(|v| v.as_str().map(str::to_string)));
261            }
262        });
263
264        let request = Request::new(Method::Get, "/").with_header(HEADER, "traced-1");
265        client(RequestId::new()).send(request).await;
266
267        let seen = seen.lock().unwrap();
268        assert!(seen.iter().any(|id| id.as_deref() == Some("traced-1")), "{seen:?}");
269    }
270}