use std::{
collections::VecDeque,
fs::{self, File, OpenOptions},
io::{self, Write},
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
sync::{Arc, Mutex},
time::SystemTime,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
static PROVIDER_HTTP_TRACE_SEQ: AtomicU64 = AtomicU64::new(1);
const PROVIDER_HTTP_TRACE_ENV: &str = "HOLON_PROVIDER_HTTP_TRACE";
const PROVIDER_HTTP_FAILURE_TRACE_ENV: &str = "HOLON_PROVIDER_HTTP_FAILURE_TRACE";
const PROVIDER_HTTP_FAILURE_TRACE_MAX_EVENTS: usize = 128;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProviderHttpTraceDiagnostics {
pub mode: String,
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<u16>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ProviderHttpTraceMode {
All,
FailureOnly,
}
impl ProviderHttpTraceMode {
fn as_str(self) -> &'static str {
match self {
Self::All => "all",
Self::FailureOnly => "failure_only",
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct ProviderHttpTrace {
home_dir: PathBuf,
mode: ProviderHttpTraceMode,
}
#[derive(Clone, Debug)]
pub(crate) struct ProviderHttpTraceRequest {
inner: Arc<Mutex<ProviderHttpTraceRequestState>>,
}
#[derive(Debug)]
struct ProviderHttpTraceRequestState {
home_dir: PathBuf,
mode: ProviderHttpTraceMode,
agent_id: String,
sequence: u64,
file_path: Option<PathBuf>,
events: VecDeque<Value>,
}
impl ProviderHttpTrace {
pub(crate) fn from_env(home_dir: impl Into<PathBuf>) -> Option<Self> {
let mode = if std::env::var(PROVIDER_HTTP_TRACE_ENV).ok().as_deref() == Some("1") {
ProviderHttpTraceMode::All
} else if std::env::var(PROVIDER_HTTP_FAILURE_TRACE_ENV)
.ok()
.as_deref()
== Some("1")
{
ProviderHttpTraceMode::FailureOnly
} else {
return None;
};
Some(Self {
home_dir: home_dir.into(),
mode,
})
}
pub(crate) fn begin_request(
&self,
agent_id: Option<&str>,
provider: &str,
model_ref: Option<&str>,
url: &str,
endpoint_kind: &str,
headers: &[(&str, String)],
body: &Value,
) -> Option<ProviderHttpTraceRequest> {
let agent_id = agent_id
.filter(|value| !value.trim().is_empty())
.map(ToString::to_string)
.or_else(|| {
std::env::var("HOLON_AGENT_ID")
.ok()
.filter(|value| !value.trim().is_empty())
})
.unwrap_or_else(|| "unknown-agent".into());
let sequence = PROVIDER_HTTP_TRACE_SEQ.fetch_add(1, Ordering::Relaxed);
let request = ProviderHttpTraceRequest {
inner: Arc::new(Mutex::new(ProviderHttpTraceRequestState {
home_dir: self.home_dir.clone(),
mode: self.mode,
agent_id,
sequence,
file_path: None,
events: VecDeque::new(),
})),
};
request.write_event(json!({
"type": "request",
"created_at_ms": current_time_millis(),
"sequence": sequence,
"provider": provider,
"model_ref": model_ref,
"endpoint_kind": endpoint_kind,
"url": redact_url(url),
"headers": redact_headers(headers),
"body": redact_json_secrets(body),
}));
Some(request)
}
}
impl ProviderHttpTraceRequest {
pub(crate) fn write_response_headers(
&self,
status: reqwest::StatusCode,
headers: &reqwest::header::HeaderMap,
) {
self.write_event(json!({
"type": "response_headers",
"created_at_ms": current_time_millis(),
"status": status.as_u16(),
"headers": redact_header_map(headers),
}));
}
pub(crate) fn write_response_body(&self, body: &str) {
self.write_event(json!({
"type": "response_body",
"created_at_ms": current_time_millis(),
"bytes": body.len(),
"body": body,
}));
}
pub(crate) fn write_stream_chunk(&self, chunk: &[u8]) {
self.write_event(json!({
"type": "stream_chunk",
"created_at_ms": current_time_millis(),
"bytes": chunk.len(),
"text": String::from_utf8_lossy(chunk),
}));
}
pub(crate) fn write_stream_terminal(&self, body: &Value) {
self.write_event(json!({
"type": "stream_terminal_response",
"created_at_ms": current_time_millis(),
"body": body,
}));
}
pub(crate) fn diagnostics(&self, status: Option<u16>) -> Option<ProviderHttpTraceDiagnostics> {
let mut guard = self.inner.lock().ok()?;
let path = ensure_trace_file(&mut guard)?;
Some(ProviderHttpTraceDiagnostics {
mode: guard.mode.as_str().to_string(),
path: path.to_string_lossy().to_string(),
status,
})
}
fn write_event(&self, event: Value) {
let Ok(mut guard) = self.inner.lock() else {
return;
};
match guard.mode {
ProviderHttpTraceMode::All => {
let Some(path) = ensure_trace_file(&mut guard) else {
return;
};
append_trace_event(&path, &event);
}
ProviderHttpTraceMode::FailureOnly => {
guard.events.push_back(event);
if guard.events.len() > PROVIDER_HTTP_FAILURE_TRACE_MAX_EVENTS {
guard.events.pop_front();
}
}
}
}
}
fn ensure_trace_file(state: &mut ProviderHttpTraceRequestState) -> Option<PathBuf> {
if let Some(path) = &state.file_path {
return Some(path.clone());
}
let trace_dir = state
.home_dir
.join(".holon")
.join("http-trace")
.join(sanitize_trace_path_segment(&state.agent_id));
fs::create_dir_all(&trace_dir).ok()?;
let created_at_ms = current_time_millis();
let path = trace_dir.join(format!(
"trace-{created_at_ms}-{sequence}.jsonl",
sequence = state.sequence
));
write_trace_events(&path, &state.events).ok()?;
state.events.clear();
state.file_path = Some(path.clone());
Some(path)
}
fn write_trace_events(path: &Path, events: &VecDeque<Value>) -> io::Result<()> {
let mut file = File::create(path)?;
for event in events {
let line = serde_json::to_string(event).map_err(io::Error::other)?;
writeln!(file, "{line}")?;
}
Ok(())
}
fn append_trace_event(path: &Path, event: &Value) {
let Ok(line) = serde_json::to_string(event) else {
return;
};
let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) else {
return;
};
let _ = writeln!(file, "{line}");
}
pub(crate) fn sanitize_trace_path_segment(value: &str) -> String {
value
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
ch
} else {
'_'
}
})
.collect()
}
pub(crate) fn redact_headers(headers: &[(&str, String)]) -> Value {
Value::Array(
headers
.iter()
.map(|(name, value)| {
json!({
"name": *name,
"value": redact_header_value(name, value),
})
})
.collect(),
)
}
fn redact_header_map(headers: &reqwest::header::HeaderMap) -> Value {
Value::Array(
headers
.iter()
.map(|(name, value)| {
let value = value.to_str().unwrap_or("<non-utf8>");
json!({
"name": name.as_str(),
"value": redact_header_value(name.as_str(), value),
})
})
.collect(),
)
}
fn redact_header_value(name: &str, value: &str) -> String {
let lowered = name.to_ascii_lowercase();
if matches!(
lowered.as_str(),
"authorization" | "proxy-authorization" | "cookie" | "set-cookie" | "x-api-key"
) {
"[REDACTED]".into()
} else {
value.into()
}
}
pub(crate) fn redact_url(raw: &str) -> String {
let Ok(mut url) = reqwest::Url::parse(raw) else {
return raw.into();
};
if url.password().is_some() {
let _ = url.set_password(Some("[REDACTED]"));
}
if !url.username().is_empty() {
let _ = url.set_username("[REDACTED]");
}
let redacted_pairs = url
.query_pairs()
.map(|(key, value)| {
let lowered = key.to_ascii_lowercase();
let value = if lowered.contains("key")
|| lowered.contains("token")
|| lowered.contains("secret")
{
"[REDACTED]".into()
} else {
value.into_owned()
};
(key.into_owned(), value)
})
.collect::<Vec<_>>();
if !redacted_pairs.is_empty() {
url.query_pairs_mut().clear().extend_pairs(redacted_pairs);
}
url.to_string()
}
pub(crate) fn redact_json_secrets(value: &Value) -> Value {
match value {
Value::Object(map) => Value::Object(
map.iter()
.map(|(key, value)| {
let lowered = key.to_ascii_lowercase();
let value = if is_secret_json_key(&lowered) {
Value::String("[REDACTED]".into())
} else {
redact_json_secrets(value)
};
(key.clone(), value)
})
.collect(),
),
Value::Array(items) => Value::Array(items.iter().map(redact_json_secrets).collect()),
other => other.clone(),
}
}
fn is_secret_json_key(lowered: &str) -> bool {
lowered.contains("api_key")
|| lowered.contains("apikey")
|| lowered.contains("secret")
|| lowered == "authorization"
|| lowered == "token"
|| lowered.ends_with("_token")
|| lowered.starts_with("token_")
|| lowered.contains("_token_")
}
fn current_time_millis() -> u128 {
SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.map(|duration| duration.as_millis())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::{
redact_headers, redact_json_secrets, redact_url, sanitize_trace_path_segment,
ProviderHttpTrace,
};
use serde_json::{json, Value};
use std::fs;
#[test]
fn provider_http_trace_redacts_secrets() {
let headers = redact_headers(&[
("authorization", "Bearer secret".into()),
("openai-beta", "responses=experimental".into()),
]);
assert_eq!(headers[0]["value"], "[REDACTED]");
assert_eq!(headers[1]["value"], "responses=experimental");
assert_eq!(
redact_url("https://user:pass@example.com/v1/responses?api_key=secret&debug=1"),
"https://%5BREDACTED%5D:%5BREDACTED%5D@example.com/v1/responses?api_key=%5BREDACTED%5D&debug=1"
);
let body = redact_json_secrets(&json!({
"model": "gpt-test",
"max_output_tokens": 4096,
"access_token": "secret",
"nested": {
"api_key": "secret",
"prompt_tokens": 123,
"reasoning_tokens": 45
}
}));
assert_eq!(body["access_token"], "[REDACTED]");
assert_eq!(body["nested"]["api_key"], "[REDACTED]");
assert_eq!(body["max_output_tokens"], 4096);
assert_eq!(body["nested"]["prompt_tokens"], 123);
assert_eq!(body["nested"]["reasoning_tokens"], 45);
assert_eq!(body["model"], "gpt-test");
}
#[test]
fn provider_http_trace_sanitizes_agent_path_segment() {
assert_eq!(
sanitize_trace_path_segment("agent/with spaces"),
"agent_with_spaces"
);
}
#[test]
fn provider_http_trace_writes_full_request_body_under_home() {
let home = tempfile::tempdir().unwrap();
let trace = ProviderHttpTrace {
home_dir: home.path().to_path_buf(),
mode: super::ProviderHttpTraceMode::All,
};
let body = json!({
"model": "gpt-test",
"input": [{ "type": "message", "content": "hello" }],
"tools": [{ "type": "function", "name": "ApplyPatch" }]
});
trace
.begin_request(
Some("agent/one"),
"openai",
Some("openai/gpt-test"),
"https://api.openai.com/v1/responses",
"responses",
&[("authorization", "Bearer secret".into())],
&body,
)
.expect("trace should be written");
let trace_dir = home.path().join(".holon/http-trace/agent_one");
let entries = fs::read_dir(trace_dir)
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert_eq!(entries.len(), 1);
let line = fs::read_to_string(entries[0].path()).unwrap();
let event: Value = serde_json::from_str(line.trim()).unwrap();
assert_eq!(event["type"], "request");
assert_eq!(
event["body"]["tools"][0]["name"],
Value::String("ApplyPatch".into())
);
assert_eq!(event["headers"][0]["value"], "[REDACTED]");
}
#[test]
fn provider_http_failure_trace_writes_only_when_diagnostics_are_requested() {
let home = tempfile::tempdir().unwrap();
let trace = ProviderHttpTrace {
home_dir: home.path().to_path_buf(),
mode: super::ProviderHttpTraceMode::FailureOnly,
};
let request = trace
.begin_request(
Some("agent/one"),
"anthropic",
Some("anthropic/claude"),
"https://api.anthropic.com/v1/messages",
"messages",
&[("authorization", "Bearer secret".into())],
&json!({ "model": "claude", "messages": [] }),
)
.expect("trace should be created");
let trace_dir = home.path().join(".holon/http-trace/agent_one");
assert!(!trace_dir.exists());
let diagnostics = request
.diagnostics(Some(400))
.expect("failure diagnostics should write trace");
assert_eq!(diagnostics.mode, "failure_only");
assert!(std::path::Path::new(&diagnostics.path).exists());
}
#[test]
fn provider_http_failure_trace_keeps_buffer_when_trace_file_cannot_be_created() {
let home = tempfile::tempdir().unwrap();
let home_file = home.path().join("not-a-directory");
fs::write(&home_file, "occupied").unwrap();
let trace = ProviderHttpTrace {
home_dir: home_file,
mode: super::ProviderHttpTraceMode::FailureOnly,
};
let request = trace
.begin_request(
Some("agent/one"),
"anthropic",
Some("anthropic/claude"),
"https://api.anthropic.com/v1/messages",
"messages",
&[("authorization", "Bearer secret".into())],
&json!({ "model": "claude", "messages": [] }),
)
.expect("trace should be created");
assert!(request.diagnostics(Some(500)).is_none());
let state = request.inner.lock().unwrap();
assert_eq!(state.events.len(), 1);
assert!(state.file_path.is_none());
}
#[test]
fn provider_http_failure_trace_keeps_only_the_recent_event_window() {
let home = tempfile::tempdir().unwrap();
let trace = ProviderHttpTrace {
home_dir: home.path().to_path_buf(),
mode: super::ProviderHttpTraceMode::FailureOnly,
};
let request = trace
.begin_request(
Some("agent/one"),
"anthropic",
Some("anthropic/claude"),
"https://api.anthropic.com/v1/messages",
"messages",
&[("authorization", "Bearer secret".into())],
&json!({ "model": "claude", "messages": [] }),
)
.expect("trace should be created");
for index in 0..super::PROVIDER_HTTP_FAILURE_TRACE_MAX_EVENTS + 10 {
request.write_stream_terminal(&json!({ "index": index }));
}
let state = request.inner.lock().unwrap();
assert_eq!(
state.events.len(),
super::PROVIDER_HTTP_FAILURE_TRACE_MAX_EVENTS
);
assert_eq!(state.events.front().unwrap()["body"]["index"], 10);
}
}