1use std::fs::{self, OpenOptions};
2use std::io::Write;
3use std::path::PathBuf;
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::sync::{Mutex, OnceLock};
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use base64::Engine;
9use serde::{Deserialize, Serialize};
10use serde_json::Value as JsonValue;
11
12use crate::config::proxy_home_dir;
13use crate::usage::UsageMetrics;
14
15#[derive(Debug, Clone, Copy)]
16pub struct HttpDebugOptions {
17 pub enabled: bool,
18 pub all: bool,
19 pub max_body_bytes: usize,
20}
21
22#[derive(Debug, Clone, Copy)]
23pub struct HttpWarnOptions {
24 pub enabled: bool,
25 pub all: bool,
26 pub max_body_bytes: usize,
27}
28
29pub fn should_log_request_body_preview() -> bool {
30 env_bool_default("CODEX_HELPER_HTTP_LOG_REQUEST_BODY", false)
33}
34
35pub fn now_ms() -> u64 {
36 SystemTime::now()
37 .duration_since(UNIX_EPOCH)
38 .map(|d| d.as_millis() as u64)
39 .unwrap_or(0)
40}
41
42fn env_bool(key: &str) -> bool {
43 let Ok(v) = std::env::var(key) else {
44 return false;
45 };
46 matches!(
47 v.trim().to_ascii_lowercase().as_str(),
48 "1" | "true" | "yes" | "y" | "on"
49 )
50}
51
52fn env_bool_default(key: &str, default: bool) -> bool {
53 match std::env::var(key) {
54 Ok(v) => matches!(
55 v.trim().to_ascii_lowercase().as_str(),
56 "1" | "true" | "yes" | "y" | "on"
57 ),
58 Err(_) => default,
59 }
60}
61
62pub fn http_debug_options() -> HttpDebugOptions {
63 static OPT: OnceLock<HttpDebugOptions> = OnceLock::new();
64 *OPT.get_or_init(|| {
65 let enabled = env_bool("CODEX_HELPER_HTTP_DEBUG");
66 let all = env_bool("CODEX_HELPER_HTTP_DEBUG_ALL");
67 let max_body_bytes = std::env::var("CODEX_HELPER_HTTP_DEBUG_BODY_MAX")
68 .ok()
69 .and_then(|s| s.trim().parse::<usize>().ok())
70 .filter(|&n| n > 0)
71 .unwrap_or(64 * 1024);
72 HttpDebugOptions {
73 enabled,
74 all,
75 max_body_bytes,
76 }
77 })
78}
79
80pub fn http_warn_options() -> HttpWarnOptions {
81 static OPT: OnceLock<HttpWarnOptions> = OnceLock::new();
82 *OPT.get_or_init(|| {
83 let enabled = env_bool_default("CODEX_HELPER_HTTP_WARN", true);
86 let all = env_bool_default("CODEX_HELPER_HTTP_WARN_ALL", false);
87 let max_body_bytes = std::env::var("CODEX_HELPER_HTTP_WARN_BODY_MAX")
88 .ok()
89 .and_then(|s| s.trim().parse::<usize>().ok())
90 .filter(|&n| n > 0)
91 .unwrap_or(8 * 1024);
92 HttpWarnOptions {
93 enabled,
94 all,
95 max_body_bytes,
96 }
97 })
98}
99
100pub fn should_include_http_debug(status_code: u16) -> bool {
101 let opt = http_debug_options();
102 if !opt.enabled {
103 return false;
104 }
105 if opt.all {
106 return true;
107 }
108 !(200..300).contains(&status_code)
109}
110
111pub fn should_include_http_warn(status_code: u16) -> bool {
112 let opt = http_warn_options();
113 if !opt.enabled {
114 return false;
115 }
116 if opt.all {
117 return true;
118 }
119 !(200..300).contains(&status_code)
120}
121
122#[derive(Debug, Serialize, Clone)]
123pub struct HeaderEntry {
124 pub name: String,
125 pub value: String,
126}
127
128#[derive(Debug, Serialize, Clone)]
129pub struct AuthResolutionLog {
130 #[serde(skip_serializing_if = "Option::is_none")]
132 pub authorization: Option<String>,
133 #[serde(skip_serializing_if = "Option::is_none")]
135 pub x_api_key: Option<String>,
136}
137
138#[derive(Debug, Serialize, Clone)]
139pub struct BodyPreview {
140 #[serde(skip_serializing_if = "Option::is_none")]
141 pub content_type: Option<String>,
142 pub encoding: String,
143 pub data: String,
144 pub truncated: bool,
145 pub original_len: usize,
146}
147
148fn normalize_content_type(content_type: Option<&str>) -> Option<&str> {
149 let ct = content_type?.trim();
150 let (base, _) = ct.split_once(';').unwrap_or((ct, ""));
151 let base = base.trim();
152 if base.is_empty() { None } else { Some(base) }
153}
154
155fn is_textual_content_type(content_type: Option<&str>) -> bool {
156 let Some(ct) = normalize_content_type(content_type) else {
157 return false;
158 };
159 ct.starts_with("text/")
160 || ct == "application/json"
161 || ct.ends_with("+json")
162 || ct == "application/x-www-form-urlencoded"
163 || ct == "application/xml"
164 || ct.ends_with("+xml")
165 || ct == "text/event-stream"
166}
167
168pub fn make_body_preview(bytes: &[u8], content_type: Option<&str>, max: usize) -> BodyPreview {
169 let original_len = bytes.len();
170 let take = original_len.min(max);
171 let truncated = original_len > take;
172 let slice = &bytes[..take];
173
174 if is_textual_content_type(content_type) {
175 let text = String::from_utf8_lossy(slice).into_owned();
176 return BodyPreview {
177 content_type: normalize_content_type(content_type).map(|s| s.to_string()),
178 encoding: "utf8".to_string(),
179 data: text,
180 truncated,
181 original_len,
182 };
183 }
184
185 let b64 = base64::engine::general_purpose::STANDARD.encode(slice);
186 BodyPreview {
187 content_type: normalize_content_type(content_type).map(|s| s.to_string()),
188 encoding: "base64".to_string(),
189 data: b64,
190 truncated,
191 original_len,
192 }
193}
194
195#[derive(Debug, Serialize, Clone)]
196pub struct HttpDebugLog {
197 #[serde(skip_serializing_if = "Option::is_none")]
198 pub request_body_len: Option<usize>,
199 #[serde(skip_serializing_if = "Option::is_none")]
200 pub upstream_request_body_len: Option<usize>,
201 #[serde(skip_serializing_if = "Option::is_none")]
203 pub upstream_headers_ms: Option<u64>,
204 #[serde(skip_serializing_if = "Option::is_none")]
206 pub upstream_first_chunk_ms: Option<u64>,
207 #[serde(skip_serializing_if = "Option::is_none")]
209 pub upstream_body_read_ms: Option<u64>,
210 #[serde(skip_serializing_if = "Option::is_none")]
212 pub upstream_error_class: Option<String>,
213 #[serde(skip_serializing_if = "Option::is_none")]
215 pub upstream_error_hint: Option<String>,
216 #[serde(skip_serializing_if = "Option::is_none")]
218 pub upstream_cf_ray: Option<String>,
219 pub client_uri: String,
220 pub target_url: String,
221 pub client_headers: Vec<HeaderEntry>,
222 pub upstream_request_headers: Vec<HeaderEntry>,
223 #[serde(skip_serializing_if = "Option::is_none")]
224 pub auth_resolution: Option<AuthResolutionLog>,
225 #[serde(skip_serializing_if = "Option::is_none")]
226 pub client_body: Option<BodyPreview>,
227 #[serde(skip_serializing_if = "Option::is_none")]
228 pub upstream_request_body: Option<BodyPreview>,
229 #[serde(skip_serializing_if = "Option::is_none")]
230 pub upstream_response_headers: Option<Vec<HeaderEntry>>,
231 #[serde(skip_serializing_if = "Option::is_none")]
232 pub upstream_response_body: Option<BodyPreview>,
233 #[serde(skip_serializing_if = "Option::is_none")]
234 pub upstream_error: Option<String>,
235}
236
237#[derive(Debug, Serialize)]
238pub struct RequestLog<'a> {
239 pub timestamp_ms: u64,
240 pub service: &'a str,
241 pub method: &'a str,
242 pub path: &'a str,
243 pub status_code: u16,
244 pub duration_ms: u64,
245 #[serde(skip_serializing_if = "Option::is_none")]
249 pub ttfb_ms: Option<u64>,
250 pub config_name: &'a str,
251 #[serde(skip_serializing_if = "Option::is_none")]
252 pub provider_id: Option<String>,
253 pub upstream_base_url: &'a str,
254 #[serde(skip_serializing_if = "Option::is_none")]
255 pub session_id: Option<String>,
256 #[serde(skip_serializing_if = "Option::is_none")]
257 pub cwd: Option<String>,
258 #[serde(skip_serializing_if = "Option::is_none")]
259 pub reasoning_effort: Option<String>,
260 #[serde(skip_serializing_if = "Option::is_none")]
261 pub usage: Option<UsageMetrics>,
262 #[serde(skip_serializing_if = "Option::is_none")]
263 pub http_debug: Option<HttpDebugLog>,
264 #[serde(skip_serializing_if = "Option::is_none")]
265 pub http_debug_ref: Option<HttpDebugRef>,
266 #[serde(skip_serializing_if = "Option::is_none")]
267 pub retry: Option<RetryInfo>,
268}
269
270#[derive(Debug, Serialize, Clone)]
271pub struct HttpDebugRef {
272 pub id: String,
273 pub file: String,
274}
275
276#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
277pub struct RetryInfo {
278 pub attempts: u32,
279 pub upstream_chain: Vec<String>,
280}
281
282#[derive(Debug, Serialize)]
283struct HttpDebugLogEntry<'a> {
284 pub id: &'a str,
285 pub timestamp_ms: u64,
286 pub service: &'a str,
287 pub method: &'a str,
288 pub path: &'a str,
289 pub status_code: u16,
290 pub duration_ms: u64,
291 #[serde(skip_serializing_if = "Option::is_none")]
292 pub ttfb_ms: Option<u64>,
293 pub config_name: &'a str,
294 #[serde(skip_serializing_if = "Option::is_none")]
295 pub provider_id: Option<String>,
296 pub upstream_base_url: &'a str,
297 #[serde(skip_serializing_if = "Option::is_none")]
298 pub session_id: Option<String>,
299 #[serde(skip_serializing_if = "Option::is_none")]
300 pub cwd: Option<String>,
301 #[serde(skip_serializing_if = "Option::is_none")]
302 pub reasoning_effort: Option<String>,
303 #[serde(skip_serializing_if = "Option::is_none")]
304 pub usage: Option<UsageMetrics>,
305 #[serde(skip_serializing_if = "Option::is_none")]
306 pub retry: Option<RetryInfo>,
307 pub http_debug: HttpDebugLog,
308}
309
310#[derive(Debug, Clone, Copy)]
311struct RequestLogOptions {
312 max_bytes: u64,
313 max_files: usize,
314 only_errors: bool,
315}
316
317fn log_path() -> PathBuf {
318 proxy_home_dir().join("logs").join("requests.jsonl")
319}
320
321fn debug_log_path() -> PathBuf {
322 proxy_home_dir().join("logs").join("requests_debug.jsonl")
323}
324
325fn log_lock() -> &'static Mutex<()> {
326 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
327 LOCK.get_or_init(|| Mutex::new(()))
328}
329
330fn http_debug_split_enabled() -> bool {
331 env_bool_default("CODEX_HELPER_HTTP_DEBUG_SPLIT", true) || http_debug_options().all
334}
335
336fn request_log_options() -> RequestLogOptions {
337 static OPT: OnceLock<RequestLogOptions> = OnceLock::new();
338 *OPT.get_or_init(|| {
339 let max_bytes = std::env::var("CODEX_HELPER_REQUEST_LOG_MAX_BYTES")
340 .ok()
341 .and_then(|s| s.trim().parse::<u64>().ok())
342 .filter(|&n| n > 0)
343 .unwrap_or(50 * 1024 * 1024);
344 let max_files = std::env::var("CODEX_HELPER_REQUEST_LOG_MAX_FILES")
345 .ok()
346 .and_then(|s| s.trim().parse::<usize>().ok())
347 .filter(|&n| n > 0)
348 .unwrap_or(10);
349 let only_errors = env_bool("CODEX_HELPER_REQUEST_LOG_ONLY_ERRORS");
350 RequestLogOptions {
351 max_bytes,
352 max_files,
353 only_errors,
354 }
355 })
356}
357
358fn retry_trace_enabled() -> bool {
359 static ENABLED: OnceLock<bool> = OnceLock::new();
360 *ENABLED.get_or_init(|| env_bool("CODEX_HELPER_RETRY_TRACE"))
361}
362
363fn retry_trace_path() -> PathBuf {
364 std::env::var("CODEX_HELPER_RETRY_TRACE_PATH")
365 .ok()
366 .map(|s| s.trim().to_string())
367 .filter(|s| !s.is_empty())
368 .map(PathBuf::from)
369 .unwrap_or_else(|| proxy_home_dir().join("logs").join("retry_trace.jsonl"))
370}
371
372pub fn log_retry_trace(mut event: JsonValue) {
373 if !retry_trace_enabled() {
374 return;
375 }
376
377 if let JsonValue::Object(ref mut obj) = event {
378 obj.entry("ts_ms".to_string())
379 .or_insert_with(|| JsonValue::Number(serde_json::Number::from(now_ms())));
380 }
381
382 let opt = request_log_options();
383 let path = retry_trace_path();
384 if let Some(parent) = path.parent() {
385 let _ = fs::create_dir_all(parent);
386 }
387
388 let _guard = match log_lock().lock() {
389 Ok(g) => g,
390 Err(e) => e.into_inner(),
391 };
392
393 if let Ok(line) = serde_json::to_string(&event) {
394 rotate_and_prune_if_needed(&path, opt);
395 if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(&path) {
396 let _ = writeln!(file, "{}", line);
397 }
398 }
399}
400
401fn rotate_and_prune_if_needed(path: &PathBuf, opt: RequestLogOptions) {
402 if opt.max_bytes == 0 {
403 return;
404 }
405 let Ok(meta) = fs::metadata(path) else {
406 return;
407 };
408 if meta.len() < opt.max_bytes {
409 return;
410 }
411
412 let ts = SystemTime::now()
413 .duration_since(UNIX_EPOCH)
414 .map(|d| d.as_millis() as u64)
415 .unwrap_or(0);
416 let prefix = path
417 .file_stem()
418 .and_then(|s| s.to_str())
419 .unwrap_or("requests");
420 let rotated_name = format!("{prefix}.{ts}.jsonl");
421 let rotated_path = path.with_file_name(rotated_name);
422 let _ = fs::rename(path, &rotated_path);
423
424 let Some(dir) = path.parent() else {
425 return;
426 };
427 let Ok(rd) = fs::read_dir(dir) else {
428 return;
429 };
430 let mut rotated: Vec<PathBuf> = rd
431 .filter_map(|e| e.ok())
432 .map(|e| e.path())
433 .filter(|p| {
434 p.file_name()
435 .and_then(|n| n.to_str())
436 .map(|s| s.starts_with(&format!("{prefix}.")) && s.ends_with(".jsonl"))
437 .unwrap_or(false)
438 })
439 .collect();
440 if rotated.len() <= opt.max_files {
441 return;
442 }
443 rotated.sort();
444 let remove_count = rotated.len().saturating_sub(opt.max_files);
445 for p in rotated.into_iter().take(remove_count) {
446 let _ = fs::remove_file(p);
447 }
448}
449
450#[allow(clippy::too_many_arguments)]
451pub fn log_request_with_debug(
452 service: &str,
453 method: &str,
454 path: &str,
455 status_code: u16,
456 duration_ms: u64,
457 ttfb_ms: Option<u64>,
458 config_name: &str,
459 provider_id: Option<String>,
460 upstream_base_url: &str,
461 session_id: Option<String>,
462 cwd: Option<String>,
463 reasoning_effort: Option<String>,
464 usage: Option<UsageMetrics>,
465 retry: Option<RetryInfo>,
466 http_debug: Option<HttpDebugLog>,
467) {
468 let opt = request_log_options();
469 if opt.only_errors && (200..300).contains(&status_code) {
470 return;
471 }
472
473 let ts = now_ms();
474
475 static DEBUG_SEQ: AtomicU64 = AtomicU64::new(0);
476 let mut http_debug_for_main = http_debug;
477 let mut http_debug_ref: Option<HttpDebugRef> = None;
478
479 let log_file_path = log_path();
480 if let Some(parent) = log_file_path.parent() {
481 let _ = fs::create_dir_all(parent);
482 }
483
484 let _guard = match log_lock().lock() {
485 Ok(g) => g,
486 Err(e) => e.into_inner(),
487 };
488
489 if http_debug_split_enabled()
491 && let Some(h) = http_debug_for_main.take()
492 {
493 let seq = DEBUG_SEQ.fetch_add(1, Ordering::Relaxed);
494 let id = format!("{ts}-{seq}");
495 let debug_entry = HttpDebugLogEntry {
496 id: &id,
497 timestamp_ms: ts,
498 service,
499 method,
500 path,
501 status_code,
502 duration_ms,
503 ttfb_ms,
504 config_name,
505 provider_id: provider_id.clone(),
506 upstream_base_url,
507 session_id: session_id.clone(),
508 cwd: cwd.clone(),
509 reasoning_effort: reasoning_effort.clone(),
510 usage: usage.clone(),
511 retry: retry.clone(),
512 http_debug: h,
513 };
514
515 let debug_path = debug_log_path();
516 if let Some(parent) = debug_path.parent() {
517 let _ = fs::create_dir_all(parent);
518 }
519 let mut wrote_debug = false;
520 if let Ok(line) = serde_json::to_string(&debug_entry) {
521 rotate_and_prune_if_needed(&debug_path, opt);
522 if let Ok(mut file) = OpenOptions::new()
523 .create(true)
524 .append(true)
525 .open(&debug_path)
526 && writeln!(file, "{}", line).is_ok()
527 {
528 wrote_debug = true;
529 }
530 }
531
532 if wrote_debug {
533 http_debug_ref = Some(HttpDebugRef {
534 id,
535 file: "requests_debug.jsonl".to_string(),
536 });
537 } else {
538 let HttpDebugLogEntry { http_debug, .. } = debug_entry;
540 http_debug_for_main = Some(http_debug);
541 }
542 }
543
544 let entry = RequestLog {
545 timestamp_ms: ts,
546 service,
547 method,
548 path,
549 status_code,
550 duration_ms,
551 ttfb_ms,
552 config_name,
553 provider_id,
554 upstream_base_url,
555 session_id,
556 cwd,
557 reasoning_effort,
558 usage,
559 http_debug: http_debug_for_main,
560 http_debug_ref,
561 retry,
562 };
563
564 rotate_and_prune_if_needed(&log_file_path, opt);
565 if let Ok(line) = serde_json::to_string(&entry)
566 && let Ok(mut file) = OpenOptions::new()
567 .create(true)
568 .append(true)
569 .open(&log_file_path)
570 {
571 let _ = writeln!(file, "{}", line);
572 }
573}