use std::fs::OpenOptions;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use thiserror::Error;
use tracing_subscriber::fmt::writer::BoxMakeWriter;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::EnvFilter;
use crate::secrets::looks_like_secret;
#[derive(Debug, Error)]
pub enum LogInitError {
#[error("failed to open log file at {path}: {source}")]
OpenLog {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("invalid log level {0:?}")]
InvalidLevel(String),
#[error("global tracing subscriber already installed")]
AlreadyInstalled,
}
#[derive(Debug, Clone)]
pub struct LogConfig {
pub log_dir: PathBuf,
pub level: String,
pub file_name: String,
}
impl LogConfig {
pub fn new(log_dir: impl Into<PathBuf>, level: impl Into<String>) -> Self {
Self { log_dir: log_dir.into(), level: level.into(), file_name: "agent.jsonl".to_string() }
}
}
pub fn init(cfg: &LogConfig) -> Result<(), LogInitError> {
install(cfg, std::io::stderr)
}
#[tracing::instrument(skip_all, fields(level = %cfg.level, log_dir = %cfg.log_dir.display()))]
pub fn install<W>(cfg: &LogConfig, human_writer: W) -> Result<(), LogInitError>
where
W: for<'a> tracing_subscriber::fmt::MakeWriter<'a> + Send + Sync + 'static,
{
let env_filter = EnvFilter::try_new(&cfg.level)
.map_err(|_| LogInitError::InvalidLevel(cfg.level.clone()))?;
std::fs::create_dir_all(&cfg.log_dir)
.map_err(|source| LogInitError::OpenLog { path: cfg.log_dir.clone(), source })?;
let log_path = cfg.log_dir.join(&cfg.file_name);
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.map_err(|source| LogInitError::OpenLog { path: log_path.clone(), source })?;
let file = Arc::new(file);
let json_writer = BoxMakeWriter::new(move || -> Box<dyn io::Write + Send> {
Box::new(RedactingWriter::new(FileHandle(Arc::clone(&file))))
});
let json_layer = tracing_subscriber::fmt::layer()
.json()
.with_current_span(true)
.with_span_list(true)
.with_target(true)
.with_writer(json_writer);
let human_writer = RedactingMakeWriter::new(human_writer);
let human_layer = tracing_subscriber::fmt::layer().with_target(false).with_writer(human_writer);
tracing_subscriber::registry()
.with(env_filter)
.with(json_layer)
.with(human_layer)
.try_init()
.map_err(|_| LogInitError::AlreadyInstalled)
}
pub fn json_log_path(log_dir: &Path) -> PathBuf {
log_dir.join("agent.jsonl")
}
struct FileHandle(Arc<std::fs::File>);
impl io::Write for FileHandle {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
(&*self.0).write(buf)
}
fn flush(&mut self) -> io::Result<()> {
(&*self.0).flush()
}
}
const REDACTED: &[u8] = b"<redacted>";
pub(crate) struct RedactingWriter<W: io::Write> {
inner: W,
buf: Vec<u8>,
}
impl<W: io::Write> RedactingWriter<W> {
pub(crate) fn new(inner: W) -> Self {
Self { inner, buf: Vec::with_capacity(512) }
}
}
impl<W: io::Write> io::Write for RedactingWriter<W> {
fn write(&mut self, data: &[u8]) -> io::Result<usize> {
self.buf.extend_from_slice(data);
while let Some(idx) = self.buf.iter().position(|b| *b == b'\n') {
let line: Vec<u8> = self.buf.drain(..=idx).collect();
let scrubbed = redact_line(&line);
self.inner.write_all(&scrubbed)?;
}
Ok(data.len())
}
fn flush(&mut self) -> io::Result<()> {
if !self.buf.is_empty() {
let scrubbed = redact_line(&self.buf);
self.inner.write_all(&scrubbed)?;
self.buf.clear();
}
self.inner.flush()
}
}
impl<W: io::Write> Drop for RedactingWriter<W> {
fn drop(&mut self) {
let _ = self.flush();
}
}
fn redact_line(line: &[u8]) -> Vec<u8> {
let Ok(s) = std::str::from_utf8(line) else {
return line.to_vec();
};
let mut out = String::with_capacity(s.len());
let mut start = 0;
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if is_token_char(c) {
let mut end = i + 1;
while end < bytes.len() && is_token_char(bytes[end]) {
end += 1;
}
let candidate = &s[i..end];
if looks_like_secret(candidate) {
out.push_str(&s[start..i]);
out.push_str(std::str::from_utf8(REDACTED).unwrap());
start = end;
}
i = end;
} else {
i += 1;
}
}
out.push_str(&s[start..]);
out.into_bytes()
}
fn is_token_char(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'-' || b == b'_'
}
#[derive(Clone)]
struct RedactingMakeWriter<W> {
inner: W,
}
impl<W> RedactingMakeWriter<W> {
fn new(inner: W) -> Self {
Self { inner }
}
}
impl<'a, W> tracing_subscriber::fmt::MakeWriter<'a> for RedactingMakeWriter<W>
where
W: tracing_subscriber::fmt::MakeWriter<'a> + 'static,
{
type Writer = RedactingWriter<W::Writer>;
fn make_writer(&'a self) -> Self::Writer {
RedactingWriter::new(self.inner.make_writer())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn json_log_path_appends_filename() {
assert_eq!(
json_log_path(Path::new("/var/state/logs")),
PathBuf::from("/var/state/logs/agent.jsonl")
);
}
#[test]
fn redact_line_replaces_anthropic_key_shape() {
let scrubbed = redact_line(b"calling api with token=sk-ant-api03-aaaaabbbbbcccccddddd\n");
let out = String::from_utf8(scrubbed).unwrap();
assert!(!out.contains("sk-ant"), "redacted output still contains secret: {out:?}");
assert!(out.contains("<redacted>"));
}
#[test]
fn redact_line_leaves_non_secret_text_intact() {
let input = b"GET /api/v1/health status=200\n";
let scrubbed = redact_line(input);
assert_eq!(scrubbed, input);
}
#[test]
fn install_writes_json_file_and_is_idempotent_in_failure() {
let tmp = tempfile::tempdir().expect("tempdir");
let cfg = LogConfig::new(tmp.path(), "info");
let first = install(&cfg, std::io::sink);
let second = install(&cfg, std::io::sink);
assert!(first.is_ok() || matches!(first, Err(LogInitError::AlreadyInstalled)));
assert!(matches!(second, Err(LogInitError::AlreadyInstalled)));
let log_path = json_log_path(tmp.path());
assert!(log_path.exists(), "{} should exist", log_path.display());
}
}