gatel_core/
observability.rs1use std::fs::{File, OpenOptions};
8use std::io::{self, BufWriter, Write};
9use std::path::{Path, PathBuf};
10use std::sync::Mutex;
11
12use tracing::debug;
13
14pub struct LogfileBackend {
24 path: PathBuf,
25 writer: Mutex<BufWriter<File>>,
26 rotate_size: u64,
27 rotate_keep: usize,
28 bytes_written: Mutex<u64>,
29}
30
31impl LogfileBackend {
32 pub fn new(path: impl Into<PathBuf>, rotate_size: u64, rotate_keep: usize) -> io::Result<Self> {
34 let path = path.into();
35 let file = open_append(&path)?;
36 let initial_size = file.metadata().map(|m| m.len()).unwrap_or(0);
37 Ok(Self {
38 path,
39 writer: Mutex::new(BufWriter::new(file)),
40 rotate_size,
41 rotate_keep,
42 bytes_written: Mutex::new(initial_size),
43 })
44 }
45
46 pub fn write_line(&self, line: &str) -> io::Result<()> {
48 let mut writer = self.writer.lock().unwrap();
49 let mut bytes = self.bytes_written.lock().unwrap();
50
51 writer.write_all(line.as_bytes())?;
52 writer.write_all(b"\n")?;
53 writer.flush()?;
54
55 *bytes += line.len() as u64 + 1;
56
57 if self.rotate_size > 0 && *bytes >= self.rotate_size {
58 drop(writer);
59 drop(bytes);
60 self.rotate()?;
61 }
62
63 Ok(())
64 }
65
66 fn rotate(&self) -> io::Result<()> {
67 for i in (1..self.rotate_keep).rev() {
69 let from = rotated_path(&self.path, i);
70 let to = rotated_path(&self.path, i + 1);
71 if from.exists() {
72 std::fs::rename(&from, &to)?;
73 }
74 }
75
76 let oldest = rotated_path(&self.path, self.rotate_keep);
78 if oldest.exists() {
79 std::fs::remove_file(&oldest)?;
80 }
81
82 if self.path.exists() {
84 std::fs::rename(&self.path, rotated_path(&self.path, 1))?;
85 }
86
87 let file = open_append(&self.path)?;
89 let mut writer = self.writer.lock().unwrap();
90 *writer = BufWriter::new(file);
91 let mut bytes = self.bytes_written.lock().unwrap();
92 *bytes = 0;
93
94 debug!(path = %self.path.display(), "log file rotated");
95 Ok(())
96 }
97}
98
99fn rotated_path(base: &Path, n: usize) -> PathBuf {
100 let mut p = base.as_os_str().to_owned();
101 p.push(format!(".{n}"));
102 PathBuf::from(p)
103}
104
105fn open_append(path: &Path) -> io::Result<File> {
106 if let Some(parent) = path.parent() {
107 std::fs::create_dir_all(parent)?;
108 }
109 OpenOptions::new().create(true).append(true).open(path)
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum StdlogOutput {
119 Stdout,
120 Stderr,
121}
122
123pub struct StdlogBackend {
127 output: StdlogOutput,
128 json: bool,
129}
130
131impl StdlogBackend {
132 pub fn new(output: StdlogOutput, json: bool) -> Self {
134 Self { output, json }
135 }
136
137 pub fn write_entry(&self, level: &str, message: &str) {
139 let line = if self.json {
140 let ts = chrono_now();
141 format!(
142 r#"{{"timestamp":"{ts}","level":"{level}","message":{}}}"#,
143 json_escape(message)
144 )
145 } else {
146 format!("[{level}] {message}")
147 };
148
149 match self.output {
150 StdlogOutput::Stdout => {
151 let _ = writeln!(io::stdout(), "{line}");
152 }
153 StdlogOutput::Stderr => {
154 let _ = writeln!(io::stderr(), "{line}");
155 }
156 }
157 }
158}
159
160fn chrono_now() -> String {
162 use std::time::SystemTime;
163 let d = SystemTime::now()
164 .duration_since(SystemTime::UNIX_EPOCH)
165 .unwrap_or_default();
166 let secs = d.as_secs();
167 format!("{secs}")
169}
170
171fn json_escape(s: &str) -> String {
173 let mut out = String::with_capacity(s.len() + 2);
174 out.push('"');
175 for c in s.chars() {
176 match c {
177 '"' => out.push_str("\\\""),
178 '\\' => out.push_str("\\\\"),
179 '\n' => out.push_str("\\n"),
180 '\r' => out.push_str("\\r"),
181 '\t' => out.push_str("\\t"),
182 c if c.is_control() => {
183 out.push_str(&format!("\\u{:04x}", c as u32));
184 }
185 c => out.push(c),
186 }
187 }
188 out.push('"');
189 out
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[test]
197 fn logfile_write_and_rotate() {
198 let dir = tempfile::tempdir().unwrap();
199 let path = dir.path().join("test.log");
200
201 let backend = LogfileBackend::new(&path, 50, 3).unwrap();
202 for i in 0..10 {
203 backend
204 .write_line(&format!("line {i} with some padding"))
205 .unwrap();
206 }
207
208 assert!(path.exists());
210 assert!(rotated_path(&path, 1).exists());
212 }
213
214 #[test]
215 fn stdlog_json_format() {
216 let backend = StdlogBackend::new(StdlogOutput::Stderr, true);
217 backend.write_entry("info", "test message with \"quotes\"");
219 }
220
221 #[test]
222 fn json_escape_special_chars() {
223 assert_eq!(json_escape("hello"), "\"hello\"");
224 assert_eq!(json_escape("a\"b"), "\"a\\\"b\"");
225 assert_eq!(json_escape("a\nb"), "\"a\\nb\"");
226 }
227}