Skip to main content

microclaw_engine/internal/app/
logging.rs

1use crate::internal::observability::logs::OtlpLogExporter;
2use anyhow::{Context, Result};
3use chrono::{DateTime, Duration, NaiveDateTime, Utc};
4use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge;
5use opentelemetry_sdk::logs::{SdkLogger, SdkLoggerProvider};
6use serde_yaml::Value;
7use std::collections::VecDeque;
8use std::fs::{self, File, OpenOptions};
9use std::io::{self, Write};
10use std::path::{Path, PathBuf};
11use std::sync::{Arc, Mutex, OnceLock};
12use tracing_subscriber::fmt::writer::MakeWriter;
13use tracing_subscriber::layer::SubscriberExt;
14use tracing_subscriber::util::SubscriberInitExt;
15
16pub const LOG_FILE_PREFIX: &str = "microclaw-";
17pub const LOG_FILE_SUFFIX: &str = ".log";
18pub const LOG_RETENTION_DAYS: i64 = 30;
19static OTEL_LOG_EXPORTER: OnceLock<Arc<OtlpLogExporter>> = OnceLock::new();
20
21pub fn init_logging(runtime_data_dir: &str, observability: Option<&Value>) -> Result<()> {
22    let log_dir = PathBuf::from(runtime_data_dir).join("logs");
23    fs::create_dir_all(&log_dir)
24        .with_context(|| format!("Failed to create log directory: {}", log_dir.display()))?;
25    cleanup_old_logs(&log_dir, Utc::now(), LOG_RETENTION_DAYS)?;
26
27    let filter = tracing_subscriber::EnvFilter::try_from_default_env()
28        .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
29    let writer = HourlyLogWriter::new(log_dir, LOG_RETENTION_DAYS)?;
30    let fmt_layer = tracing_subscriber::fmt::layer()
31        .with_ansi(false)
32        .with_writer(writer);
33    let otel_layer = build_otel_log_layer(observability);
34    if let Some(otel_layer) = otel_layer {
35        tracing_subscriber::registry()
36            .with(filter)
37            .with(fmt_layer)
38            .with(otel_layer)
39            .init();
40    } else {
41        tracing_subscriber::registry()
42            .with(filter)
43            .with(fmt_layer)
44            .init();
45    }
46
47    Ok(())
48}
49
50pub fn init_console_logging(observability: Option<&Value>) {
51    let filter = tracing_subscriber::EnvFilter::try_from_default_env()
52        .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
53    let otel_layer = build_otel_log_layer(observability);
54
55    #[cfg(feature = "journald")]
56    {
57        let use_journald = std::env::var("JOURNAL_STREAM").is_ok()
58            || std::env::var("RUST_LOG_STYLE").is_ok_and(|v| v.eq_ignore_ascii_case("systemd"));
59
60        if use_journald {
61            if let Ok(journald_layer) = tracing_journald::layer() {
62                if let Some(otel_layer) = otel_layer {
63                    tracing_subscriber::registry()
64                        .with(filter)
65                        .with(journald_layer)
66                        .with(otel_layer)
67                        .init();
68                } else {
69                    tracing_subscriber::registry()
70                        .with(filter)
71                        .with(journald_layer)
72                        .init();
73                }
74                return;
75            }
76        }
77    }
78
79    let fmt_layer = tracing_subscriber::fmt::layer();
80    if let Some(otel_layer) = otel_layer {
81        tracing_subscriber::registry()
82            .with(filter)
83            .with(fmt_layer)
84            .with(otel_layer)
85            .init();
86    } else {
87        tracing_subscriber::registry()
88            .with(filter)
89            .with(fmt_layer)
90            .init();
91    }
92}
93
94fn build_otel_log_layer(
95    observability: Option<&Value>,
96) -> Option<OpenTelemetryTracingBridge<SdkLoggerProvider, SdkLogger>> {
97    let exporter = OtlpLogExporter::from_observability(observability)?;
98    let exporter = OTEL_LOG_EXPORTER.get_or_init(|| exporter.clone());
99    let provider = exporter.logger_provider();
100    Some(OpenTelemetryTracingBridge::new(provider.as_ref()))
101}
102
103#[derive(Debug)]
104struct HourlyState {
105    current_hour_key: String,
106    file: File,
107}
108
109#[derive(Clone, Debug)]
110struct HourlyLogWriter {
111    log_dir: PathBuf,
112    retention_days: i64,
113    state: Arc<Mutex<HourlyState>>,
114}
115
116impl HourlyLogWriter {
117    fn new(log_dir: PathBuf, retention_days: i64) -> Result<Self> {
118        let now = Utc::now();
119        let hour_key = hour_key(now);
120        let file = open_log_file(&log_dir, &hour_key)?;
121        let state = HourlyState {
122            current_hour_key: hour_key,
123            file,
124        };
125        Ok(Self {
126            log_dir,
127            retention_days,
128            state: Arc::new(Mutex::new(state)),
129        })
130    }
131}
132
133impl<'a> MakeWriter<'a> for HourlyLogWriter {
134    type Writer = HourlyLogGuard;
135
136    fn make_writer(&'a self) -> Self::Writer {
137        HourlyLogGuard {
138            log_dir: self.log_dir.clone(),
139            retention_days: self.retention_days,
140            state: self.state.clone(),
141        }
142    }
143}
144
145struct HourlyLogGuard {
146    log_dir: PathBuf,
147    retention_days: i64,
148    state: Arc<Mutex<HourlyState>>,
149}
150
151impl Write for HourlyLogGuard {
152    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
153        let now = Utc::now();
154        let now_key = hour_key(now);
155        let mut state = self
156            .state
157            .lock()
158            .map_err(|_| io::Error::other("failed to lock log writer"))?;
159
160        if state.current_hour_key != now_key {
161            state.file.flush()?;
162            state.file = open_log_file(&self.log_dir, &now_key)?;
163            state.current_hour_key = now_key;
164            let _ = cleanup_old_logs(&self.log_dir, now, self.retention_days);
165        }
166
167        state.file.write(buf)
168    }
169
170    fn flush(&mut self) -> io::Result<()> {
171        let mut state = self
172            .state
173            .lock()
174            .map_err(|_| io::Error::other("failed to lock log writer"))?;
175        state.file.flush()
176    }
177}
178
179fn hour_key(now: DateTime<Utc>) -> String {
180    now.format("%Y-%m-%d-%H").to_string()
181}
182
183fn log_file_path(log_dir: &Path, hour: &str) -> PathBuf {
184    log_dir.join(format!("{LOG_FILE_PREFIX}{hour}{LOG_FILE_SUFFIX}"))
185}
186
187fn open_log_file(log_dir: &Path, hour: &str) -> io::Result<File> {
188    OpenOptions::new()
189        .create(true)
190        .append(true)
191        .open(log_file_path(log_dir, hour))
192}
193
194pub fn cleanup_old_logs(log_dir: &Path, now: DateTime<Utc>, retention_days: i64) -> Result<()> {
195    let cutoff = now - Duration::days(retention_days);
196    let entries = match fs::read_dir(log_dir) {
197        Ok(v) => v,
198        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
199        Err(e) => return Err(e).with_context(|| format!("Failed to read {}", log_dir.display())),
200    };
201
202    for entry in entries {
203        let entry = entry?;
204        let path = entry.path();
205        if !path.is_file() {
206            continue;
207        }
208        let Some(file_name) = path.file_name().and_then(|s| s.to_str()) else {
209            continue;
210        };
211        let Some(log_time) = parse_log_filename_time(file_name) else {
212            continue;
213        };
214        if log_time < cutoff {
215            let _ = fs::remove_file(&path);
216        }
217    }
218    Ok(())
219}
220
221fn parse_log_filename_time(file_name: &str) -> Option<DateTime<Utc>> {
222    if !(file_name.starts_with(LOG_FILE_PREFIX) && file_name.ends_with(LOG_FILE_SUFFIX)) {
223        return None;
224    }
225    let body = &file_name[LOG_FILE_PREFIX.len()..file_name.len() - LOG_FILE_SUFFIX.len()];
226    let naive =
227        NaiveDateTime::parse_from_str(&format!("{body}:00:00"), "%Y-%m-%d-%H:%M:%S").ok()?;
228    Some(DateTime::<Utc>::from_naive_utc_and_offset(naive, Utc))
229}
230
231pub fn list_log_files_sorted(log_dir: &Path) -> Result<Vec<PathBuf>> {
232    let mut files = Vec::new();
233    let entries = match fs::read_dir(log_dir) {
234        Ok(v) => v,
235        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(files),
236        Err(e) => return Err(e).with_context(|| format!("Failed to read {}", log_dir.display())),
237    };
238    for entry in entries {
239        let entry = entry?;
240        let path = entry.path();
241        let Some(name) = path.file_name().and_then(|v| v.to_str()) else {
242            continue;
243        };
244        if parse_log_filename_time(name).is_some() {
245            files.push(path);
246        }
247    }
248    files.sort();
249    Ok(files)
250}
251
252pub fn read_last_lines_from_logs(log_dir: &Path, max_lines: usize) -> Result<Vec<String>> {
253    let mut queue: VecDeque<String> = VecDeque::new();
254    for file in list_log_files_sorted(log_dir)? {
255        let content = fs::read_to_string(&file)
256            .with_context(|| format!("Failed to read {}", file.display()))?;
257        for line in content.lines() {
258            queue.push_back(line.to_string());
259            if queue.len() > max_lines {
260                queue.pop_front();
261            }
262        }
263    }
264    Ok(queue.into_iter().collect())
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use uuid::Uuid;
271
272    fn test_dir() -> PathBuf {
273        std::env::temp_dir().join(format!("microclaw_logging_test_{}", Uuid::new_v4()))
274    }
275
276    #[test]
277    fn test_parse_log_filename_time() {
278        assert!(parse_log_filename_time("microclaw-2026-02-08-10.log").is_some());
279        assert!(parse_log_filename_time("microclaw-2026-02-08.log").is_none());
280        assert!(parse_log_filename_time("other-2026-02-08-10.log").is_none());
281    }
282
283    #[test]
284    fn test_cleanup_old_logs_keeps_recent_removes_old() {
285        let dir = test_dir();
286        fs::create_dir_all(&dir).unwrap();
287        fs::write(dir.join("microclaw-2025-01-01-00.log"), "old").unwrap();
288        fs::write(dir.join("microclaw-2026-02-08-10.log"), "new").unwrap();
289
290        let now = DateTime::parse_from_rfc3339("2026-02-08T11:00:00Z")
291            .unwrap()
292            .with_timezone(&Utc);
293        cleanup_old_logs(&dir, now, 30).unwrap();
294
295        assert!(!dir.join("microclaw-2025-01-01-00.log").exists());
296        assert!(dir.join("microclaw-2026-02-08-10.log").exists());
297        let _ = fs::remove_dir_all(&dir);
298    }
299
300    #[test]
301    fn test_read_last_lines_from_logs() {
302        let dir = test_dir();
303        fs::create_dir_all(&dir).unwrap();
304        fs::write(dir.join("microclaw-2026-02-08-09.log"), "a\nb\n").unwrap();
305        fs::write(dir.join("microclaw-2026-02-08-10.log"), "c\nd\n").unwrap();
306
307        let lines = read_last_lines_from_logs(&dir, 3).unwrap();
308        assert_eq!(lines, vec!["b", "c", "d"]);
309        let _ = fs::remove_dir_all(&dir);
310    }
311}