Skip to main content

claude_codex/
logging.rs

1use crate::{config, paths};
2use serde_json::Value;
3use std::collections::HashSet;
4use std::fs::{self, OpenOptions};
5use std::io::{self, Write};
6use std::path::Path;
7use std::sync::atomic::{AtomicUsize, Ordering};
8use std::time::{SystemTime, UNIX_EPOCH};
9
10pub const MAX_LOG_BYTES: u64 = 20 * 1024 * 1024;
11
12static STDERR_SUPPRESSION_DEPTH: AtomicUsize = AtomicUsize::new(0);
13
14pub const REDACT_KEYS: [&str; 15] = [
15    "authorization",
16    "proxy-authorization",
17    "access",
18    "access_token",
19    "refresh",
20    "refresh_token",
21    "id_token",
22    "code",
23    "code_verifier",
24    "chatgpt-account-id",
25    "cookie",
26    "set-cookie",
27    "x-api-key",
28    "apikey",
29    "api_key",
30];
31
32pub fn log_file() -> std::path::PathBuf {
33    paths::log_file()
34}
35
36#[must_use]
37pub struct StderrSuppressionGuard;
38
39impl Drop for StderrSuppressionGuard {
40    fn drop(&mut self) {
41        STDERR_SUPPRESSION_DEPTH.fetch_sub(1, Ordering::Relaxed);
42    }
43}
44
45pub fn suppress_stderr() -> StderrSuppressionGuard {
46    STDERR_SUPPRESSION_DEPTH.fetch_add(1, Ordering::Relaxed);
47    StderrSuppressionGuard
48}
49
50fn stderr_suppressed() -> bool {
51    STDERR_SUPPRESSION_DEPTH.load(Ordering::Relaxed) > 0
52}
53
54fn should_mirror_to_stderr(level: &str) -> bool {
55    !stderr_suppressed() && (matches!(level, "warn" | "error") || config::log_stderr())
56}
57
58#[derive(Clone)]
59pub struct Logger {
60    service: String,
61    base: serde_json::Map<String, Value>,
62}
63
64impl Logger {
65    pub fn child(&self, bindings: serde_json::Map<String, Value>) -> Logger {
66        let mut merged = self.base.clone();
67        merged.extend(bindings);
68        Logger {
69            service: self.service.clone(),
70            base: merged,
71        }
72    }
73
74    pub fn debug(&self, msg: &str, fields: Option<serde_json::Map<String, Value>>) {
75        self.emit("debug", msg, fields)
76    }
77
78    pub fn info(&self, msg: &str, fields: Option<serde_json::Map<String, Value>>) {
79        self.emit("info", msg, fields)
80    }
81
82    pub fn warn(&self, msg: &str, fields: Option<serde_json::Map<String, Value>>) {
83        self.emit("warn", msg, fields)
84    }
85
86    pub fn error(&self, msg: &str, fields: Option<serde_json::Map<String, Value>>) {
87        self.emit("error", msg, fields)
88    }
89
90    fn emit(&self, level: &str, msg: &str, fields: Option<serde_json::Map<String, Value>>) {
91        let mut body = serde_json::Map::new();
92        body.insert("t".into(), Value::String(now_iso8601()));
93        body.insert("level".into(), Value::String(level.to_string()));
94        body.insert("service".into(), Value::String(self.service.clone()));
95        body.insert("msg".into(), Value::String(msg.to_string()));
96
97        let mut merged = self.base.clone();
98        if let Some(fields) = fields {
99            merged.extend(fields);
100        }
101        if !merged.is_empty() {
102            body.insert("fields".into(), redact_value(Value::Object(merged)));
103        }
104
105        let line = Value::Object(body).to_string();
106
107        let mirror_to_stderr = should_mirror_to_stderr(level);
108        if mirror_to_stderr {
109            let _ = writeln!(io::stderr(), "{line}");
110        }
111
112        if write_log_line(&line).is_err() && mirror_to_stderr {
113            // swallow logging errors intentionally
114        }
115    }
116}
117
118pub fn create_logger(service: &str) -> Logger {
119    Logger {
120        service: service.to_string(),
121        base: serde_json::Map::new(),
122    }
123}
124
125fn write_log_line(line: &str) -> io::Result<()> {
126    let file = log_file();
127    if let Some(dir) = file.parent() {
128        create_dir(dir, 0o700)?;
129    }
130
131    if fs::metadata(&file).is_ok_and(|meta| meta.len() > MAX_LOG_BYTES) {
132        rotate_file(&file)?;
133    }
134
135    let mut out = OpenOptions::new().create(true).append(true).open(&file)?;
136    out.write_all(line.as_bytes())?;
137    out.write_all(b"\n")?;
138    Ok(())
139}
140
141fn rotate_file(path: &Path) -> io::Result<()> {
142    let ts = SystemTime::now()
143        .duration_since(UNIX_EPOCH)
144        .unwrap_or_default()
145        .as_millis();
146    let rotated = path.with_extension(format!("{ts}"));
147    fs::rename(path, rotated)?;
148    Ok(())
149}
150
151fn create_dir(path: &Path, mode: u32) -> io::Result<()> {
152    fs::create_dir_all(path)?;
153    set_mode(path, mode);
154    Ok(())
155}
156
157fn set_mode(path: &Path, mode: u32) {
158    #[cfg(unix)]
159    {
160        use std::os::unix::fs::PermissionsExt;
161        if let Ok(meta) = fs::metadata(path) {
162            let mut perm = meta.permissions();
163            perm.set_mode(mode);
164            let _ = fs::set_permissions(path, perm);
165        }
166    }
167}
168
169fn now_iso8601() -> String {
170    let now = time::OffsetDateTime::now_utc();
171    let format = time::format_description::parse_borrowed::<3>(
172        "[year]-[month]-[day]T[hour]:[minute]:[second]Z",
173    )
174    .unwrap();
175    now.format(&format).unwrap_or_else(|_| String::new())
176}
177
178pub fn redact_value(value: Value) -> Value {
179    redact_with_depth(value, 0)
180}
181
182fn redact_with_depth(value: Value, depth: u8) -> Value {
183    if depth > 6 {
184        return Value::String("[depth-limit]".into());
185    }
186
187    match value {
188        Value::String(s) => {
189            if config::log_verbose() {
190                Value::String(s)
191            } else if s.len() > 4000 {
192                Value::String(format!("{}…[{} more]", &s[..4000], s.len() - 4000))
193            } else {
194                Value::String(s)
195            }
196        }
197        Value::Array(values) => Value::Array(
198            values
199                .into_iter()
200                .map(|v| redact_with_depth(v, depth + 1))
201                .collect(),
202        ),
203        Value::Object(fields) => {
204            let mut out = serde_json::Map::new();
205            for (key, value) in fields {
206                if REDACT_KEYS.contains(&key.to_lowercase().as_str()) {
207                    out.insert(key, redact_key_redaction(value));
208                } else {
209                    out.insert(key, redact_with_depth(value, depth + 1));
210                }
211            }
212            Value::Object(out)
213        }
214        value => value,
215    }
216}
217
218fn redact_key_redaction(value: Value) -> Value {
219    match value {
220        Value::String(s) => Value::String(format!("[redacted len={}]", s.len())),
221        _ => Value::String("[redacted]".to_string()),
222    }
223}
224
225pub fn redacted_keys() -> HashSet<&'static str> {
226    REDACT_KEYS.iter().copied().collect()
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use std::sync::Mutex;
233
234    static STDERR_TEST_LOCK: Mutex<()> = Mutex::new(());
235
236    #[test]
237    fn stderr_suppression_disables_level_mirroring() {
238        let _lock = STDERR_TEST_LOCK.lock().unwrap();
239        assert!(should_mirror_to_stderr("warn"));
240
241        {
242            let _guard = suppress_stderr();
243            assert!(!should_mirror_to_stderr("warn"));
244            assert!(!should_mirror_to_stderr("error"));
245        }
246
247        assert!(should_mirror_to_stderr("warn"));
248    }
249
250    #[test]
251    fn stderr_suppression_supports_nested_guards() {
252        let _lock = STDERR_TEST_LOCK.lock().unwrap();
253        let outer = suppress_stderr();
254        let inner = suppress_stderr();
255        assert!(!should_mirror_to_stderr("warn"));
256
257        drop(inner);
258        assert!(!should_mirror_to_stderr("warn"));
259
260        drop(outer);
261        assert!(should_mirror_to_stderr("warn"));
262    }
263
264    #[test]
265    fn redacts_proxy_authorization_case_insensitively() {
266        let redacted = redact_value(serde_json::json!({
267            "Proxy-Authorization": "Basic dXNlcjpwYXNz",
268            "safe": "kept"
269        }));
270
271        assert_eq!(redacted["safe"], "kept");
272        assert_eq!(redacted["Proxy-Authorization"], "[redacted len=18]");
273    }
274}