Skip to main content

churust_logging/
lib.rs

1//! Per-request structured logging for the Churust web framework.
2//!
3//! This crate provides [`CallLogging`], a [`Plugin`] that installs a middleware
4//! in the [`Phase::Monitoring`] phase.  The middleware records the HTTP method,
5//! request path, response status code, and wall-clock latency (in milliseconds)
6//! for every request that passes through the application.  Logging is
7//! implemented on top of the [`tracing`] crate, so it integrates with any
8//! `tracing`-compatible subscriber (e.g. `tracing-subscriber`'s `fmt` layer).
9//!
10//! # Quick start
11//!
12//! ```
13//! use churust_core::{Churust, Call, TestClient};
14//! use churust_logging::CallLogging;
15//!
16//! # tokio::runtime::Runtime::new().unwrap().block_on(async {
17//! let app = Churust::server()
18//!     .install(CallLogging::new())            // add structured request logs
19//!     .routing(|r| {
20//!         r.get("/", |_c: Call| async { "hello" });
21//!     })
22//!     .build();
23//!
24//! let res = TestClient::new(app).get("/").send().await;
25//! assert_eq!(res.status().as_u16(), 200);
26//! # });
27//! ```
28//!
29//! # Log output
30//!
31//! Each completed request emits a single `tracing` event at the configured
32//! level with the following structured fields:
33//!
34//! | field        | type   | description                         |
35//! |--------------|--------|-------------------------------------|
36//! | `method`     | string | HTTP method (GET, POST, …)          |
37//! | `path`       | string | Request path (/api/users, …)        |
38//! | `status`     | u16    | Response status code (200, 404, …)  |
39//! | `latency_ms` | u128   | Round-trip time in milliseconds     |
40//!
41//! A typical log line produced by a `fmt` subscriber might look like:
42//!
43//! ```text
44//! INFO  churust_logging: request method=GET path=/api/users status=200 latency_ms=3
45//! ```
46//!
47//! # Middleware phase
48//!
49//! [`CallLogging`] runs in [`Phase::Monitoring`], which is the outermost phase
50//! in Churust's middleware pipeline.  This means the timer starts before any
51//! authentication, CORS, or routing middleware runs, giving an accurate
52//! end-to-end latency for every request.
53//!
54//! [`tracing`]: https://docs.rs/tracing
55
56#![deny(missing_docs)]
57
58use async_trait::async_trait;
59use churust_core::{AppBuilder, Call, Middleware, Next, Phase, Plugin, Response};
60use std::sync::Arc;
61use std::time::Instant;
62use tracing::Level;
63
64/// A [`Plugin`] that adds structured per-request logging to a Churust application.
65///
66/// `CallLogging` installs a single middleware in the [`Phase::Monitoring`] phase.
67/// For every request it records the HTTP method, request path, response status
68/// code, and elapsed wall-clock time in milliseconds, emitting them as a single
69/// [`tracing`] event.
70///
71/// # Default log level
72///
73/// Unless overridden with [`CallLogging::level`], events are emitted at
74/// [`Level::INFO`].
75///
76/// # Examples
77///
78/// ## Basic usage (default INFO level)
79///
80/// ```
81/// use churust_core::{Churust, Call, TestClient};
82/// use churust_logging::CallLogging;
83///
84/// # tokio::runtime::Runtime::new().unwrap().block_on(async {
85/// let app = Churust::server()
86///     .install(CallLogging::new())
87///     .routing(|r| {
88///         r.get("/ping", |_c: Call| async { "pong" });
89///     })
90///     .build();
91///
92/// let res = TestClient::new(app).get("/ping").send().await;
93/// assert_eq!(res.status().as_u16(), 200);
94/// # });
95/// ```
96///
97/// ## Custom log level (DEBUG)
98///
99/// ```
100/// use churust_core::{Churust, Call, TestClient};
101/// use churust_logging::CallLogging;
102/// use tracing::Level;
103///
104/// # tokio::runtime::Runtime::new().unwrap().block_on(async {
105/// let app = Churust::server()
106///     .install(CallLogging::new().level(Level::DEBUG))
107///     .routing(|r| {
108///         r.get("/", |_c: Call| async { "hi" });
109///     })
110///     .build();
111///
112/// let res = TestClient::new(app).get("/").send().await;
113/// assert_eq!(res.status().as_u16(), 200);
114/// # });
115/// ```
116///
117/// [`tracing`]: https://docs.rs/tracing
118/// [`Level::INFO`]: tracing::Level::INFO
119#[derive(Debug, Clone)]
120pub struct CallLogging {
121    level: Level,
122}
123
124impl Default for CallLogging {
125    /// Creates a [`CallLogging`] plugin with [`Level::INFO`] as the default log level.
126    ///
127    /// This is equivalent to calling [`CallLogging::new()`].
128    ///
129    /// [`Level::INFO`]: tracing::Level::INFO
130    fn default() -> Self {
131        Self { level: Level::INFO }
132    }
133}
134
135impl CallLogging {
136    /// Creates a new `CallLogging` plugin with the default log level ([`Level::INFO`]).
137    ///
138    /// Use [`CallLogging::level`] on the returned value to select a different
139    /// severity level before passing the plugin to [`AppBuilder::install`].
140    ///
141    /// # Examples
142    ///
143    /// ```
144    /// use churust_logging::CallLogging;
145    ///
146    /// let plugin = CallLogging::new();
147    /// ```
148    ///
149    /// [`Level::INFO`]: tracing::Level::INFO
150    pub fn new() -> Self {
151        Self::default()
152    }
153
154    /// Overrides the [`tracing`] level at which request events are emitted.
155    ///
156    /// All five standard [`tracing`] levels are supported:
157    /// [`Level::ERROR`], [`Level::WARN`], [`Level::INFO`] (default),
158    /// [`Level::DEBUG`], and [`Level::TRACE`].
159    ///
160    /// Choosing a lower level (e.g. `DEBUG` or `TRACE`) is useful in
161    /// development where you want request logs without cluttering production
162    /// output.  Choosing a higher level (`WARN` or `ERROR`) lets you capture
163    /// only unexpected or slow requests when combined with a filtered subscriber.
164    ///
165    /// # Arguments
166    ///
167    /// * `level` — The [`tracing::Level`] to use for the emitted event.
168    ///
169    /// # Examples
170    ///
171    /// ```
172    /// use churust_logging::CallLogging;
173    /// use tracing::Level;
174    ///
175    /// // Emit request logs at TRACE level (very verbose).
176    /// let plugin = CallLogging::new().level(Level::TRACE);
177    ///
178    /// // Emit request logs at WARN level (production-quiet).
179    /// let plugin = CallLogging::new().level(Level::WARN);
180    /// ```
181    ///
182    /// [`tracing`]: https://docs.rs/tracing
183    pub fn level(mut self, level: Level) -> Self {
184        self.level = level;
185        self
186    }
187}
188
189impl Plugin for CallLogging {
190    fn install(self: Box<Self>, app: &mut AppBuilder) {
191        app.add_middleware_in(
192            Phase::Monitoring,
193            Arc::new(LogMiddleware { level: self.level }),
194        );
195    }
196}
197
198struct LogMiddleware {
199    level: Level,
200}
201
202#[async_trait]
203impl Middleware for LogMiddleware {
204    async fn handle(&self, mut call: Call, next: Next) -> Response {
205        let method = call.method().clone();
206        let path = call.path().to_string();
207
208        // Continue an inbound trace when the caller supplied one, so a request
209        // crossing service boundaries keeps a single trace id. Otherwise start
210        // one, which is what makes correlation possible at all.
211        let id = RequestId::from_call(&call);
212        let trace_id = id.trace_id.clone();
213        let request_id = id.request_id.clone();
214        call.insert(id);
215
216        let start = Instant::now();
217        let mut res = next.run(call).await;
218        let latency_ms = start.elapsed().as_millis();
219        let status = res.status.as_u16();
220
221        // Echo it back so a client can quote the id in a bug report.
222        if let Ok(v) = http::HeaderValue::from_str(&request_id) {
223            res.headers
224                .insert(http::header::HeaderName::from_static("x-request-id"), v);
225        }
226
227        // tracing macros need a const level; branch on the configured level.
228        match self.level {
229            Level::ERROR => {
230                tracing::error!(%method, path, status, latency_ms, request_id, trace_id, "request")
231            }
232            Level::WARN => {
233                tracing::warn!(%method, path, status, latency_ms, request_id, trace_id, "request")
234            }
235            Level::INFO => {
236                tracing::info!(%method, path, status, latency_ms, request_id, trace_id, "request")
237            }
238            Level::DEBUG => {
239                tracing::debug!(%method, path, status, latency_ms, request_id, trace_id, "request")
240            }
241            Level::TRACE => {
242                tracing::trace!(%method, path, status, latency_ms, request_id, trace_id, "request")
243            }
244        }
245        res
246    }
247}
248
249/// Correlation identifiers for one request.
250///
251/// Seeded by [`CallLogging`] and readable from a handler with
252/// [`Call::get`](churust_core::Call::get). The `x-request-id` response header
253/// carries the request id back to the client.
254///
255/// ```
256/// use churust_core::{Call, Churust, TestClient};
257/// use churust_logging::{CallLogging, RequestId};
258/// # tokio::runtime::Runtime::new().unwrap().block_on(async {
259/// let app = Churust::server()
260///     .install(CallLogging::new())
261///     .routing(|r| {
262///         r.get("/", |c: Call| async move {
263///             c.get::<RequestId>().map(|id| id.request_id).unwrap_or_default()
264///         });
265///     })
266///     .build();
267///
268/// let res = TestClient::new(app).get("/").send().await;
269/// assert!(!res.text().is_empty());
270/// assert_eq!(res.header("x-request-id"), Some(res.text().as_str()));
271/// # });
272/// ```
273#[derive(Debug, Clone)]
274pub struct RequestId {
275    /// Unique to this request.
276    pub request_id: String,
277    /// Shared by every request in the same distributed trace. Taken from an
278    /// inbound W3C `traceparent` when present.
279    pub trace_id: String,
280}
281
282impl RequestId {
283    fn from_call(call: &Call) -> Self {
284        // W3C traceparent: version-traceid-spanid-flags, e.g.
285        // 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
286        let inbound = call.header("traceparent").and_then(|v| {
287            let parts: Vec<&str> = v.split('-').collect();
288            // Validate rather than trust: a malformed value must not become a
289            // trace id, or one bad caller poisons the whole correlation index.
290            (parts.len() >= 3
291                && parts[1].len() == 32
292                && parts[1].chars().all(|c| c.is_ascii_hexdigit())
293                && parts[1].chars().any(|c| c != '0'))
294            .then(|| parts[1].to_string())
295        });
296
297        Self {
298            request_id: gen_hex(16),
299            trace_id: inbound.unwrap_or_else(|| gen_hex(32)),
300        }
301    }
302}
303
304/// A hex identifier of `n` characters.
305///
306/// Deliberately not cryptographic and deliberately dependency-free: this is a
307/// correlation id, not a secret. Uniqueness comes from a nanosecond clock, a
308/// per-process counter and the pid, which is enough to keep log lines apart.
309fn gen_hex(n: usize) -> String {
310    use std::sync::atomic::{AtomicU64, Ordering};
311    static COUNTER: AtomicU64 = AtomicU64::new(0);
312
313    let nanos = std::time::SystemTime::now()
314        .duration_since(std::time::UNIX_EPOCH)
315        .map(|d| d.as_nanos() as u64)
316        .unwrap_or(0);
317    let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
318    let pid = std::process::id() as u64;
319
320    let mut out = String::with_capacity(n);
321    let mut state = nanos ^ (seq.wrapping_mul(0x9E37_79B9_7F4A_7C15)) ^ (pid << 32);
322    while out.len() < n {
323        // xorshift64*, plenty for log correlation.
324        state ^= state >> 12;
325        state ^= state << 25;
326        state ^= state >> 27;
327        let v = state.wrapping_mul(0x2545_F491_4F6C_DD1D);
328        out.push_str(&format!("{v:016x}"));
329    }
330    out.truncate(n);
331    out
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use churust_core::{App, Churust, TestClient};
338    use http::StatusCode;
339
340    fn app() -> App {
341        Churust::server()
342            .install(CallLogging::new())
343            .routing(|r| {
344                r.get("/", |_c: Call| async { "ok" });
345            })
346            .build()
347    }
348
349    #[tokio::test]
350    async fn logging_is_transparent() {
351        // The middleware must not alter the response.
352        let client = TestClient::new(app());
353        let res = client.get("/").send().await;
354        assert_eq!(res.status(), StatusCode::OK);
355        assert_eq!(res.text(), "ok");
356    }
357}