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 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 if serde_json::to_writer(&mut *file, &record).is_ok() {
65 let _ = file.write_all(b"\n");
66 let _ = file.flush();
67 }
68 }
69}
70
71#[derive(Debug, Clone, Copy, Serialize)]
72#[serde(rename_all = "snake_case")]
73pub enum LogLevel {
74 Error,
75 Info,
76}
77
78#[derive(Debug, Serialize)]
79struct OperationalRecord {
80 timestamp: String,
81 level: LogLevel,
82 target: &'static str,
83 event: &'static str,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 fields: Option<Value>,
86}
87
88#[cfg(test)]
89mod tests {
90 use std::fs;
91
92 use serde_json::Value;
93 use tempfile::tempdir;
94
95 use super::{LogLevel, OperationalLog};
96
97 #[test]
98 fn writes_one_structured_record_per_line() {
99 let root = tempdir().unwrap();
100 let path = root.path().join("logs/daemon.ndjson");
101 let log = OperationalLog::open(&path).unwrap();
102
103 log.emit(LogLevel::Info, "sloop::daemon", "daemon_started");
104
105 let contents = fs::read_to_string(path).unwrap();
106 let record: Value = serde_json::from_str(contents.trim_end()).unwrap();
107 assert_eq!(record["level"], "info");
108 assert_eq!(record["target"], "sloop::daemon");
109 assert_eq!(record["event"], "daemon_started");
110 assert!(record["timestamp"].as_str().unwrap().ends_with('Z'));
111 }
112
113 #[test]
114 fn writes_safe_context_as_structured_fields() {
115 let root = tempdir().unwrap();
116 let path = root.path().join("logs/daemon.ndjson");
117 let log = OperationalLog::open(&path).unwrap();
118
119 log.emit_with_fields(
120 LogLevel::Error,
121 "sloop::dispatcher",
122 "run_exit_persist_failed",
123 serde_json::json!({"run_id": "R7", "error": "database is busy"}),
124 );
125
126 let contents = fs::read_to_string(path).unwrap();
127 let record: Value = serde_json::from_str(contents.trim_end()).unwrap();
128 assert_eq!(record["fields"]["run_id"], "R7");
129 assert_eq!(record["fields"]["error"], "database is busy");
130 }
131}