ripress/middlewares/
logger.rs

1#![warn(missing_docs)]
2use crate::{context::HttpResponse, req::HttpRequest, types::FutMiddleware};
3use std::collections::HashMap;
4use tracing::info;
5
6/// Builtin Logger Middleware
7///
8/// This middleware provides comprehensive request and response logging capabilities
9/// for HTTP requests. It captures various aspects of each request including method,
10/// path, status code, headers, IP address, user agent, query parameters, and body size.
11/// The middleware is highly configurable, allowing you to selectively enable/disable
12/// different logging components and exclude specific paths from logging.
13///
14/// ## Features
15///
16/// * **Selective logging** - Enable/disable individual log components
17/// * **Custom header logging** - Log specific headers by name
18/// * **Path exclusion** - Skip logging for specified paths (e.g., health checks)
19/// * **Query parameter logging** - Capture and log request query parameters
20/// * **Body size tracking** - Log response body size with stream detection
21/// * **IP address logging** - Capture client IP addresses
22/// * **User agent detection** - Log client user agent strings
23/// * **Prefix-based exclusion** - Exclude paths using prefix matching
24/// * **Thread-safe operation** - Safe for concurrent use across multiple threads
25/// * **Zero-allocation exclusion** - Excluded requests bypass all processing
26///
27/// ## Configuration
28///
29/// The middleware accepts an optional `LoggerConfig` struct to customize logging behavior:
30///
31/// * `method` - Log HTTP method (GET, POST, etc.) - default: true
32/// * `path` - Log request path - default: true
33/// * `status` - Log response status code - default: true
34/// * `user_agent` - Log client user agent - default: true
35/// * `ip` - Log client IP address - default: true
36/// * `headers` - List of specific headers to log - default: empty
37/// * `body_size` - Log response body size - default: true
38/// * `query_params` - Log query parameters - default: true
39/// * `exclude_paths` - List of path prefixes to exclude from logging - default: empty
40///
41/// ## Path Exclusion Behavior
42///
43/// The `exclude_paths` configuration uses prefix matching for efficient filtering:
44/// * `/health` excludes `/health`, `/health/live`, `/health/ready`, etc.
45/// * `/api/internal` excludes all paths starting with `/api/internal`
46/// * Excluded requests are processed with minimal overhead
47/// * No log output is generated for excluded paths
48///
49/// ## Log Format
50///
51/// The middleware outputs structured logs using the `tracing` crate with the following format:
52/// ```md
53/// path: /api/users,
54/// user_agent: Mozilla/5.0...,
55/// ip: 192.168.1.1,
56/// custom-header: value,
57/// status_code: 200
58/// query_params: {"id": "123", "format": "json"},
59/// method: GET,
60/// body_size: 1024
61/// ```
62///
63/// ## Examples
64///
65/// Basic usage with default configuration:
66///
67/// ```no_run
68/// use ripress::{app::App, middlewares::logger::LoggerConfig};
69///
70/// tracing_subscriber::fmt::init();
71/// let mut app = App::new();
72/// app.use_logger(Some(LoggerConfig::default()));
73/// ```
74///
75/// Minimal logging configuration:
76///
77/// ```no_run
78/// use ripress::{app::App, middlewares::logger::LoggerConfig};
79/// tracing_subscriber::fmt::init();
80///
81/// let mut app = App::new();
82/// let config = LoggerConfig {
83///     method: true,
84///     path: true,
85///     status: true,
86///     user_agent: false,
87///     ip: false,
88///     headers: vec![],
89///     body_size: false,
90///     query_params: false,
91///     exclude_paths: vec![],
92/// };
93/// app.use_logger(Some(config));
94/// ```
95///
96/// Custom header logging with path exclusions:
97///
98/// ```no_run
99/// use ripress::{app::App, middlewares::logger::LoggerConfig};
100/// tracing_subscriber::fmt::init();
101///
102/// let mut app = App::new();
103/// let config = LoggerConfig {
104///     method: true,
105///     path: true,
106///     status: true,
107///     user_agent: true,
108///     ip: true,
109///     headers: vec![
110///         "authorization".to_string(),
111///         "x-request-id".to_string(),
112///         "content-type".to_string(),
113///     ],
114///     body_size: true,
115///     query_params: true,
116///     exclude_paths: vec![
117///         "/health".to_string(),
118///         "/metrics".to_string(),
119///         "/favicon.ico".to_string(),
120///     ],
121/// };
122/// app.use_logger(Some(config));
123/// ```
124///
125/// Using default configuration (recommended for development):
126///
127/// ```no_run
128/// use ripress::app::App;
129///
130/// tracing_subscriber::fmt::init();
131///
132/// let mut app = App::new();
133/// app.use_logger(None); // Uses LoggerConfig::default()
134/// ```
135///
136/// Production configuration with security considerations:
137///
138/// ```no_run
139/// use ripress::{app::App, middlewares::logger::LoggerConfig};
140///
141/// tracing_subscriber::fmt::init();
142///
143/// let mut app = App::new();
144/// let config = LoggerConfig {
145///     method: true,
146///     path: true,
147///     status: true,
148///     user_agent: false, // May contain sensitive info
149///     ip: true,
150///     headers: vec![
151///         "x-request-id".to_string(), // Safe to log
152///         "content-length".to_string(),
153///         // Note: Don't log authorization, cookies, or other sensitive headers
154///     ],
155///     body_size: true,
156///     query_params: false, // May contain sensitive data
157///     exclude_paths: vec![
158///         "/health".to_string(),
159///         "/metrics".to_string(),
160///         "/internal".to_string(),
161///     ],
162/// };
163/// app.use_logger(Some(config));
164/// ```
165///
166/// ## Output Examples
167///
168/// Default configuration output:
169/// ```md
170/// path: /api/users/123,
171/// user_agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36,
172/// ip: 192.168.1.100,
173/// status_code: 200
174/// query_params: {"include": "profile", "format": "json"},
175/// method: GET,
176/// body_size: 2048
177/// ```
178///
179/// Custom headers output:
180/// ```md
181/// path: /api/upload,
182/// user_agent: PostmanRuntime/7.32.3,
183/// ip: 10.0.0.15,
184/// authorization: <missing>,
185/// x-request-id: abc-123-def,
186/// content-type: multipart/form-data,
187/// status_code: 201
188/// query_params: {},
189/// method: POST,
190/// body_size: stream
191/// ```
192///
193/// ## Performance Considerations
194///
195/// * **Excluded paths** bypass all processing for maximum efficiency
196/// * **Header lookups** are performed only for configured headers
197/// * **String allocation** occurs only for enabled log components
198/// * **Query parameter cloning** happens only when enabled
199/// * **Arc-based config sharing** minimizes memory overhead
200/// * **Structured output** reduces parsing overhead in log aggregators
201///
202/// ## Security Considerations
203///
204/// * **Sensitive headers** (Authorization, Cookie, etc.) should not be logged
205/// * **Query parameters** may contain sensitive data - consider disabling
206/// * **User agents** may contain personally identifiable information
207/// * **IP addresses** may be subject to privacy regulations (GDPR, etc.)
208/// * **Path exclusions** prevent accidental logging of sensitive endpoints
209///
210/// ## Integration with Log Aggregators
211///
212/// The structured output format is designed for easy parsing by log aggregation systems:
213/// * **Consistent field names** for reliable parsing
214/// * **Comma-separated format** for simple splitting
215/// * **Missing header indicator** (`<missing>`) for clear status
216/// * **Stream detection** for body size handling
217/// * **JSON-formatted query params** for structured data
218///
219/// ## Troubleshooting
220///
221/// Common issues and solutions:
222/// * **Missing headers**: Check header name case (middleware converts to lowercase)
223/// * **No output**: Verify path isn't in `exclude_paths`
224/// * **Partial logs**: Check individual boolean flags in configuration
225/// * **Performance impact**: Use path exclusions for high-traffic endpoints
226///
227/// ## Thread Safety
228///
229/// The middleware is fully thread-safe:
230/// * Configuration is wrapped in `Arc` for efficient sharing
231/// * No mutable state is maintained between requests
232/// * Safe for use in multi-threaded web servers
233/// * Clone-friendly for multiple route registration
234
235/// Configuration struct for the Logger Middleware
236///
237/// This struct controls which aspects of HTTP requests and responses are logged.
238/// All fields are boolean flags or collections that determine what information
239/// is captured and output for each request.
240///
241/// ## Field Details
242///
243/// * `method` - Logs the HTTP method (GET, POST, PUT, DELETE, etc.)
244/// * `path` - Logs the request path (e.g., "/api/users/123")
245/// * `status` - Logs the HTTP response status code (200, 404, 500, etc.)
246/// * `user_agent` - Logs the User-Agent header sent by the client
247/// * `ip` - Logs the client's IP address
248/// * `headers` - A list of specific header names to log (case-insensitive)
249/// * `body_size` - Logs the size of the response body in bytes, or "stream" for streaming responses
250/// * `query_params` - Logs URL query parameters as a structured format
251/// * `exclude_paths` - Path prefixes that should be excluded from logging entirely
252///
253/// ## Default Configuration
254///
255/// By default, all standard fields are enabled and no paths are excluded:
256/// - All boolean fields default to `true`
257/// - `headers` defaults to empty (no custom headers logged)
258/// - `exclude_paths` defaults to empty (all paths logged)
259#[derive(Clone)]
260pub struct LoggerConfig {
261    /// Whether to log the HTTP method (GET, POST, etc.)
262    pub method: bool,
263    /// Whether to log the request path
264    pub path: bool,
265    /// Whether to log the response status code
266    pub status: bool,
267    /// Whether to log the client's User-Agent header
268    pub user_agent: bool,
269    /// Whether to log the client's IP address
270    pub ip: bool,
271    /// List of specific header names to log (converted to lowercase)
272    ///
273    /// Headers not present in the request will show as "<missing>" in the log output.
274    /// Common headers to log: "content-type", "x-request-id", "authorization"
275    pub headers: Vec<String>, // Specific headers to log
276    /// Whether to log the response body size
277    ///
278    /// Shows actual byte count for regular responses, "stream" for streaming responses.
279    pub body_size: bool,
280    /// Whether to log URL query parameters
281    ///
282    /// Query parameters are logged in a structured JSON-like format for easy parsing.
283    pub query_params: bool,
284    /// List of path prefixes to exclude from logging
285    ///
286    /// Uses prefix matching: "/health" excludes "/health", "/health/live", etc.
287    /// Useful for excluding health checks, metrics endpoints, and other high-frequency requests.
288    pub exclude_paths: Vec<String>, // Don't log health checks, etc.
289}
290
291impl Default for LoggerConfig {
292    fn default() -> Self {
293        LoggerConfig {
294            method: true,
295            path: true,
296            status: true,
297            user_agent: true,
298            ip: true,
299            headers: vec![],
300            body_size: true,
301            query_params: true,
302            exclude_paths: vec![],
303        }
304    }
305}
306
307/// Creates a logger middleware function
308///
309/// Returns a middleware function that logs HTTP request and response information
310/// according to the provided configuration. The middleware processes requests
311/// efficiently, with excluded paths bypassing all logging overhead.
312///
313/// ## Parameters
314///
315/// * `config` - Optional logging configuration. If `None`, uses `LoggerConfig::default()`
316///   which enables all standard logging fields.
317///
318/// ## Returns
319///
320/// A middleware function compatible with the ripress framework that:
321/// * Logs request and response information to stdout
322/// * Skips processing for paths matching `exclude_paths` prefixes
323/// * Captures configured headers with case-insensitive matching
324/// * Handles streaming responses appropriately
325/// * Operates efficiently with minimal allocation overhead
326///
327/// ## Thread Safety
328///
329/// The returned middleware is `Send + Sync` and safe for concurrent use.
330/// Configuration is shared via `Arc` to minimize memory overhead when
331/// the middleware is used across multiple routes or threads.
332///
333/// ## Performance Notes
334///
335/// * Excluded paths are checked first and bypass all other processing
336/// * Header lookups only occur for headers specified in the configuration
337/// * String formatting only happens for enabled log components
338/// * Configuration is shared via Arc to avoid cloning overhead
339///
340/// ## Log Output
341///
342/// All log output is written using the `tracing` crate at the `info` level. The format is
343/// comma-separated key-value pairs, making it suitable for structured
344/// log parsing systems. Fields are output in a consistent order regardless
345/// of configuration.
346///
347/// ## Tracing Setup
348///
349/// To see the log output, you need to initialize a tracing subscriber in your application:
350///
351/// ```rust
352/// use tracing_subscriber;
353///
354/// fn main() {
355///     // Initialize tracing subscriber
356///     tracing_subscriber::fmt::init();
357///     
358///     // Your app code here
359/// }
360/// ```
361pub(crate) fn logger(
362    config: Option<LoggerConfig>,
363) -> impl Fn(HttpRequest, HttpResponse) -> FutMiddleware + Send + Sync + 'static {
364    let cfg = std::sync::Arc::new(config.unwrap_or_default());
365    move |req, res| {
366        let config = std::sync::Arc::clone(&cfg);
367
368        // Treat entries as prefixes; excludes "/health" will also exclude "/health/live".
369        if config
370            .exclude_paths
371            .iter()
372            .any(|prefix| req.path.starts_with(prefix))
373        {
374            return Box::pin(async move { (req, None) });
375        }
376
377        Box::pin(async move {
378            let path = req.path.clone();
379            let method = req.method.clone();
380            let user_agent = req.headers.user_agent().unwrap_or("Unknown").to_string();
381            let ip = req.ip;
382            let status_code = res.status_code;
383            let mut headers = HashMap::new();
384
385            if !config.headers.is_empty() {
386                for header in &config.headers {
387                    let key = header.to_ascii_lowercase();
388                    let value = req
389                        .headers
390                        .get(&key)
391                        .map(|v| v.to_string())
392                        .unwrap_or_else(|| "<missing>".to_string());
393                    headers.insert(key, value);
394                }
395            }
396
397            let query_params = req.query.clone();
398
399            let mut msg = String::new();
400
401            if config.path {
402                msg.push_str(&format!("path: {}, \n", path));
403            }
404            if config.user_agent {
405                msg.push_str(&format!("user_agent: {}, \n", user_agent));
406            }
407            if config.ip {
408                msg.push_str(&format!("ip: {}, \n", ip));
409            }
410            for (key, value) in headers {
411                msg.push_str(&format!("{}: {}, \n", key, value));
412            }
413            if config.status {
414                msg.push_str(&format!("status_code: {}\n", status_code));
415            }
416            if config.query_params {
417                msg.push_str(&format!("query_params: {:?}, \n", query_params));
418            }
419            if config.method {
420                msg.push_str(&format!("method: {}, \n", method));
421            }
422            if config.body_size {
423                if res.is_stream {
424                    msg.push_str("body_size: stream\n");
425                } else {
426                    msg.push_str(&format!("body_size: {}\n", res.body.len()));
427                }
428            }
429
430            let msg = msg.trim_end_matches([',', ' ', '\t', '\n']);
431
432            info!("{}", msg);
433
434            (req, None)
435        })
436    }
437}