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, call: Call, next: Next) -> Response {
205        let method = call.method().clone();
206        let path = call.path().to_string();
207        let start = Instant::now();
208        let res = next.run(call).await;
209        let latency_ms = start.elapsed().as_millis();
210        let status = res.status.as_u16();
211        // tracing macros need a const level; branch on the configured level.
212        match self.level {
213            Level::ERROR => tracing::error!(%method, path, status, latency_ms, "request"),
214            Level::WARN => tracing::warn!(%method, path, status, latency_ms, "request"),
215            Level::INFO => tracing::info!(%method, path, status, latency_ms, "request"),
216            Level::DEBUG => tracing::debug!(%method, path, status, latency_ms, "request"),
217            Level::TRACE => tracing::trace!(%method, path, status, latency_ms, "request"),
218        }
219        res
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use churust_core::{App, Churust, TestClient};
227    use http::StatusCode;
228
229    fn app() -> App {
230        Churust::server()
231            .install(CallLogging::new())
232            .routing(|r| {
233                r.get("/", |_c: Call| async { "ok" });
234            })
235            .build()
236    }
237
238    #[tokio::test]
239    async fn logging_is_transparent() {
240        // The middleware must not alter the response.
241        let client = TestClient::new(app());
242        let res = client.get("/").send().await;
243        assert_eq!(res.status(), StatusCode::OK);
244        assert_eq!(res.text(), "ok");
245    }
246}