Skip to main content

gate4agent_node_core/
node_log.rs

1//! Rotating JSON-lines log for the node. Path comes from the caller or
2//! `GATE4AGENT_NODE_LOG` — never a hardcoded absolute path.
3
4use std::fs::{File, OpenOptions};
5use std::io::Write;
6use std::path::{Path, PathBuf};
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::sync::{Mutex, OnceLock};
9use std::time::{SystemTime, UNIX_EPOCH};
10
11const MAX_BYTES: u64 = 5 * 1024 * 1024;
12const ENV_LOG: &str = "GATE4AGENT_NODE_LOG";
13
14static READY: AtomicBool = AtomicBool::new(false);
15static SINK: OnceLock<Mutex<Sink>> = OnceLock::new();
16
17struct Sink {
18    path: PathBuf,
19    file: File,
20    written: u64,
21}
22
23/// Install the file sink. Also mirrors every line to stderr.
24/// Safe to call more than once: the first successful path wins.
25pub fn init(path: impl AsRef<Path>) -> Result<(), String> {
26    let path = path.as_ref().to_path_buf();
27    if path.as_os_str().is_empty() {
28        return Err("node log path is empty".to_owned());
29    }
30    if let Some(parent) = path.parent() {
31        std::fs::create_dir_all(parent).map_err(|e| format!("node log dir: {e}"))?;
32    }
33    let meta_len = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
34    let file = OpenOptions::new()
35        .create(true)
36        .append(true)
37        .open(&path)
38        .map_err(|e| format!("node log open {}: {e}", path.display()))?;
39    let sink = Sink {
40        path,
41        file,
42        written: meta_len,
43    };
44    let _ = SINK.set(Mutex::new(sink));
45    READY.store(true, Ordering::SeqCst);
46    Ok(())
47}
48
49/// Init from `GATE4AGENT_NODE_LOG` when set. No-op when unset.
50pub fn init_from_env() -> Result<(), String> {
51    match std::env::var_os(ENV_LOG) {
52        Some(v) if !v.is_empty() => init(PathBuf::from(v)),
53        _ => Ok(()),
54    }
55}
56
57pub fn env_name() -> &'static str {
58    ENV_LOG
59}
60
61/// Resolve the default log path next to an enroll/state directory
62/// (`<state>/node.log`), without hardcoding a home root.
63pub fn path_beside_state(state_dir: &Path) -> PathBuf {
64    state_dir.join("node.log")
65}
66
67fn now_ms() -> u64 {
68    SystemTime::now()
69        .duration_since(UNIX_EPOCH)
70        .map(|d| d.as_millis() as u64)
71        .unwrap_or(0)
72}
73
74fn emit(level: &str, event: &str, fields: &[(&str, String)]) {
75    let mut line = format!(
76        "{{\"ts_ms\":{},\"level\":{level:?},\"event\":{event:?}",
77        now_ms()
78    );
79    for (k, v) in fields {
80        line.push(',');
81        line.push_str(&format!("{k:?}:{v:?}"));
82    }
83    line.push('}');
84    eprintln!("{line}");
85    if !READY.load(Ordering::Relaxed) {
86        return;
87    }
88    let Some(lock) = SINK.get() else {
89        return;
90    };
91    let Ok(mut sink) = lock.lock() else {
92        return;
93    };
94    if sink.written >= MAX_BYTES {
95        let _ = sink.file.flush();
96        let path = sink.path.clone();
97        match rotate(&path) {
98            Ok(f) => {
99                sink.file = f;
100                sink.written = 0;
101            }
102            Err(_) => return,
103        }
104    }
105    if writeln!(sink.file, "{line}").is_ok() {
106        sink.written = sink.written.saturating_add(line.len() as u64 + 1);
107        let _ = sink.file.flush();
108    }
109}
110
111fn rotate(path: &Path) -> Result<File, String> {
112    let bak = path.with_extension("log.1");
113    let _ = std::fs::remove_file(&bak);
114    let _ = std::fs::rename(path, &bak);
115    OpenOptions::new()
116        .create(true)
117        .append(true)
118        .open(path)
119        .map_err(|e| e.to_string())
120}
121
122pub fn info(event: &str, fields: &[(&str, String)]) {
123    emit("info", event, fields);
124}
125
126pub fn warn(event: &str, fields: &[(&str, String)]) {
127    emit("warn", event, fields);
128}
129
130pub fn error(event: &str, fields: &[(&str, String)]) {
131    emit("error", event, fields);
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use std::io::Read;
138
139    #[test]
140    fn path_beside_state_is_relative_to_caller() {
141        let dir = std::env::temp_dir().join(format!("g4a-log-path-{}", std::process::id()));
142        let _ = std::fs::remove_dir_all(&dir);
143        std::fs::create_dir_all(&dir).unwrap();
144        let p = path_beside_state(&dir);
145        assert_eq!(p, dir.join("node.log"));
146        let _ = std::fs::remove_dir_all(&dir);
147    }
148
149    #[test]
150    fn writes_and_rotates_under_tempdir() {
151        let dir = std::env::temp_dir().join(format!("g4a-log-rot-{}", std::process::id()));
152        let _ = std::fs::remove_dir_all(&dir);
153        std::fs::create_dir_all(&dir).unwrap();
154        let path = dir.join("node.log");
155        // Fresh sink for this test process — only works if SINK unset.
156        // Use a unique subprocess-free approach: write via init only once per
157        // process. If already inited, still verify helper path logic above.
158        if SINK.get().is_none() {
159            init(&path).unwrap();
160            info("test_event", &[("k", "v".to_owned())]);
161            let mut body = String::new();
162            File::open(&path).unwrap().read_to_string(&mut body).unwrap();
163            assert!(body.contains("test_event"));
164            assert!(body.contains("\"k\":\"v\""));
165        }
166        let _ = std::fs::remove_dir_all(&dir);
167    }
168}