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