use std::path::Path;
use std::sync::Mutex;
use serde::Serialize;
use crate::RuntimeResult;
use crate::logging::RotatingLog;
pub const EXEC_LOG_FILENAME: &str = "exec.log";
const EXEC_LOG_MAX_BYTES: u64 = 10 * 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum LogSource {
Stdout,
Stderr,
Output,
System,
}
#[derive(Debug, Serialize)]
struct ExecLogEntry<'a> {
t: &'a str,
s: LogSource,
d: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
e: Option<&'static str>,
}
pub struct LogWriter {
inner: Mutex<RotatingLog>,
}
impl LogWriter {
pub fn open(log_dir: &Path) -> RuntimeResult<Self> {
let inner = RotatingLog::new(log_dir, "exec", EXEC_LOG_MAX_BYTES)?;
Ok(Self {
inner: Mutex::new(inner),
})
}
pub fn write_chunk(&self, source: LogSource, session_id: u64, data: &[u8]) {
let decoded = String::from_utf8_lossy(data);
self.write_entry(source, Some(session_id), &decoded);
}
pub fn write_system(&self, message: &str) {
let body = if message.ends_with('\n') {
message.to_string()
} else {
format!("{message}\n")
};
self.write_entry(LogSource::System, None, &body);
}
fn write_entry(&self, source: LogSource, session_id: Option<u64>, data: &str) {
let entry = ExecLogEntry {
t: &now_rfc3339(),
s: source,
d: data,
id: session_id,
e: None,
};
let mut line = match serde_json::to_vec(&entry) {
Ok(v) => v,
Err(err) => {
tracing::warn!(
error = %err,
"exec_log: failed to serialize entry, dropping"
);
return;
}
};
line.push(b'\n');
if let Ok(mut guard) = self.inner.lock()
&& let Err(err) = guard.write(&line)
{
tracing::warn!(error = %err, "exec_log: write failed");
}
}
}
fn now_rfc3339() -> String {
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(serde::Deserialize)]
struct Entry {
t: String,
s: String,
d: String,
#[serde(default)]
id: Option<u64>,
}
fn read_entries(dir: &Path) -> Vec<Entry> {
let path = dir.join(EXEC_LOG_FILENAME);
let bytes = std::fs::read(&path).unwrap_or_default();
std::str::from_utf8(&bytes)
.unwrap()
.lines()
.filter(|l| !l.is_empty())
.map(|l| serde_json::from_str(l).expect("valid json line"))
.collect()
}
#[test]
fn writes_stdout_and_stderr_as_jsonl() {
let dir = tempfile::tempdir().unwrap();
let writer = LogWriter::open(dir.path()).unwrap();
writer.write_chunk(LogSource::Stdout, 7, b"hello\n");
writer.write_chunk(LogSource::Stderr, 7, b"oops\n");
let entries = read_entries(dir.path());
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].s, "stdout");
assert_eq!(entries[0].d, "hello\n");
assert_eq!(entries[0].id, Some(7));
assert_eq!(entries[1].s, "stderr");
assert_eq!(entries[1].d, "oops\n");
assert_eq!(entries[1].id, Some(7));
assert!(entries[0].t.ends_with('Z'));
}
#[test]
fn distinct_sessions_get_distinct_ids() {
let dir = tempfile::tempdir().unwrap();
let writer = LogWriter::open(dir.path()).unwrap();
writer.write_chunk(LogSource::Stdout, 1, b"a\n");
writer.write_chunk(LogSource::Stdout, 42, b"b\n");
let entries = read_entries(dir.path());
assert_eq!(entries[0].id, Some(1));
assert_eq!(entries[1].id, Some(42));
}
#[test]
fn write_system_appends_newline_when_missing() {
let dir = tempfile::tempdir().unwrap();
let writer = LogWriter::open(dir.path()).unwrap();
writer.write_system("--- sandbox started ---");
let entries = read_entries(dir.path());
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].s, "system");
assert_eq!(entries[0].d, "--- sandbox started ---\n");
assert_eq!(entries[0].id, None);
}
#[test]
fn non_utf8_bytes_use_replacement_char() {
let dir = tempfile::tempdir().unwrap();
let writer = LogWriter::open(dir.path()).unwrap();
writer.write_chunk(LogSource::Stdout, 1, &[0xff, 0xfe, b'h', b'i']);
let entries = read_entries(dir.path());
assert_eq!(entries.len(), 1);
assert!(entries[0].d.contains('\u{FFFD}'));
assert!(entries[0].d.contains("hi"));
}
#[test]
fn second_open_appends() {
let dir = tempfile::tempdir().unwrap();
{
let writer = LogWriter::open(dir.path()).unwrap();
writer.write_chunk(LogSource::Stdout, 1, b"line1\n");
}
{
let writer = LogWriter::open(dir.path()).unwrap();
writer.write_chunk(LogSource::Stdout, 1, b"line2\n");
}
let entries = read_entries(dir.path());
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].d, "line1\n");
assert_eq!(entries[1].d, "line2\n");
}
}