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}