Skip to main content

codex_helper_core/
logging.rs

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    // Default OFF: request bodies can be large and often contain sensitive data.
31    // Enable explicitly when debugging request payload issues.
32    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        // Default ON: for non-2xx, record a small header/body preview to help debug upstream errors.
84        // Set CODEX_HELPER_HTTP_WARN=0 to disable.
85        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    /// Where the upstream `Authorization` header value came from (never includes the secret).
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub authorization: Option<String>,
133    /// Where the upstream `X-API-Key` header value came from (never includes the secret).
134    #[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    /// Time spent waiting for upstream response headers (ms), measured from just before sending the upstream request.
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub upstream_headers_ms: Option<u64>,
204    /// Time to first upstream response body chunk (ms), measured from just before sending the upstream request.
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub upstream_first_chunk_ms: Option<u64>,
207    /// Time spent reading upstream response body to completion (ms). Only meaningful for non-stream responses.
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub upstream_body_read_ms: Option<u64>,
210    /// A coarse classification for upstream non-2xx responses (e.g. Cloudflare challenge).
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub upstream_error_class: Option<String>,
213    /// A human-readable hint to help diagnose upstream non-2xx responses.
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub upstream_error_hint: Option<String>,
216    /// Cloudflare request id when present (from `cf-ray` response header).
217    #[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    /// Time to first byte / first chunk from the upstream (ms).
246    /// - For streaming responses: measured to the first response body chunk.
247    /// - For non-streaming responses: measured to response headers.
248    #[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    // When HTTP debug is enabled for all requests, splitting is strongly recommended to keep
332    // the main request log lightweight. Users can also enable splitting explicitly.
333    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    // Optional: write large http_debug blobs to a separate file and keep only a reference in requests.jsonl.
490    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            // If we failed to write the debug entry, fall back to inline logging to avoid losing data.
539            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}