use chrono::Utc;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
static LOG_DIR: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
const REDACTED_ENV: &[&str] = &[
"OPENAI_API_KEY",
"OPENROUTER_API_KEY",
"LLAMACPP_API_KEY",
"ANTHROPIC_API_KEY",
];
fn dir_lock() -> &'static Mutex<Option<PathBuf>> {
LOG_DIR.get_or_init(|| Mutex::new(None))
}
pub fn install_panic_hook() {
static INSTALLED: OnceLock<()> = OnceLock::new();
INSTALLED.get_or_init(|| {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
prev(info);
let dir = dir_lock().lock().ok().and_then(|g| g.clone());
if let Some(dir) = dir {
let _ = write_crash_report(&dir, info);
}
}));
});
}
pub fn set_crash_dir(session_dir: &str) {
let path = Path::new(session_dir).join("crashes");
if let Ok(mut guard) = dir_lock().lock() {
*guard = Some(path);
}
}
fn write_crash_report(
dir: &Path,
info: &std::panic::PanicHookInfo<'_>,
) -> std::io::Result<PathBuf> {
std::fs::create_dir_all(dir)?;
let ts = Utc::now().format("%Y-%m-%dT%H-%M-%S-%3fZ");
let path = dir.join(format!("{ts}.log"));
let mut body = String::new();
body.push_str(&format!(
"albatross {} crash report\n",
env!("CARGO_PKG_VERSION")
));
body.push_str(&format!("timestamp: {}\n", Utc::now().to_rfc3339()));
body.push_str(&format!(
"os: {} {}\n",
std::env::consts::OS,
std::env::consts::ARCH
));
body.push_str("\n--- panic ---\n");
if let Some(location) = info.location() {
body.push_str(&format!(
"location: {}:{}:{}\n",
location.file(),
location.line(),
location.column()
));
}
let payload = info.payload();
let msg = if let Some(s) = payload.downcast_ref::<&str>() {
(*s).to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
"<non-string payload>".into()
};
body.push_str(&format!("message: {msg}\n"));
body.push_str("\n--- backtrace ---\n");
let bt = std::backtrace::Backtrace::capture();
body.push_str(&bt.to_string());
if matches!(bt.status(), std::backtrace::BacktraceStatus::Disabled) {
body.push_str("\n(set RUST_BACKTRACE=1 to capture frames)\n");
}
body.push_str("\n--- env (sensitive values redacted) ---\n");
let mut env_keys: Vec<_> = std::env::vars()
.filter(|(k, _)| {
k.starts_with("ALBATROSS_")
|| k.starts_with("AGENT_")
|| k.starts_with("BACKEND")
|| k.starts_with("APPROVAL_")
|| k.starts_with("WARMUP")
|| k.starts_with("WORKSPACE_")
|| k.starts_with("OUTSIDE_")
|| REDACTED_ENV.contains(&k.as_str())
|| k.ends_with("_BASE_URL")
|| k.ends_with("_API_KEY")
})
.collect();
env_keys.sort();
for (k, v) in env_keys {
let value = if REDACTED_ENV.contains(&k.as_str()) || k.ends_with("_API_KEY") {
if v.is_empty() {
"(empty)".into()
} else {
"(redacted)".into()
}
} else {
v
};
body.push_str(&format!("{k}={value}\n"));
}
std::fs::write(&path, body)?;
eprintln!(
"\nalbatross wrote a crash log to {} — attach it when filing an issue.",
path.display()
);
Ok(path)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static SERIAL: Mutex<()> = Mutex::new(());
#[test]
fn writes_report_with_panic_message_and_redaction() {
let _guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
std::env::set_var("OPENAI_API_KEY", "sk-not-real-zzzzzzzz");
std::env::set_var("ALBATROSS_TEST_FLAG", "visible-value");
let dir_path = dir.path().to_path_buf();
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = write_crash_report(&dir_path.join("crashes"), info);
}));
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
panic!("intentional test panic");
}));
std::panic::set_hook(prev);
assert!(result.is_err());
let logs: Vec<_> = std::fs::read_dir(dir.path().join("crashes"))
.unwrap()
.map(|e| e.unwrap().path())
.collect();
assert_eq!(logs.len(), 1);
let body = std::fs::read_to_string(&logs[0]).unwrap();
assert!(body.contains("intentional test panic"));
assert!(body.contains("OPENAI_API_KEY=(redacted)"));
assert!(body.contains("ALBATROSS_TEST_FLAG=visible-value"));
assert!(!body.contains("sk-not-real-zzzzzzzz"));
std::env::remove_var("OPENAI_API_KEY");
std::env::remove_var("ALBATROSS_TEST_FLAG");
}
#[test]
fn redacts_empty_api_key_as_empty_not_redacted() {
let _guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
std::env::set_var("OPENROUTER_API_KEY", "");
let dir_path = dir.path().to_path_buf();
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = write_crash_report(&dir_path.join("crashes"), info);
}));
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
panic!("test");
}));
std::panic::set_hook(prev);
let logs: Vec<_> = std::fs::read_dir(dir.path().join("crashes"))
.unwrap()
.map(|e| e.unwrap().path())
.collect();
let body = std::fs::read_to_string(&logs[0]).unwrap();
assert!(body.contains("OPENROUTER_API_KEY=(empty)"));
std::env::remove_var("OPENROUTER_API_KEY");
}
}