Skip to main content

gatel_core/
observability.rs

1//! Observability backends for structured logging.
2//!
3//! Provides multiple log output destinations beyond the default stderr:
4//! - [`LogfileBackend`] — write structured logs to a rotating file
5//! - [`StdlogBackend`] — write structured JSON logs to stdout or stderr
6
7use std::fs::{File, OpenOptions};
8use std::io::{self, BufWriter, Write};
9use std::path::{Path, PathBuf};
10use std::sync::Mutex;
11
12use tracing::debug;
13
14// ---------------------------------------------------------------------------
15// Logfile backend
16// ---------------------------------------------------------------------------
17
18/// A rotating log file writer.
19///
20/// Logs are appended to the current file. When the file exceeds
21/// `rotate_size`, it is renamed with a numeric suffix and a new file is
22/// opened. Up to `rotate_keep` old files are retained.
23pub 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    /// Open or create a log file at `path`.
33    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    /// Write a log line. Rotates if size threshold is exceeded.
47    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        // Rename existing rotated files: .2 -> .3, .1 -> .2, etc.
68        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        // Delete the oldest if it exceeds keep count.
77        let oldest = rotated_path(&self.path, self.rotate_keep);
78        if oldest.exists() {
79            std::fs::remove_file(&oldest)?;
80        }
81
82        // Current → .1
83        if self.path.exists() {
84            std::fs::rename(&self.path, rotated_path(&self.path, 1))?;
85        }
86
87        // Open a fresh file.
88        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// ---------------------------------------------------------------------------
113// Stdlog backend
114// ---------------------------------------------------------------------------
115
116/// Output destination for [`StdlogBackend`].
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum StdlogOutput {
119    Stdout,
120    Stderr,
121}
122
123/// Structured log writer to stdout or stderr.
124///
125/// Each line is a JSON object with `timestamp`, `level`, and `message` fields.
126pub struct StdlogBackend {
127    output: StdlogOutput,
128    json: bool,
129}
130
131impl StdlogBackend {
132    /// Create a new stdlog backend.
133    pub fn new(output: StdlogOutput, json: bool) -> Self {
134        Self { output, json }
135    }
136
137    /// Write a log entry.
138    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
160/// Simple ISO-8601 timestamp without pulling in chrono.
161fn 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    // Very simple formatting — not locale-aware but good enough for logs.
168    format!("{secs}")
169}
170
171/// Escape a string for JSON output.
172fn 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        // After rotation, the main file should exist and be small.
209        assert!(path.exists());
210        // At least one rotated file should exist.
211        assert!(rotated_path(&path, 1).exists());
212    }
213
214    #[test]
215    fn stdlog_json_format() {
216        let backend = StdlogBackend::new(StdlogOutput::Stderr, true);
217        // Just verify it doesn't panic.
218        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}