microclaw_engine/internal/app/
logging.rs1use 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}