microclaw-engine 0.5.0

Channel-independent Agent Engine and default services for MicroClaw
Documentation
use crate::internal::observability::logs::OtlpLogExporter;
use anyhow::{Context, Result};
use chrono::{DateTime, Duration, NaiveDateTime, Utc};
use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge;
use opentelemetry_sdk::logs::{SdkLogger, SdkLoggerProvider};
use serde_yaml::Value;
use std::collections::VecDeque;
use std::fs::{self, File, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use tracing_subscriber::fmt::writer::MakeWriter;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;

pub const LOG_FILE_PREFIX: &str = "microclaw-";
pub const LOG_FILE_SUFFIX: &str = ".log";
pub const LOG_RETENTION_DAYS: i64 = 30;
static OTEL_LOG_EXPORTER: OnceLock<Arc<OtlpLogExporter>> = OnceLock::new();

pub fn init_logging(runtime_data_dir: &str, observability: Option<&Value>) -> Result<()> {
    let log_dir = PathBuf::from(runtime_data_dir).join("logs");
    fs::create_dir_all(&log_dir)
        .with_context(|| format!("Failed to create log directory: {}", log_dir.display()))?;
    cleanup_old_logs(&log_dir, Utc::now(), LOG_RETENTION_DAYS)?;

    let filter = tracing_subscriber::EnvFilter::try_from_default_env()
        .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
    let writer = HourlyLogWriter::new(log_dir, LOG_RETENTION_DAYS)?;
    let fmt_layer = tracing_subscriber::fmt::layer()
        .with_ansi(false)
        .with_writer(writer);
    let otel_layer = build_otel_log_layer(observability);
    if let Some(otel_layer) = otel_layer {
        tracing_subscriber::registry()
            .with(filter)
            .with(fmt_layer)
            .with(otel_layer)
            .init();
    } else {
        tracing_subscriber::registry()
            .with(filter)
            .with(fmt_layer)
            .init();
    }

    Ok(())
}

pub fn init_console_logging(observability: Option<&Value>) {
    let filter = tracing_subscriber::EnvFilter::try_from_default_env()
        .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
    let otel_layer = build_otel_log_layer(observability);

    #[cfg(feature = "journald")]
    {
        let use_journald = std::env::var("JOURNAL_STREAM").is_ok()
            || std::env::var("RUST_LOG_STYLE").is_ok_and(|v| v.eq_ignore_ascii_case("systemd"));

        if use_journald {
            if let Ok(journald_layer) = tracing_journald::layer() {
                if let Some(otel_layer) = otel_layer {
                    tracing_subscriber::registry()
                        .with(filter)
                        .with(journald_layer)
                        .with(otel_layer)
                        .init();
                } else {
                    tracing_subscriber::registry()
                        .with(filter)
                        .with(journald_layer)
                        .init();
                }
                return;
            }
        }
    }

    let fmt_layer = tracing_subscriber::fmt::layer();
    if let Some(otel_layer) = otel_layer {
        tracing_subscriber::registry()
            .with(filter)
            .with(fmt_layer)
            .with(otel_layer)
            .init();
    } else {
        tracing_subscriber::registry()
            .with(filter)
            .with(fmt_layer)
            .init();
    }
}

fn build_otel_log_layer(
    observability: Option<&Value>,
) -> Option<OpenTelemetryTracingBridge<SdkLoggerProvider, SdkLogger>> {
    let exporter = OtlpLogExporter::from_observability(observability)?;
    let exporter = OTEL_LOG_EXPORTER.get_or_init(|| exporter.clone());
    let provider = exporter.logger_provider();
    Some(OpenTelemetryTracingBridge::new(provider.as_ref()))
}

#[derive(Debug)]
struct HourlyState {
    current_hour_key: String,
    file: File,
}

#[derive(Clone, Debug)]
struct HourlyLogWriter {
    log_dir: PathBuf,
    retention_days: i64,
    state: Arc<Mutex<HourlyState>>,
}

impl HourlyLogWriter {
    fn new(log_dir: PathBuf, retention_days: i64) -> Result<Self> {
        let now = Utc::now();
        let hour_key = hour_key(now);
        let file = open_log_file(&log_dir, &hour_key)?;
        let state = HourlyState {
            current_hour_key: hour_key,
            file,
        };
        Ok(Self {
            log_dir,
            retention_days,
            state: Arc::new(Mutex::new(state)),
        })
    }
}

impl<'a> MakeWriter<'a> for HourlyLogWriter {
    type Writer = HourlyLogGuard;

    fn make_writer(&'a self) -> Self::Writer {
        HourlyLogGuard {
            log_dir: self.log_dir.clone(),
            retention_days: self.retention_days,
            state: self.state.clone(),
        }
    }
}

struct HourlyLogGuard {
    log_dir: PathBuf,
    retention_days: i64,
    state: Arc<Mutex<HourlyState>>,
}

impl Write for HourlyLogGuard {
    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
        let now = Utc::now();
        let now_key = hour_key(now);
        let mut state = self
            .state
            .lock()
            .map_err(|_| io::Error::other("failed to lock log writer"))?;

        if state.current_hour_key != now_key {
            state.file.flush()?;
            state.file = open_log_file(&self.log_dir, &now_key)?;
            state.current_hour_key = now_key;
            let _ = cleanup_old_logs(&self.log_dir, now, self.retention_days);
        }

        state.file.write(buf)
    }

    fn flush(&mut self) -> io::Result<()> {
        let mut state = self
            .state
            .lock()
            .map_err(|_| io::Error::other("failed to lock log writer"))?;
        state.file.flush()
    }
}

fn hour_key(now: DateTime<Utc>) -> String {
    now.format("%Y-%m-%d-%H").to_string()
}

fn log_file_path(log_dir: &Path, hour: &str) -> PathBuf {
    log_dir.join(format!("{LOG_FILE_PREFIX}{hour}{LOG_FILE_SUFFIX}"))
}

fn open_log_file(log_dir: &Path, hour: &str) -> io::Result<File> {
    OpenOptions::new()
        .create(true)
        .append(true)
        .open(log_file_path(log_dir, hour))
}

pub fn cleanup_old_logs(log_dir: &Path, now: DateTime<Utc>, retention_days: i64) -> Result<()> {
    let cutoff = now - Duration::days(retention_days);
    let entries = match fs::read_dir(log_dir) {
        Ok(v) => v,
        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
        Err(e) => return Err(e).with_context(|| format!("Failed to read {}", log_dir.display())),
    };

    for entry in entries {
        let entry = entry?;
        let path = entry.path();
        if !path.is_file() {
            continue;
        }
        let Some(file_name) = path.file_name().and_then(|s| s.to_str()) else {
            continue;
        };
        let Some(log_time) = parse_log_filename_time(file_name) else {
            continue;
        };
        if log_time < cutoff {
            let _ = fs::remove_file(&path);
        }
    }
    Ok(())
}

fn parse_log_filename_time(file_name: &str) -> Option<DateTime<Utc>> {
    if !(file_name.starts_with(LOG_FILE_PREFIX) && file_name.ends_with(LOG_FILE_SUFFIX)) {
        return None;
    }
    let body = &file_name[LOG_FILE_PREFIX.len()..file_name.len() - LOG_FILE_SUFFIX.len()];
    let naive =
        NaiveDateTime::parse_from_str(&format!("{body}:00:00"), "%Y-%m-%d-%H:%M:%S").ok()?;
    Some(DateTime::<Utc>::from_naive_utc_and_offset(naive, Utc))
}

pub fn list_log_files_sorted(log_dir: &Path) -> Result<Vec<PathBuf>> {
    let mut files = Vec::new();
    let entries = match fs::read_dir(log_dir) {
        Ok(v) => v,
        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(files),
        Err(e) => return Err(e).with_context(|| format!("Failed to read {}", log_dir.display())),
    };
    for entry in entries {
        let entry = entry?;
        let path = entry.path();
        let Some(name) = path.file_name().and_then(|v| v.to_str()) else {
            continue;
        };
        if parse_log_filename_time(name).is_some() {
            files.push(path);
        }
    }
    files.sort();
    Ok(files)
}

pub fn read_last_lines_from_logs(log_dir: &Path, max_lines: usize) -> Result<Vec<String>> {
    let mut queue: VecDeque<String> = VecDeque::new();
    for file in list_log_files_sorted(log_dir)? {
        let content = fs::read_to_string(&file)
            .with_context(|| format!("Failed to read {}", file.display()))?;
        for line in content.lines() {
            queue.push_back(line.to_string());
            if queue.len() > max_lines {
                queue.pop_front();
            }
        }
    }
    Ok(queue.into_iter().collect())
}

#[cfg(test)]
mod tests {
    use super::*;
    use uuid::Uuid;

    fn test_dir() -> PathBuf {
        std::env::temp_dir().join(format!("microclaw_logging_test_{}", Uuid::new_v4()))
    }

    #[test]
    fn test_parse_log_filename_time() {
        assert!(parse_log_filename_time("microclaw-2026-02-08-10.log").is_some());
        assert!(parse_log_filename_time("microclaw-2026-02-08.log").is_none());
        assert!(parse_log_filename_time("other-2026-02-08-10.log").is_none());
    }

    #[test]
    fn test_cleanup_old_logs_keeps_recent_removes_old() {
        let dir = test_dir();
        fs::create_dir_all(&dir).unwrap();
        fs::write(dir.join("microclaw-2025-01-01-00.log"), "old").unwrap();
        fs::write(dir.join("microclaw-2026-02-08-10.log"), "new").unwrap();

        let now = DateTime::parse_from_rfc3339("2026-02-08T11:00:00Z")
            .unwrap()
            .with_timezone(&Utc);
        cleanup_old_logs(&dir, now, 30).unwrap();

        assert!(!dir.join("microclaw-2025-01-01-00.log").exists());
        assert!(dir.join("microclaw-2026-02-08-10.log").exists());
        let _ = fs::remove_dir_all(&dir);
    }

    #[test]
    fn test_read_last_lines_from_logs() {
        let dir = test_dir();
        fs::create_dir_all(&dir).unwrap();
        fs::write(dir.join("microclaw-2026-02-08-09.log"), "a\nb\n").unwrap();
        fs::write(dir.join("microclaw-2026-02-08-10.log"), "c\nd\n").unwrap();

        let lines = read_last_lines_from_logs(&dir, 3).unwrap();
        assert_eq!(lines, vec!["b", "c", "d"]);
        let _ = fs::remove_dir_all(&dir);
    }
}