Skip to main content

hermod/server/
logging.rs

1//! File-based log writing for `hermod-tracer`
2//!
3//! Each node gets its own subdirectory under the configured `logRoot`.
4//! Within that directory, log output is written to a timestamped file:
5//!
6//! ```text
7//! {logRoot}/
8//! └── {node-id}/
9//!     ├── node-2024-01-15T10-30-00.json   ← previous file
10//!     ├── node-2024-01-15T12-00-00.json   ← current file
11//!     └── node.json                        ← symlink → current file
12//! ```
13//!
14//! The symlink is updated atomically via a `.tmp` rename so readers always
15//! see a consistent target.
16//!
17//! Two formats are supported:
18//!
19//! - [`LogFormat::ForHuman`] — `"{timestamp} [{severity}] {namespace} {message}\n"`
20//! - [`LogFormat::ForMachine`] — one JSON object per line (newline-delimited JSON)
21
22use crate::protocol::TraceObject;
23use crate::server::config::{LogFormat, LoggingParams};
24use crate::server::node::NodeId;
25use chrono::Utc;
26use std::collections::HashMap;
27use std::fs::{self, File, OpenOptions};
28use std::io::{self, Write};
29use std::path::{Path, PathBuf};
30use std::sync::Mutex;
31
32/// Extension for a given log format
33fn ext(fmt: LogFormat) -> &'static str {
34    match fmt {
35        LogFormat::ForHuman => "log",
36        LogFormat::ForMachine => "json",
37    }
38}
39
40/// A key into the handle cache
41#[derive(Hash, PartialEq, Eq, Clone, Debug)]
42pub struct LogKey {
43    /// Node identifier
44    pub node_id: NodeId,
45    /// Log root directory
46    pub log_root: PathBuf,
47    /// Log format (Human or Machine)
48    pub log_format: LogFormat,
49}
50
51/// One open log file
52pub struct LogHandle {
53    /// The open file
54    pub file: File,
55    /// Absolute path to this file
56    pub path: PathBuf,
57    /// Bytes written so far
58    pub bytes_written: u64,
59}
60
61/// Shared writer — holds open file handles for all active (node, logging-config) pairs
62pub struct LogWriter {
63    handles: Mutex<HashMap<LogKey, LogHandle>>,
64}
65
66impl LogWriter {
67    /// Create a new log writer
68    pub fn new() -> Self {
69        LogWriter {
70            handles: Mutex::new(HashMap::new()),
71        }
72    }
73
74    /// Write a batch of traces for one (node, logging-params) pair
75    pub fn write_traces(
76        &self,
77        node_id: &NodeId,
78        params: &LoggingParams,
79        traces: &[TraceObject],
80    ) -> io::Result<()> {
81        let key = LogKey {
82            node_id: node_id.clone(),
83            log_root: params.log_root.clone(),
84            log_format: params.log_format,
85        };
86
87        let mut handles = self.handles.lock().unwrap();
88        if !handles.contains_key(&key) {
89            let handle = Self::open_new_file(&params.log_root, node_id, params.log_format)?;
90            handles.insert(key.clone(), handle);
91        }
92        let handle = handles.get_mut(&key).unwrap();
93
94        for trace in traces {
95            let line = format_trace(trace, params.log_format);
96            let bytes = line.as_bytes();
97            handle.file.write_all(bytes)?;
98            handle.bytes_written += bytes.len() as u64;
99        }
100        handle.file.flush()?;
101        Ok(())
102    }
103
104    /// Rotate the log file for a key if it exceeds `limit_bytes`
105    pub fn rotate_if_needed(
106        &self,
107        node_id: &NodeId,
108        params: &LoggingParams,
109        limit_bytes: u64,
110    ) -> io::Result<()> {
111        let key = LogKey {
112            node_id: node_id.clone(),
113            log_root: params.log_root.clone(),
114            log_format: params.log_format,
115        };
116
117        let mut handles = self.handles.lock().unwrap();
118        if let Some(handle) = handles.get(&key) {
119            if handle.bytes_written >= limit_bytes {
120                let new_handle = Self::open_new_file(&params.log_root, node_id, params.log_format)?;
121                handles.insert(key, new_handle);
122            }
123        }
124        Ok(())
125    }
126
127    /// Open a new timestamped log file and update the `node.{ext}` symlink
128    pub fn open_new_file(
129        log_root: &Path,
130        node_id: &NodeId,
131        format: LogFormat,
132    ) -> io::Result<LogHandle> {
133        // Sanitise node_id for use as a directory name
134        let node_dir_name: String = node_id
135            .chars()
136            .map(|c| {
137                if c.is_alphanumeric() || c == '-' || c == '_' {
138                    c
139                } else {
140                    '_'
141                }
142            })
143            .collect();
144
145        let node_dir = log_root.join(&node_dir_name);
146        fs::create_dir_all(&node_dir)?;
147
148        let ts = Utc::now().format("%Y-%m-%dT%H-%M-%S");
149        let filename = format!("node-{}.{}", ts, ext(format));
150        let path = node_dir.join(&filename);
151
152        let file = OpenOptions::new().create(true).append(true).open(&path)?;
153
154        // Atomically update symlink: write to .tmp then rename
155        let link = node_dir.join(format!("node.{}", ext(format)));
156        let tmp_link = node_dir.join(format!("node.{}.tmp", ext(format)));
157
158        // Remove stale .tmp if it exists
159        let _ = fs::remove_file(&tmp_link);
160
161        // Create new symlink pointing at the timestamped file (relative name)
162        #[cfg(unix)]
163        std::os::unix::fs::symlink(&filename, &tmp_link)?;
164
165        #[cfg(unix)]
166        fs::rename(&tmp_link, &link)?;
167
168        Ok(LogHandle {
169            file,
170            path,
171            bytes_written: 0,
172        })
173    }
174}
175
176impl Default for LogWriter {
177    fn default() -> Self {
178        Self::new()
179    }
180}
181
182/// Format a single trace as a line (with trailing newline)
183pub fn format_trace(trace: &TraceObject, format: LogFormat) -> String {
184    match format {
185        LogFormat::ForHuman => format_human(trace),
186        LogFormat::ForMachine => format_machine(trace),
187    }
188}
189
190/// Human-readable format:
191/// `{timestamp} [{severity}] {namespace} {message}\n`
192pub fn format_human(trace: &TraceObject) -> String {
193    let ts = trace.to_timestamp.format("%Y-%m-%dT%H:%M:%S%.3fZ");
194    let ns = trace.to_namespace.join(".");
195    let msg = trace.to_human.as_deref().unwrap_or(&trace.to_machine);
196    format!("{} [{}] {} {}\n", ts, trace.to_severity, ns, msg)
197}
198
199/// Machine-readable format: JSON line
200pub fn format_machine(trace: &TraceObject) -> String {
201    let mut line = serde_json::to_string(trace).unwrap_or_else(|_| "{}".to_string());
202    line.push('\n');
203    line
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::protocol::types::{DetailLevel, Severity};
210    use chrono::TimeZone;
211
212    fn make_trace() -> TraceObject {
213        TraceObject {
214            to_human: Some("hello world".to_string()),
215            to_machine: r#"{"msg":"hello world"}"#.to_string(),
216            to_namespace: vec!["TestNS".to_string(), "Sub".to_string()],
217            to_severity: Severity::Info,
218            to_details: DetailLevel::DNormal,
219            to_timestamp: Utc.with_ymd_and_hms(2024, 1, 15, 10, 30, 0).unwrap(),
220            to_hostname: "testhost".to_string(),
221            to_thread_id: "1".to_string(),
222        }
223    }
224
225    #[test]
226    fn test_format_human() {
227        let trace = make_trace();
228        let line = format_human(&trace);
229        assert!(line.contains("[Info]"));
230        assert!(line.contains("TestNS.Sub"));
231        assert!(line.contains("hello world"));
232        assert!(line.ends_with('\n'));
233    }
234
235    #[test]
236    fn test_format_machine() {
237        let trace = make_trace();
238        let line = format_machine(&trace);
239        assert!(line.starts_with('{'));
240        assert!(line.ends_with('\n'));
241        // Should be valid JSON
242        let _: serde_json::Value = serde_json::from_str(line.trim()).unwrap();
243    }
244
245    #[test]
246    fn test_write_and_read_back() {
247        let dir = tempfile::tempdir().unwrap();
248        let log_root = dir.path().to_path_buf();
249        let params = LoggingParams {
250            log_root: log_root.clone(),
251            log_mode: crate::server::config::LogMode::FileMode,
252            log_format: LogFormat::ForMachine,
253        };
254
255        let writer = LogWriter::new();
256        let traces = vec![make_trace()];
257        writer
258            .write_traces(&"test-node".to_string(), &params, &traces)
259            .unwrap();
260
261        // Find the written file
262        let node_dir = log_root.join("test-node");
263        let entries: Vec<_> = fs::read_dir(&node_dir)
264            .unwrap()
265            .filter_map(|e| e.ok())
266            .filter(|e| e.file_name().to_string_lossy().ends_with(".json"))
267            .filter(|e| !e.file_name().to_string_lossy().starts_with("node.json"))
268            .collect();
269        assert_eq!(entries.len(), 1);
270        let content = fs::read_to_string(entries[0].path()).unwrap();
271        assert!(!content.is_empty());
272    }
273}