gate4agent_node_core/
node_log.rs1use 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
23pub 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
49pub 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
61pub 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 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}