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 // Owned before dispatch: `next.run` consumes `call`.
207 let path = call.path().to_owned();
208
209 // Continue an inbound trace when the caller supplied one, so a request
210 // crossing service boundaries keeps a single trace id. Otherwise start
211 // one, which is what makes correlation possible at all.
212 let id = RequestId::from_call(&call);
213 let request_id = id.request_id.clone();
214 let trace_id = id.trace_id.clone();
215 // Build the response header once; hex ids are always valid header values.
216 let request_id_header = http::HeaderValue::from_str(&request_id).ok();
217 call.insert(id);
218
219 let start = Instant::now();
220 let mut res = next.run(call).await;
221 let latency_ms = start.elapsed().as_millis();
222 let status = res.status.as_u16();
223
224 // Echo it back so a client can quote the id in a bug report.
225 if let Some(v) = request_id_header {
226 res.headers
227 .insert(http::header::HeaderName::from_static("x-request-id"), v);
228 }
229
230 // tracing macros need a const level; branch on the configured level.
231 match self.level {
232 Level::ERROR => {
233 tracing::error!(%method, path, status, latency_ms, request_id, trace_id, "request")
234 }
235 Level::WARN => {
236 tracing::warn!(%method, path, status, latency_ms, request_id, trace_id, "request")
237 }
238 Level::INFO => {
239 tracing::info!(%method, path, status, latency_ms, request_id, trace_id, "request")
240 }
241 Level::DEBUG => {
242 tracing::debug!(%method, path, status, latency_ms, request_id, trace_id, "request")
243 }
244 Level::TRACE => {
245 tracing::trace!(%method, path, status, latency_ms, request_id, trace_id, "request")
246 }
247 }
248 res
249 }
250}
251
252/// Correlation identifiers for one request.
253///
254/// Seeded by [`CallLogging`] and readable from a handler with
255/// [`Call::get`](churust_core::Call::get). The `x-request-id` response header
256/// carries the request id back to the client.
257///
258/// ```
259/// use churust_core::{Call, Churust, TestClient};
260/// use churust_logging::{CallLogging, RequestId};
261/// # tokio::runtime::Runtime::new().unwrap().block_on(async {
262/// let app = Churust::server()
263/// .install(CallLogging::new())
264/// .routing(|r| {
265/// r.get("/", |c: Call| async move {
266/// c.get::<RequestId>().map(|id| id.request_id).unwrap_or_default()
267/// });
268/// })
269/// .build();
270///
271/// let res = TestClient::new(app).get("/").send().await;
272/// assert!(!res.text().is_empty());
273/// assert_eq!(res.header("x-request-id"), Some(res.text().as_str()));
274/// # });
275/// ```
276#[derive(Debug, Clone)]
277pub struct RequestId {
278 /// Unique to this request.
279 pub request_id: String,
280 /// Shared by every request in the same distributed trace. Taken from an
281 /// inbound W3C `traceparent` when present.
282 pub trace_id: String,
283}
284
285impl RequestId {
286 fn from_call(call: &Call) -> Self {
287 // W3C traceparent: version-traceid-spanid-flags, e.g.
288 // 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
289 let inbound = call.header("traceparent").and_then(|v| {
290 let parts: Vec<&str> = v.split('-').collect();
291 // Validate rather than trust: a malformed value must not become a
292 // trace id, or one bad caller poisons the whole correlation index.
293 (parts.len() >= 3
294 && parts[1].len() == 32
295 && parts[1].chars().all(|c| c.is_ascii_hexdigit())
296 && parts[1].chars().any(|c| c != '0'))
297 .then(|| parts[1].to_string())
298 });
299
300 Self {
301 request_id: gen_hex(16),
302 trace_id: inbound.unwrap_or_else(|| gen_hex(32)),
303 }
304 }
305}
306
307/// A hex identifier of `n` characters.
308///
309/// Deliberately not cryptographic and deliberately dependency-free: this is a
310/// correlation id, not a secret. Uniqueness comes from a nanosecond clock, a
311/// per-process counter and the pid, which is enough to keep log lines apart.
312fn gen_hex(n: usize) -> String {
313 use std::sync::atomic::{AtomicU64, Ordering};
314 static COUNTER: AtomicU64 = AtomicU64::new(0);
315
316 let nanos = std::time::SystemTime::now()
317 .duration_since(std::time::UNIX_EPOCH)
318 .map(|d| d.as_nanos() as u64)
319 .unwrap_or(0);
320 let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
321 let pid = std::process::id() as u64;
322
323 let mut out = String::with_capacity(n);
324 let mut state = nanos ^ (seq.wrapping_mul(0x9E37_79B9_7F4A_7C15)) ^ (pid << 32);
325 while out.len() < n {
326 // xorshift64*, plenty for log correlation.
327 state ^= state >> 12;
328 state ^= state << 25;
329 state ^= state >> 27;
330 let v = state.wrapping_mul(0x2545_F491_4F6C_DD1D);
331 out.push_str(&format!("{v:016x}"));
332 }
333 out.truncate(n);
334 out
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340 use churust_core::{App, Churust, TestClient};
341 use http::StatusCode;
342
343 fn app() -> App {
344 Churust::server()
345 .install(CallLogging::new())
346 .routing(|r| {
347 r.get("/", |_c: Call| async { "ok" });
348 })
349 .build()
350 }
351
352 #[tokio::test]
353 async fn logging_is_transparent() {
354 // The middleware must not alter the response.
355 let client = TestClient::new(app());
356 let res = client.get("/").send().await;
357 assert_eq!(res.status(), StatusCode::OK);
358 assert_eq!(res.text(), "ok");
359 }
360}