Skip to main content

gatel_core/hoops/
logging.rs

1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3use std::time::Instant;
4
5use salvo::{Depot, FlowCtrl, Request, Response, async_trait};
6use tokio::io::AsyncWriteExt;
7use tokio::sync::Mutex;
8use tracing::info;
9
10/// Format a log line by replacing known placeholders with their values.
11#[allow(clippy::too_many_arguments)]
12fn format_log_line(
13    format: &str,
14    client_addr: &std::net::SocketAddr,
15    method: &str,
16    path: &str,
17    path_and_query: &str,
18    status: u16,
19    latency: std::time::Duration,
20    headers: &http::HeaderMap,
21    content_length: Option<u64>,
22) -> String {
23    use std::time::{SystemTime, UNIX_EPOCH};
24
25    // Build a simple UTC timestamp in "2006-01-02T15:04:05Z" format.
26    let timestamp = {
27        let secs = SystemTime::now()
28            .duration_since(UNIX_EPOCH)
29            .unwrap_or_default()
30            .as_secs();
31        // Rough calendar conversion (no external dependency).
32        let mut s = secs;
33        let sec = s % 60;
34        s /= 60;
35        let min = s % 60;
36        s /= 60;
37        let hour = s % 24;
38        s /= 24;
39        // Days since epoch → year/month/day.
40        let mut year = 1970u32;
41        loop {
42            let days_in_year = if year.is_multiple_of(4)
43                && (!year.is_multiple_of(100) || year.is_multiple_of(400))
44            {
45                366
46            } else {
47                365
48            };
49            if s < days_in_year {
50                break;
51            }
52            s -= days_in_year;
53            year += 1;
54        }
55        let leap =
56            year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400));
57        let days_in_month: [u64; 12] = [
58            31,
59            if leap { 29u64 } else { 28u64 },
60            31,
61            30,
62            31,
63            30,
64            31,
65            31,
66            30,
67            31,
68            30,
69            31,
70        ];
71        let mut month = 0u32;
72        for (i, &dim) in days_in_month.iter().enumerate() {
73            if s < dim {
74                month = i as u32 + 1;
75                break;
76            }
77            s -= dim;
78        }
79        let day = s + 1;
80        format!(
81            "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
82            year, month, day, hour, min, sec
83        )
84    };
85
86    let host = headers
87        .get(http::header::HOST)
88        .and_then(|v| v.to_str().ok())
89        .unwrap_or("-");
90
91    let user_agent = headers
92        .get(http::header::USER_AGENT)
93        .and_then(|v| v.to_str().ok())
94        .unwrap_or("-");
95
96    let content_length_str = content_length
97        .map(|n| n.to_string())
98        .unwrap_or_else(|| "-".to_string());
99
100    let mut result = format.to_string();
101    result = result.replace("{client_ip}", &client_addr.ip().to_string());
102    result = result.replace("{client_port}", &client_addr.port().to_string());
103    result = result.replace("{method}", method);
104    result = result.replace("{path}", path);
105    result = result.replace("{path_and_query}", path_and_query);
106    result = result.replace("{status}", &status.to_string());
107    result = result.replace("{latency_ms}", &latency.as_millis().to_string());
108    result = result.replace("{host}", host);
109    result = result.replace("{timestamp}", &timestamp);
110    result = result.replace("{content_length}", &content_length_str);
111    result = result.replace("{user_agent}", user_agent);
112
113    // Handle {header:Name} placeholders.
114    while let Some(start) = result.find("{header:") {
115        let end = match result[start..].find('}') {
116            Some(e) => start + e,
117            None => break,
118        };
119        let placeholder = &result[start..=end];
120        let header_name = &placeholder[8..placeholder.len() - 1];
121        let value = headers
122            .get(header_name)
123            .and_then(|v| v.to_str().ok())
124            .unwrap_or("-");
125        result = result.replace(placeholder, value);
126    }
127
128    result
129}
130
131const DEFAULT_FORMAT: &str =
132    r#"{client_ip} - [{timestamp}] "{method} {path}" {status} {latency_ms}ms"#;
133
134// ---------------------------------------------------------------------------
135// RotatingLogWriter
136// ---------------------------------------------------------------------------
137
138/// A buffered file writer that rotates the log file when it exceeds `max_size`.
139///
140/// When rotation occurs:
141/// 1. The current file is flushed and closed.
142/// 2. Existing rotated files are renamed: `log.N` → `log.N+1`, `log` → `log.1`.
143/// 3. Files beyond `max_keep` are deleted.
144/// 4. A new empty file is opened at the original path.
145pub struct RotatingLogWriter {
146    path: PathBuf,
147    writer: tokio::io::BufWriter<tokio::fs::File>,
148    current_size: u64,
149    max_size: Option<u64>,
150    max_keep: Option<usize>,
151}
152
153impl RotatingLogWriter {
154    /// Open (or create) the log file at `path`.
155    pub async fn open(
156        path: PathBuf,
157        max_size: Option<u64>,
158        max_keep: Option<usize>,
159    ) -> std::io::Result<Self> {
160        let file = tokio::fs::OpenOptions::new()
161            .create(true)
162            .append(true)
163            .open(&path)
164            .await?;
165        let current_size = file.metadata().await?.len();
166        Ok(Self {
167            path,
168            writer: tokio::io::BufWriter::new(file),
169            current_size,
170            max_size,
171            max_keep,
172        })
173    }
174
175    /// Write bytes to the log, rotating if necessary.
176    pub async fn write(&mut self, data: &[u8]) -> std::io::Result<()> {
177        // Check whether rotation is needed before writing.
178        if let Some(max) = self.max_size
179            && self.current_size + data.len() as u64 > max
180        {
181            self.rotate().await?;
182        }
183
184        self.writer.write_all(data).await?;
185        self.current_size += data.len() as u64;
186        Ok(())
187    }
188
189    /// Flush the writer.
190    pub async fn flush(&mut self) -> std::io::Result<()> {
191        self.writer.flush().await
192    }
193
194    /// Perform log rotation synchronously (sync rename is fine since this is infrequent).
195    async fn rotate(&mut self) -> std::io::Result<()> {
196        // Flush the current file before rotating.
197        self.writer.flush().await?;
198
199        // Determine how many rotated files to keep.
200        let keep = self.max_keep.unwrap_or(usize::MAX);
201
202        // Shift existing rotated files: log.N → log.N+1
203        // We work backwards from the largest existing index down to 1.
204        if keep > 0 {
205            // Find the highest existing index.
206            let mut highest = 0usize;
207            for n in 1..=keep {
208                let candidate = rotated_path(&self.path, n);
209                if candidate.exists() {
210                    highest = n;
211                } else {
212                    break;
213                }
214            }
215
216            // Rename from highest down to 1, then delete if beyond keep.
217            for n in (1..=highest).rev() {
218                let src = rotated_path(&self.path, n);
219                if n + 1 > keep {
220                    // Beyond the keep limit — delete.
221                    let _ = std::fs::remove_file(&src);
222                } else {
223                    let dst = rotated_path(&self.path, n + 1);
224                    let _ = std::fs::rename(&src, &dst);
225                }
226            }
227
228            // Rename the current log → log.1
229            if keep >= 1 {
230                let dst = rotated_path(&self.path, 1);
231                let _ = std::fs::rename(&self.path, &dst);
232            }
233        }
234
235        // Open a fresh file.
236        let new_file = tokio::fs::OpenOptions::new()
237            .create(true)
238            .write(true)
239            .truncate(true)
240            .open(&self.path)
241            .await?;
242        self.writer = tokio::io::BufWriter::new(new_file);
243        self.current_size = 0;
244
245        Ok(())
246    }
247}
248
249/// Compute the rotated path for index `n`, e.g. `/var/log/access.log.1`.
250fn rotated_path(base: &Path, n: usize) -> PathBuf {
251    let mut s = base.as_os_str().to_owned();
252    s.push(format!(".{}", n));
253    PathBuf::from(s)
254}
255
256// ---------------------------------------------------------------------------
257// LoggingHoop
258// ---------------------------------------------------------------------------
259
260/// Access-log middleware. Logs method, path, status, and latency.
261///
262/// Optionally writes structured access logs to a file in addition to the
263/// tracing output. Use `LoggingHoop::with_rotating_writer` to enable
264/// file output, or `LoggingHoop::with_files` to write errors to a
265/// separate file.
266pub struct LoggingHoop {
267    log_file: Option<Arc<Mutex<RotatingLogWriter>>>,
268    _error_log_file: Option<Arc<Mutex<RotatingLogWriter>>>,
269    format: Option<String>,
270}
271
272impl LoggingHoop {
273    pub fn new() -> Self {
274        Self {
275            log_file: None,
276            _error_log_file: None,
277            format: None,
278        }
279    }
280
281    pub fn with_rotating_writer(
282        writer: Arc<Mutex<RotatingLogWriter>>,
283        format: Option<String>,
284    ) -> Self {
285        Self {
286            log_file: Some(writer),
287            _error_log_file: None,
288            format,
289        }
290    }
291
292    /// Create a `LoggingHoop` with separate access and error log writers.
293    pub fn with_files(
294        access_writer: Arc<Mutex<RotatingLogWriter>>,
295        error_writer: Arc<Mutex<RotatingLogWriter>>,
296        format: Option<String>,
297    ) -> Self {
298        Self {
299            log_file: Some(access_writer),
300            _error_log_file: Some(error_writer),
301            format,
302        }
303    }
304
305    /// Convenience constructor that wraps a plain BufWriter<File>.
306    pub async fn with_file_path(path: PathBuf, format: Option<String>) -> std::io::Result<Self> {
307        let writer = RotatingLogWriter::open(path, None, None).await?;
308        Ok(Self {
309            log_file: Some(Arc::new(Mutex::new(writer))),
310            _error_log_file: None,
311            format,
312        })
313    }
314}
315
316impl Default for LoggingHoop {
317    fn default() -> Self {
318        Self::new()
319    }
320}
321
322#[async_trait]
323impl salvo::Handler for LoggingHoop {
324    async fn handle(
325        &self,
326        req: &mut Request,
327        depot: &mut Depot,
328        res: &mut Response,
329        ctrl: &mut FlowCtrl,
330    ) {
331        let method = req.method().clone();
332        let path = req.uri().path().to_string();
333        let path_and_query = req
334            .uri()
335            .path_and_query()
336            .map(|pq| pq.as_str().to_string())
337            .unwrap_or_else(|| path.clone());
338        let client = super::client_addr(req);
339        let req_headers = req.headers().clone();
340        let start = Instant::now();
341
342        ctrl.call_next(req, depot, res).await;
343
344        let elapsed = start.elapsed();
345        let status = res.status_code.map(|s| s.as_u16()).unwrap_or(200);
346        let content_length = res
347            .headers()
348            .get(http::header::CONTENT_LENGTH)
349            .and_then(|v| v.to_str().ok())
350            .and_then(|s| s.parse::<u64>().ok());
351
352        info!(
353            client = %client,
354            method = %method,
355            path = %path,
356            status = status,
357            latency_ms = elapsed.as_millis() as u64,
358            "request handled"
359        );
360
361        if let Some(ref file) = self.log_file {
362            let fmt = self.format.as_deref().unwrap_or(DEFAULT_FORMAT);
363            let line = format_log_line(
364                fmt,
365                &client,
366                method.as_str(),
367                &path,
368                &path_and_query,
369                status,
370                elapsed,
371                &req_headers,
372                content_length,
373            );
374            let mut writer = file.lock().await;
375            let _ = writer.write(line.as_bytes()).await;
376            let _ = writer.write(b"\n").await;
377            let _ = writer.flush().await;
378        }
379    }
380}