Skip to main content

sloop/
logging.rs

1use std::fs::{self, File, OpenOptions};
2use std::io::{self, Write};
3use std::path::Path;
4use std::sync::{Arc, Mutex};
5
6use serde::Serialize;
7use serde_json::Value;
8use time::OffsetDateTime;
9use time::format_description::well_known::Rfc3339;
10
11#[derive(Clone)]
12pub struct OperationalLog {
13    file: Arc<Mutex<File>>,
14}
15
16impl OperationalLog {
17    pub fn open(path: &Path) -> io::Result<Self> {
18        if let Some(parent) = path.parent() {
19            fs::create_dir_all(parent)?;
20        }
21        let file = OpenOptions::new().create(true).append(true).open(path)?;
22        Ok(Self {
23            file: Arc::new(Mutex::new(file)),
24        })
25    }
26
27    pub fn emit(&self, level: LogLevel, target: &'static str, event: &'static str) {
28        self.emit_fields(level, target, event, None);
29    }
30
31    /// Writes caller-selected operational context. Callers must keep fields
32    /// to identifiers, classifications, and errors; prompts and credentials
33    /// never belong in the operational log.
34    pub fn emit_with_fields(
35        &self,
36        level: LogLevel,
37        target: &'static str,
38        event: &'static str,
39        fields: Value,
40    ) {
41        self.emit_fields(level, target, event, Some(fields));
42    }
43
44    fn emit_fields(
45        &self,
46        level: LogLevel,
47        target: &'static str,
48        event: &'static str,
49        fields: Option<Value>,
50    ) {
51        let timestamp = OffsetDateTime::now_utc()
52            .format(&Rfc3339)
53            .unwrap_or_else(|_| "1970-01-01T00:00:00Z".into());
54        let record = OperationalRecord {
55            timestamp,
56            level,
57            target,
58            event,
59            fields,
60        };
61        let Ok(mut file) = self.file.lock() else {
62            return;
63        };
64        let Ok(mut line) = serde_json::to_vec(&record) else {
65            return;
66        };
67        line.push(b'\n');
68        let Ok(original_len) = file.metadata().map(|metadata| metadata.len()) else {
69            return;
70        };
71        if file.write_all(&line).and_then(|()| file.flush()).is_err() {
72            // A full filesystem can leave a short write. Remove it so a later
73            // successful record still begins on a valid NDJSON boundary.
74            let _ = file.set_len(original_len);
75        }
76    }
77}
78
79#[derive(Debug, Clone, Copy, Serialize)]
80#[serde(rename_all = "snake_case")]
81pub enum LogLevel {
82    Error,
83    Warn,
84    Info,
85}
86
87#[derive(Debug, Serialize)]
88struct OperationalRecord {
89    timestamp: String,
90    level: LogLevel,
91    target: &'static str,
92    event: &'static str,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    fields: Option<Value>,
95}
96
97#[cfg(test)]
98mod tests {
99    use std::fs;
100
101    use serde_json::Value;
102    use tempfile::tempdir;
103
104    use super::{LogLevel, OperationalLog};
105
106    #[test]
107    fn writes_one_structured_record_per_line() {
108        let root = tempdir().unwrap();
109        let path = root.path().join("logs/daemon.ndjson");
110        let log = OperationalLog::open(&path).unwrap();
111
112        log.emit(LogLevel::Info, "sloop::daemon", "daemon_started");
113
114        let contents = fs::read_to_string(path).unwrap();
115        let record: Value = serde_json::from_str(contents.trim_end()).unwrap();
116        assert_eq!(record["level"], "info");
117        assert_eq!(record["target"], "sloop::daemon");
118        assert_eq!(record["event"], "daemon_started");
119        assert!(record["timestamp"].as_str().unwrap().ends_with('Z'));
120    }
121
122    #[test]
123    fn writes_safe_context_as_structured_fields() {
124        let root = tempdir().unwrap();
125        let path = root.path().join("logs/daemon.ndjson");
126        let log = OperationalLog::open(&path).unwrap();
127
128        log.emit_with_fields(
129            LogLevel::Error,
130            "sloop::dispatcher",
131            "run_exit_persist_failed",
132            serde_json::json!({"run_id": "R7", "error": "database is busy"}),
133        );
134
135        let contents = fs::read_to_string(path).unwrap();
136        let record: Value = serde_json::from_str(contents.trim_end()).unwrap();
137        assert_eq!(record["fields"]["run_id"], "R7");
138        assert_eq!(record["fields"]["error"], "database is busy");
139    }
140}