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 }
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}