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#[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 let timestamp = {
27 let secs = SystemTime::now()
28 .duration_since(UNIX_EPOCH)
29 .unwrap_or_default()
30 .as_secs();
31 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 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}", ×tamp);
110 result = result.replace("{content_length}", &content_length_str);
111 result = result.replace("{user_agent}", user_agent);
112
113 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
134pub 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 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 pub async fn write(&mut self, data: &[u8]) -> std::io::Result<()> {
177 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 pub async fn flush(&mut self) -> std::io::Result<()> {
191 self.writer.flush().await
192 }
193
194 async fn rotate(&mut self) -> std::io::Result<()> {
196 self.writer.flush().await?;
198
199 let keep = self.max_keep.unwrap_or(usize::MAX);
201
202 if keep > 0 {
205 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 for n in (1..=highest).rev() {
218 let src = rotated_path(&self.path, n);
219 if n + 1 > keep {
220 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 if keep >= 1 {
230 let dst = rotated_path(&self.path, 1);
231 let _ = std::fs::rename(&self.path, &dst);
232 }
233 }
234
235 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
249fn 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
256pub 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 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 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}