Skip to main content

postrust_server/
telemetry.rs

1//! A span for every request, and the identifiers that tie its log lines
2//! together.
3//!
4//! [`RequestTraceLayer`] is the outermost layer of the router. For each
5//! request it:
6//!
7//! - takes the client's `x-request-id` if it is usable, or makes one, and
8//!   echoes it on the response;
9//! - reads the W3C `traceparent` (and `tracestate`) into a
10//!   [`TraceContext`], or starts a trace, and puts the context in the
11//!   request's extensions for the REST pipeline and GraphQL to find;
12//! - opens a span named `request`, at info, whose fields are OpenTelemetry's
13//!   HTTP server conventions, and records the status on it when the response
14//!   is ready;
15//! - logs one line at debug with the status and latency.
16//!
17//! Field names are the OpenTelemetry semantic conventions, spelled as
18//! `tracing-opentelemetry` reads them -- `otel.kind`, `otel.status_code` --
19//! so an exporter can be added beneath this without renaming anything.
20//!
21//! # Why the span is at info and the line at debug
22//!
23//! The span is what makes an error logged deep in the engine say which
24//! request it belonged to: every line written inside it carries `request_id`
25//! and `trace_id`. That is worth having at the default level, and it costs
26//! the formatting of a handful of borrowed fields per request.
27//!
28//! The per-request line is not. At the default `info` it would be a line for
29//! every request a busy server answers, and the failures worth seeing are
30//! already logged where they happen, with their cause -- a query fault at
31//! error, a refusal at debug (see `query_error_log` in postrust-engine). A
32//! second line at error for every 5xx would say the same thing again with
33//! less in it. So the request line is at debug, whatever its status, and a
34//! 5xx is marked on the span as `otel.status_code = "ERROR"` for a trace
35//! backend to find.
36
37use axum::extract::MatchedPath;
38use http::{HeaderValue, Request, Response};
39use pin_project_lite::pin_project;
40use postrust_core::trace::{valid_request_id, TraceContext, TraceId};
41use std::cell::Cell;
42use std::future::Future;
43use std::pin::Pin;
44use std::task::{Context, Poll};
45use std::time::Instant;
46use tower::{Layer, Service};
47use tracing::field::Empty;
48use tracing::Span;
49
50#[cfg(feature = "otel")]
51use crate::otel::RequestMeter;
52
53/// Without the `otel` feature there are no metrics to record, and this is
54/// nothing at all.
55#[cfg(not(feature = "otel"))]
56struct RequestMeter;
57
58#[cfg(not(feature = "otel"))]
59impl RequestMeter {
60    #[inline(always)]
61    fn start(_: &http::Method, _: Option<&str>) -> Self {
62        RequestMeter
63    }
64
65    #[inline(always)]
66    fn finish(&self, _: http::StatusCode, _: Instant) {}
67}
68
69/// The header a request id travels in, both ways.
70pub const REQUEST_ID_HEADER: &str = "x-request-id";
71
72/// Wraps a service so that every request gets a request id, a trace context
73/// and a span. See the [module documentation](self).
74#[derive(Clone, Copy, Debug, Default)]
75pub struct RequestTraceLayer;
76
77impl<S> Layer<S> for RequestTraceLayer {
78    type Service = RequestTrace<S>;
79
80    fn layer(&self, inner: S) -> Self::Service {
81        RequestTrace { inner }
82    }
83}
84
85/// The service [`RequestTraceLayer`] produces.
86#[derive(Clone, Debug)]
87pub struct RequestTrace<S> {
88    inner: S,
89}
90
91impl<S, B, ResBody> Service<Request<B>> for RequestTrace<S>
92where
93    S: Service<Request<B>, Response = Response<ResBody>>,
94{
95    type Response = Response<ResBody>;
96    type Error = S::Error;
97    type Future = ResponseFuture<S::Future>;
98
99    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
100        self.inner.poll_ready(cx)
101    }
102
103    fn call(&mut self, mut request: Request<B>) -> Self::Future {
104        let start = Instant::now();
105        let request_id = request_id(request.headers());
106        let trace = TraceContext::from_headers(request.headers())
107            .unwrap_or_else(|| TraceContext::root(new_trace_id()));
108
109        // The span's fields borrow from the request; `info_span!` evaluates
110        // them only when a subscriber wants the span, and the trace id is
111        // recorded as `Display`, formatted only by whoever writes it out.
112        let span = tracing::info_span!(
113            "request",
114            "http.request.method" = request.method().as_str(),
115            "url.path" = request.uri().path(),
116            "http.route" = Empty,
117            "http.response.status_code" = Empty,
118            "otel.kind" = "server",
119            "otel.status_code" = Empty,
120            // `{method} {route}`, the name an exporter gives the span; only
121            // recorded when one is exporting. See `otel::name_request_span`.
122            "otel.name" = Empty,
123            // Visible ASCII, checked or generated, so `to_str` cannot fail.
124            request_id = request_id.to_str().unwrap_or_default(),
125            trace_id = %trace.trace_id,
126        );
127        // The route the router matched, not the path: `/api/{*path}` for every
128        // REST resource, so a dashboard grouping by route has a handful of
129        // rows rather than one per table and id. Absent where nothing matched
130        // -- a 404, or the compatibility-mode fallback -- and then left unset
131        // rather than guessed, as the conventions ask.
132        let route = request.extensions().get::<MatchedPath>();
133        if let Some(route) = route {
134            span.record("http.route", route.as_str());
135        }
136        let meter = RequestMeter::start(request.method(), route.map(MatchedPath::as_str));
137
138        // With the `otel` feature and an exporter, the exported span is
139        // named as the conventions name it, and joins the caller's trace --
140        // or starts one under this trace id. Neither does anything otherwise.
141        // In that order: starting a trace here starts the exported span, and
142        // a span already started keeps the name it started with.
143        #[cfg(feature = "otel")]
144        {
145            crate::otel::name_request_span(&span, request.method(), route.map(MatchedPath::as_str));
146            crate::otel::join_trace(&span, &trace);
147        }
148
149        request.extensions_mut().insert(trace);
150
151        let inner = {
152            let _entered = span.enter();
153            self.inner.call(request)
154        };
155        ResponseFuture {
156            inner,
157            span,
158            request_id: Some(request_id),
159            start,
160            meter,
161        }
162    }
163}
164
165pin_project! {
166    /// The response future of [`RequestTrace`].
167    pub struct ResponseFuture<F> {
168        #[pin]
169        inner: F,
170        span: Span,
171        request_id: Option<HeaderValue>,
172        start: Instant,
173        // Counts the request as active until this future is dropped, however
174        // it ends -- a client that goes away mid-request included.
175        meter: RequestMeter,
176    }
177}
178
179impl<F, ResBody, E> Future for ResponseFuture<F>
180where
181    F: Future<Output = Result<Response<ResBody>, E>>,
182{
183    type Output = Result<Response<ResBody>, E>;
184
185    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
186        let this = self.project();
187        let _entered = this.span.enter();
188        let mut response = match this.inner.poll(cx) {
189            Poll::Pending => return Poll::Pending,
190            Poll::Ready(Ok(response)) => response,
191            Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
192        };
193
194        let status = response.status();
195        this.span
196            .record("http.response.status_code", status.as_u16());
197        // Only a 5xx is the server's failure. A 4xx is the client's, and the
198        // conventions leave a server span's status unset for one.
199        if status.is_server_error() {
200            this.span.record("otel.status_code", "ERROR");
201        }
202        this.meter.finish(status, *this.start);
203        tracing::debug!(
204            status = status.as_u16(),
205            latency_ms = this.start.elapsed().as_secs_f64() * 1000.0,
206            "request finished"
207        );
208
209        // A response that already names a request id keeps it: a function can
210        // set response headers through `response.headers`, and what it said
211        // was asked for.
212        if let Some(id) = this.request_id.take() {
213            response
214                .headers_mut()
215                .entry(REQUEST_ID_HEADER)
216                .or_insert(id);
217        }
218        Poll::Ready(Ok(response))
219    }
220}
221
222/// The client's `x-request-id`, if it sent exactly one that can be used as
223/// given, or a new one.
224///
225/// A value that is too long or carries anything but visible ASCII is replaced
226/// rather than trimmed: an id that has been altered no longer matches what the
227/// client logged, which is the only thing it was for.
228pub fn request_id(headers: &http::HeaderMap) -> HeaderValue {
229    let mut sent = headers.get_all(REQUEST_ID_HEADER).iter();
230    match (sent.next(), sent.next()) {
231        (Some(id), None) if valid_request_id(id.as_bytes()) => id.clone(),
232        _ => new_request_id(),
233    }
234}
235
236/// A new request id: a version 4 UUID, in its usual spelling.
237pub fn new_request_id() -> HeaderValue {
238    let mut bytes = random_u128().to_be_bytes();
239    // Version 4, variant RFC 4122: what makes it a UUID and not 128 bits.
240    bytes[6] = (bytes[6] & 0x0f) | 0x40;
241    bytes[8] = (bytes[8] & 0x3f) | 0x80;
242
243    const HEX: &[u8; 16] = b"0123456789abcdef";
244    let mut text = [0u8; 36];
245    let mut at = 0;
246    for (i, b) in bytes.iter().enumerate() {
247        if matches!(i, 4 | 6 | 8 | 10) {
248            text[at] = b'-';
249            at += 1;
250        }
251        text[at] = HEX[(b >> 4) as usize];
252        text[at + 1] = HEX[(b & 0x0f) as usize];
253        at += 2;
254    }
255    HeaderValue::from_bytes(&text).expect("hex digits and hyphens are a valid header value")
256}
257
258/// A new trace id, for a request that did not arrive as part of one.
259pub fn new_trace_id() -> TraceId {
260    loop {
261        // All zeroes is the one value refused, at a chance of 2^-128.
262        if let Some(id) = TraceId::from_bytes(random_u128().to_be_bytes()) {
263            return id;
264        }
265    }
266}
267
268thread_local! {
269    static RNG_STATE: Cell<u64> = Cell::new(seed());
270}
271
272/// A seed from the standard library's per-process random keys. `RandomState`
273/// exists to make hash tables unpredictable, so it is seeded from the
274/// operating system; hashing anything with it gives a different 64 bits on
275/// every thread.
276fn seed() -> u64 {
277    use std::hash::BuildHasher;
278    std::collections::hash_map::RandomState::new().hash_one(0x5eed_u64)
279}
280
281/// 128 bits from SplitMix64, kept per thread.
282///
283/// Not a cryptographic generator, and these ids are not secrets: a client may
284/// send any request id it likes, and a trace id is written into every log line
285/// and passed to every service downstream. What they need is to be unique and
286/// spread evenly, and this gives that for a few nanoseconds, where reading
287/// the operating system's generator for each request would be a system call.
288fn random_u128() -> u128 {
289    fn next(state: &Cell<u64>) -> u64 {
290        let s = state.get().wrapping_add(0x9e37_79b9_7f4a_7c15);
291        state.set(s);
292        let mut z = s;
293        z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
294        z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
295        z ^ (z >> 31)
296    }
297    RNG_STATE.with(|state| ((next(state) as u128) << 64) | next(state) as u128)
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn a_generated_request_id_is_a_v4_uuid() {
306        let id = new_request_id();
307        let text = id.to_str().unwrap();
308        assert_eq!(text.len(), 36);
309        let parts: Vec<&str> = text.split('-').collect();
310        assert_eq!(
311            parts.iter().map(|p| p.len()).collect::<Vec<_>>(),
312            [8, 4, 4, 4, 12]
313        );
314        assert!(parts[2].starts_with('4'));
315        assert!(matches!(parts[3].as_bytes()[0], b'8' | b'9' | b'a' | b'b'));
316        assert!(valid_request_id(id.as_bytes()));
317        assert_ne!(new_request_id(), id);
318    }
319
320    #[test]
321    fn a_usable_request_id_is_kept() {
322        let mut headers = http::HeaderMap::new();
323        headers.insert(REQUEST_ID_HEADER, "abc-123".parse().unwrap());
324        assert_eq!(request_id(&headers), "abc-123");
325    }
326
327    #[test]
328    fn an_unusable_request_id_is_replaced() {
329        for bad in ["has space", &"x".repeat(129)] {
330            let mut headers = http::HeaderMap::new();
331            headers.insert(REQUEST_ID_HEADER, bad.parse().unwrap());
332            let id = request_id(&headers);
333            assert_ne!(id, bad);
334            assert_eq!(id.len(), 36);
335        }
336        // Two ids is no id: there is no telling which the client logged.
337        let mut headers = http::HeaderMap::new();
338        headers.append(REQUEST_ID_HEADER, "one".parse().unwrap());
339        headers.append(REQUEST_ID_HEADER, "two".parse().unwrap());
340        assert_eq!(request_id(&headers).len(), 36);
341    }
342
343    #[test]
344    fn trace_ids_differ() {
345        assert_ne!(new_trace_id(), new_trace_id());
346    }
347}