Skip to main content

codex_helper_core/
logging.rs

1use std::fs::{self, OpenOptions};
2use std::io::Write;
3use std::path::{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::state::{RouteDecisionProvenance, SessionIdentitySource};
14use crate::usage::UsageMetrics;
15
16#[path = "logging/control_trace.rs"]
17mod control_trace_impl;
18
19use control_trace_impl::append_control_trace_payload;
20pub use control_trace_impl::{
21    ControlTraceDetail, ControlTraceLogEntry, control_trace_path, log_retry_trace,
22    read_recent_control_trace_entries,
23};
24
25#[derive(Debug, Clone, Copy)]
26pub struct HttpDebugOptions {
27    pub enabled: bool,
28    pub all: bool,
29    pub max_body_bytes: usize,
30}
31
32#[derive(Debug, Clone, Copy)]
33pub struct HttpWarnOptions {
34    pub enabled: bool,
35    pub all: bool,
36    pub max_body_bytes: usize,
37}
38
39pub fn should_log_request_body_preview() -> bool {
40    // Default OFF: request bodies can be large and often contain sensitive data.
41    // Enable explicitly when debugging request payload issues.
42    env_bool_default("CODEX_HELPER_HTTP_LOG_REQUEST_BODY", false)
43}
44
45pub fn now_ms() -> u64 {
46    SystemTime::now()
47        .duration_since(UNIX_EPOCH)
48        .map(|d| d.as_millis() as u64)
49        .unwrap_or(0)
50}
51
52pub fn request_trace_id(service: &str, request_id: u64) -> String {
53    let service = service.trim();
54    if service.is_empty() {
55        format!("request-{request_id}")
56    } else {
57        format!("{service}-{request_id}")
58    }
59}
60
61fn env_bool(key: &str) -> bool {
62    let Ok(v) = std::env::var(key) else {
63        return false;
64    };
65    matches!(
66        v.trim().to_ascii_lowercase().as_str(),
67        "1" | "true" | "yes" | "y" | "on"
68    )
69}
70
71fn env_bool_default(key: &str, default: bool) -> bool {
72    match std::env::var(key) {
73        Ok(v) => matches!(
74            v.trim().to_ascii_lowercase().as_str(),
75            "1" | "true" | "yes" | "y" | "on"
76        ),
77        Err(_) => default,
78    }
79}
80
81pub fn http_debug_options() -> HttpDebugOptions {
82    static OPT: OnceLock<HttpDebugOptions> = OnceLock::new();
83    *OPT.get_or_init(|| {
84        let enabled = env_bool("CODEX_HELPER_HTTP_DEBUG");
85        let all = env_bool("CODEX_HELPER_HTTP_DEBUG_ALL");
86        let max_body_bytes = std::env::var("CODEX_HELPER_HTTP_DEBUG_BODY_MAX")
87            .ok()
88            .and_then(|s| s.trim().parse::<usize>().ok())
89            .filter(|&n| n > 0)
90            .unwrap_or(64 * 1024);
91        HttpDebugOptions {
92            enabled,
93            all,
94            max_body_bytes,
95        }
96    })
97}
98
99pub fn http_warn_options() -> HttpWarnOptions {
100    static OPT: OnceLock<HttpWarnOptions> = OnceLock::new();
101    *OPT.get_or_init(|| {
102        // Default ON: for non-2xx, record a small header/body preview to help debug upstream errors.
103        // Set CODEX_HELPER_HTTP_WARN=0 to disable.
104        let enabled = env_bool_default("CODEX_HELPER_HTTP_WARN", true);
105        let all = env_bool_default("CODEX_HELPER_HTTP_WARN_ALL", false);
106        let max_body_bytes = std::env::var("CODEX_HELPER_HTTP_WARN_BODY_MAX")
107            .ok()
108            .and_then(|s| s.trim().parse::<usize>().ok())
109            .filter(|&n| n > 0)
110            .unwrap_or(8 * 1024);
111        HttpWarnOptions {
112            enabled,
113            all,
114            max_body_bytes,
115        }
116    })
117}
118
119pub fn should_include_http_debug(status_code: u16) -> bool {
120    let opt = http_debug_options();
121    if !opt.enabled {
122        return false;
123    }
124    if opt.all {
125        return true;
126    }
127    !(200..300).contains(&status_code)
128}
129
130pub fn should_include_http_warn(status_code: u16) -> bool {
131    let opt = http_warn_options();
132    if !opt.enabled {
133        return false;
134    }
135    if opt.all {
136        return true;
137    }
138    !(200..300).contains(&status_code)
139}
140
141#[derive(Debug, Serialize, Clone)]
142pub struct HeaderEntry {
143    pub name: String,
144    pub value: String,
145}
146
147#[derive(Debug, Serialize, Clone)]
148pub struct AuthResolutionLog {
149    /// Where the upstream `Authorization` header value came from (never includes the secret).
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub authorization: Option<String>,
152    /// Where the upstream `X-API-Key` header value came from (never includes the secret).
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub x_api_key: Option<String>,
155}
156
157#[derive(Debug, Serialize, Clone)]
158pub struct BodyPreview {
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub content_type: Option<String>,
161    pub encoding: String,
162    pub data: String,
163    pub truncated: bool,
164    pub original_len: usize,
165}
166
167fn normalize_content_type(content_type: Option<&str>) -> Option<&str> {
168    let ct = content_type?.trim();
169    let (base, _) = ct.split_once(';').unwrap_or((ct, ""));
170    let base = base.trim();
171    if base.is_empty() { None } else { Some(base) }
172}
173
174fn is_textual_content_type(content_type: Option<&str>) -> bool {
175    let Some(ct) = normalize_content_type(content_type) else {
176        return false;
177    };
178    ct.starts_with("text/")
179        || ct == "application/json"
180        || ct.ends_with("+json")
181        || ct == "application/x-www-form-urlencoded"
182        || ct == "application/xml"
183        || ct.ends_with("+xml")
184        || ct == "text/event-stream"
185}
186
187pub fn make_body_preview(bytes: &[u8], content_type: Option<&str>, max: usize) -> BodyPreview {
188    let original_len = bytes.len();
189    let take = original_len.min(max);
190    let truncated = original_len > take;
191    let slice = &bytes[..take];
192
193    if is_textual_content_type(content_type) {
194        let text = String::from_utf8_lossy(slice).into_owned();
195        return BodyPreview {
196            content_type: normalize_content_type(content_type).map(|s| s.to_string()),
197            encoding: "utf8".to_string(),
198            data: text,
199            truncated,
200            original_len,
201        };
202    }
203
204    let b64 = base64::engine::general_purpose::STANDARD.encode(slice);
205    BodyPreview {
206        content_type: normalize_content_type(content_type).map(|s| s.to_string()),
207        encoding: "base64".to_string(),
208        data: b64,
209        truncated,
210        original_len,
211    }
212}
213
214#[derive(Debug, Serialize, Clone)]
215pub struct HttpDebugLog {
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub request_body_len: Option<usize>,
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub upstream_request_body_len: Option<usize>,
220    /// Time spent waiting for upstream response headers (ms), measured from just before sending the upstream request.
221    #[serde(skip_serializing_if = "Option::is_none")]
222    pub upstream_headers_ms: Option<u64>,
223    /// Time to first upstream response body chunk (ms), measured from just before sending the upstream request.
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub upstream_first_chunk_ms: Option<u64>,
226    /// Time spent reading upstream response body to completion (ms). Only meaningful for non-stream responses.
227    #[serde(skip_serializing_if = "Option::is_none")]
228    pub upstream_body_read_ms: Option<u64>,
229    /// A coarse classification for upstream non-2xx responses (e.g. Cloudflare challenge).
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub upstream_error_class: Option<String>,
232    /// A human-readable hint to help diagnose upstream non-2xx responses.
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub upstream_error_hint: Option<String>,
235    /// Cloudflare request id when present (from `cf-ray` response header).
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub upstream_cf_ray: Option<String>,
238    pub client_uri: String,
239    pub target_url: String,
240    pub client_headers: Vec<HeaderEntry>,
241    pub upstream_request_headers: Vec<HeaderEntry>,
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub auth_resolution: Option<AuthResolutionLog>,
244    #[serde(skip_serializing_if = "Option::is_none")]
245    pub client_body: Option<BodyPreview>,
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub upstream_request_body: Option<BodyPreview>,
248    #[serde(skip_serializing_if = "Option::is_none")]
249    pub upstream_response_headers: Option<Vec<HeaderEntry>>,
250    #[serde(skip_serializing_if = "Option::is_none")]
251    pub upstream_response_body: Option<BodyPreview>,
252    #[serde(skip_serializing_if = "Option::is_none")]
253    pub upstream_error: Option<String>,
254}
255
256#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
257pub struct ServiceTierLog {
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub requested: Option<String>,
260    #[serde(skip_serializing_if = "Option::is_none")]
261    pub effective: Option<String>,
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub actual: Option<String>,
264}
265
266fn service_tier_log_is_empty(value: &ServiceTierLog) -> bool {
267    value.requested.is_none() && value.effective.is_none() && value.actual.is_none()
268}
269
270#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
271pub struct CodexBridgeLog {
272    pub patch_mode: String,
273    #[serde(default, skip_serializing_if = "bool_is_false")]
274    pub remote_compaction_v1_request: bool,
275    #[serde(default, skip_serializing_if = "bool_is_false")]
276    pub remote_compaction_v2_request: bool,
277    #[serde(default, skip_serializing_if = "bool_is_false")]
278    pub responses_websocket_request: bool,
279    #[serde(default, skip_serializing_if = "bool_is_false")]
280    pub strips_client_auth: bool,
281}
282
283#[derive(Debug, Serialize)]
284pub struct RequestLog<'a> {
285    pub timestamp_ms: u64,
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub request_id: Option<u64>,
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub trace_id: Option<String>,
290    pub service: &'a str,
291    pub method: &'a str,
292    pub path: &'a str,
293    pub status_code: u16,
294    pub duration_ms: u64,
295    /// Time to first byte / first chunk from the upstream (ms).
296    /// - For streaming responses: measured to the first response body chunk.
297    /// - For non-streaming responses: measured to response headers.
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub ttfb_ms: Option<u64>,
300    #[serde(skip_serializing_if = "Option::is_none")]
301    pub station_name: Option<&'a str>,
302    #[serde(skip_serializing_if = "Option::is_none")]
303    pub provider_id: Option<String>,
304    #[serde(skip_serializing_if = "Option::is_none")]
305    pub endpoint_id: Option<String>,
306    #[serde(skip_serializing_if = "Option::is_none")]
307    pub provider_endpoint_key: Option<String>,
308    pub upstream_base_url: &'a str,
309    #[serde(skip_serializing_if = "Option::is_none")]
310    pub session_id: Option<String>,
311    #[serde(skip_serializing_if = "Option::is_none")]
312    pub session_identity_source: Option<SessionIdentitySource>,
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub cwd: Option<String>,
315    #[serde(skip_serializing_if = "Option::is_none")]
316    pub model: Option<String>,
317    #[serde(skip_serializing_if = "Option::is_none")]
318    pub reasoning_effort: Option<String>,
319    #[serde(skip_serializing_if = "service_tier_log_is_empty", default)]
320    pub service_tier: ServiceTierLog,
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub codex_bridge: Option<CodexBridgeLog>,
323    #[serde(skip_serializing_if = "Option::is_none")]
324    pub usage: Option<UsageMetrics>,
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub http_debug: Option<HttpDebugLog>,
327    #[serde(skip_serializing_if = "Option::is_none")]
328    pub http_debug_ref: Option<HttpDebugRef>,
329    #[serde(skip_serializing_if = "Option::is_none")]
330    pub route_decision: Option<RouteDecisionProvenance>,
331    #[serde(skip_serializing_if = "Option::is_none")]
332    pub retry: Option<RetryInfo>,
333}
334
335#[derive(Debug, Serialize, Clone)]
336pub struct HttpDebugRef {
337    pub id: String,
338    pub file: String,
339}
340
341fn route_attempts_is_empty(value: &[RouteAttemptLog]) -> bool {
342    value.is_empty()
343}
344
345fn route_attempt_avoid_for_station_is_empty(value: &[usize]) -> bool {
346    value.is_empty()
347}
348
349fn route_attempt_avoided_candidate_indices_is_empty(value: &[usize]) -> bool {
350    value.is_empty()
351}
352
353fn route_attempt_route_path_is_empty(value: &[String]) -> bool {
354    value.is_empty()
355}
356
357fn bool_is_false(value: &bool) -> bool {
358    !*value
359}
360
361#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)]
362pub struct RouteAttemptLog {
363    pub attempt_index: u32,
364    #[serde(skip_serializing_if = "Option::is_none")]
365    pub provider_id: Option<String>,
366    #[serde(skip_serializing_if = "Option::is_none")]
367    pub endpoint_id: Option<String>,
368    #[serde(skip_serializing_if = "Option::is_none")]
369    pub provider_endpoint_key: Option<String>,
370    #[serde(skip_serializing_if = "Option::is_none")]
371    pub preference_group: Option<u32>,
372    #[serde(default, skip_serializing_if = "route_attempt_route_path_is_empty")]
373    pub route_path: Vec<String>,
374    #[serde(skip_serializing_if = "Option::is_none")]
375    pub provider_attempt: Option<u32>,
376    #[serde(skip_serializing_if = "Option::is_none")]
377    pub upstream_attempt: Option<u32>,
378    #[serde(skip_serializing_if = "Option::is_none")]
379    pub provider_max_attempts: Option<u32>,
380    #[serde(skip_serializing_if = "Option::is_none")]
381    pub upstream_max_attempts: Option<u32>,
382    #[serde(skip_serializing_if = "Option::is_none")]
383    pub station_name: Option<String>,
384    #[serde(skip_serializing_if = "Option::is_none")]
385    pub upstream_base_url: Option<String>,
386    #[serde(skip_serializing_if = "Option::is_none")]
387    pub upstream_index: Option<usize>,
388    #[serde(
389        default,
390        skip_serializing_if = "route_attempt_avoid_for_station_is_empty"
391    )]
392    pub avoid_for_station: Vec<usize>,
393    #[serde(
394        default,
395        skip_serializing_if = "route_attempt_avoided_candidate_indices_is_empty"
396    )]
397    pub avoided_candidate_indices: Vec<usize>,
398    #[serde(skip_serializing_if = "Option::is_none")]
399    pub avoided_total: Option<usize>,
400    #[serde(skip_serializing_if = "Option::is_none")]
401    pub total_upstreams: Option<usize>,
402    pub decision: String,
403    #[serde(skip_serializing_if = "Option::is_none")]
404    pub reason: Option<String>,
405    #[serde(skip_serializing_if = "Option::is_none")]
406    pub status_code: Option<u16>,
407    #[serde(skip_serializing_if = "Option::is_none")]
408    pub error_class: Option<String>,
409    #[serde(skip_serializing_if = "Option::is_none")]
410    pub model: Option<String>,
411    #[serde(skip_serializing_if = "Option::is_none")]
412    pub upstream_headers_ms: Option<u64>,
413    #[serde(skip_serializing_if = "Option::is_none")]
414    pub duration_ms: Option<u64>,
415    #[serde(skip_serializing_if = "Option::is_none")]
416    pub cooldown_secs: Option<u64>,
417    #[serde(skip_serializing_if = "Option::is_none")]
418    pub cooldown_reason: Option<String>,
419    #[serde(default, skip_serializing_if = "bool_is_false")]
420    pub skipped: bool,
421    pub raw: String,
422}
423
424#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
425pub struct RetryInfo {
426    pub attempts: u32,
427    pub upstream_chain: Vec<String>,
428    #[serde(default, skip_serializing_if = "route_attempts_is_empty")]
429    pub route_attempts: Vec<RouteAttemptLog>,
430}
431
432impl RetryInfo {
433    pub fn route_attempts_or_derived(&self) -> Vec<RouteAttemptLog> {
434        if self.route_attempts.is_empty() {
435            parse_route_attempts_from_chain(&self.upstream_chain)
436        } else {
437            self.route_attempts.clone()
438        }
439    }
440
441    pub fn touches_station(&self, station_name: &str) -> bool {
442        let station_name = station_name.trim();
443        if station_name.is_empty() {
444            return false;
445        }
446        self.route_attempts_or_derived()
447            .iter()
448            .any(|attempt| attempt.station_name.as_deref() == Some(station_name))
449    }
450
451    pub fn touched_other_station(&self, final_station: Option<&str>) -> bool {
452        let Some(final_station) = final_station
453            .map(str::trim)
454            .filter(|station| !station.is_empty())
455        else {
456            return false;
457        };
458
459        self.route_attempts_or_derived()
460            .iter()
461            .filter_map(|attempt| attempt.station_name.as_deref())
462            .any(|station| station != final_station)
463    }
464}
465
466pub(crate) fn parse_route_attempts_from_chain(chain: &[String]) -> Vec<RouteAttemptLog> {
467    chain
468        .iter()
469        .enumerate()
470        .map(|(idx, raw)| parse_route_attempt_from_chain_entry(raw, idx as u32))
471        .collect()
472}
473
474fn parse_route_attempt_from_chain_entry(raw: &str, attempt_index: u32) -> RouteAttemptLog {
475    let raw = raw.trim();
476    let mut attempt = RouteAttemptLog {
477        attempt_index,
478        decision: "observed".to_string(),
479        raw: raw.to_string(),
480        ..Default::default()
481    };
482
483    if raw.starts_with("all_upstreams_avoided") {
484        attempt.decision = "all_upstreams_avoided".to_string();
485        attempt.reason = route_chain_value(raw, "total").map(|total| format!("total={total}"));
486        attempt.skipped = true;
487        return attempt;
488    }
489
490    let (target, metadata, upstream_index) = split_route_chain_entry(raw);
491    attempt.upstream_index = upstream_index;
492    if let Some(target) = target {
493        let (station_name, upstream_base_url) = parse_route_chain_target(target);
494        attempt.station_name = station_name;
495        attempt.upstream_base_url = upstream_base_url;
496    }
497    apply_route_chain_identity_metadata(&mut attempt, raw);
498
499    if let Some(status_code) =
500        route_chain_value(metadata, "status").and_then(|value| value.parse::<u16>().ok())
501    {
502        attempt.status_code = Some(status_code);
503        attempt.decision = if (200..300).contains(&status_code) {
504            "completed".to_string()
505        } else {
506            "failed_status".to_string()
507        };
508    }
509
510    if let Some(error_class) =
511        route_chain_value(metadata, "class").filter(|value| !value.eq_ignore_ascii_case("-"))
512    {
513        attempt.error_class = Some(error_class);
514    }
515    if let Some(model) = route_chain_value(metadata, "model") {
516        attempt.model = Some(model);
517    }
518
519    if let Some(model) = route_chain_value(metadata, "skipped_unsupported_model") {
520        attempt.decision = "skipped_capability_mismatch".to_string();
521        attempt.reason = Some("unsupported_model".to_string());
522        attempt.model = Some(model);
523        attempt.skipped = true;
524    }
525
526    if let Some(error) = route_chain_value(metadata, "transport_error") {
527        attempt.decision = "failed_transport".to_string();
528        attempt.reason = Some(error);
529        attempt.error_class = Some("upstream_transport_error".to_string());
530    }
531
532    if let Some(error) = route_chain_value(metadata, "target_build_error") {
533        attempt.decision = "failed_target_build".to_string();
534        attempt.reason = Some(error);
535        attempt.error_class = Some("target_build_error".to_string());
536    }
537
538    if let Some(error) = route_chain_value(metadata, "body_read_error") {
539        attempt.decision = "failed_body_read".to_string();
540        attempt.reason = Some(error);
541        attempt.error_class = Some("upstream_body_read_error".to_string());
542    }
543    if let Some(error) = route_chain_value(metadata, "body_too_large") {
544        attempt.decision = "failed_body_too_large".to_string();
545        attempt.reason = Some(error);
546        attempt.error_class = Some("upstream_response_body_too_large".to_string());
547    }
548
549    attempt
550}
551
552fn apply_route_chain_identity_metadata(attempt: &mut RouteAttemptLog, raw: &str) {
553    if let Some(station) = route_chain_value(raw, "station") {
554        attempt.station_name = non_empty_str(station.as_str()).map(ToOwned::to_owned);
555    }
556    if let Some(provider_endpoint_key) = route_chain_value(raw, "endpoint") {
557        let provider_endpoint_key = provider_endpoint_key.trim().to_string();
558        if !provider_endpoint_key.is_empty() && provider_endpoint_key != "-" {
559            let parts = provider_endpoint_key.split('/').collect::<Vec<_>>();
560            if parts.len() >= 3 {
561                attempt.provider_id = non_empty_str(parts[1]).map(ToOwned::to_owned);
562                attempt.endpoint_id = non_empty_str(parts[2]).map(ToOwned::to_owned);
563            }
564            attempt.provider_endpoint_key = Some(provider_endpoint_key);
565        }
566    }
567    if let Some(group) = route_chain_value(raw, "group").and_then(|value| value.parse::<u32>().ok())
568    {
569        attempt.preference_group = Some(group);
570    }
571    if let Some(station) = route_chain_value(raw, "compat_station") {
572        attempt.station_name = non_empty_str(station.as_str()).map(ToOwned::to_owned);
573    }
574    if let Some(index) =
575        route_chain_value(raw, "upstream_index").and_then(|value| value.parse::<usize>().ok())
576    {
577        attempt.upstream_index = Some(index);
578    }
579    if let Some(url) = route_chain_value(raw, "url") {
580        attempt.upstream_base_url = non_empty_str(url.as_str()).map(ToOwned::to_owned);
581    }
582    if let Some(indices) = route_chain_value(raw, "avoid_candidates") {
583        attempt.avoided_candidate_indices = parse_usize_list(indices.as_str());
584    }
585    if let Some(indices) = route_chain_value(raw, "avoid") {
586        attempt.avoid_for_station = parse_usize_list(indices.as_str());
587    }
588}
589
590fn parse_usize_list(raw: &str) -> Vec<usize> {
591    raw.split(',')
592        .filter_map(|part| part.trim().parse::<usize>().ok())
593        .collect()
594}
595
596fn split_route_chain_entry(raw: &str) -> (Option<&str>, &str, Option<usize>) {
597    if let Some(idx_start) = raw.find(" (idx=") {
598        let target = raw[..idx_start].trim();
599        let after_idx = &raw[idx_start + " (idx=".len()..];
600        if let Some(idx_end) = after_idx.find(')') {
601            let upstream_index = after_idx[..idx_end].trim().parse::<usize>().ok();
602            let metadata = after_idx[idx_end + 1..].trim();
603            return (non_empty_str(target), metadata, upstream_index);
604        }
605        return (non_empty_str(target), after_idx.trim(), None);
606    }
607
608    if let Some(metadata_start) = first_route_chain_key_start(raw) {
609        let target = raw[..metadata_start].trim();
610        let metadata = raw[metadata_start..].trim();
611        return (non_empty_str(target), metadata, None);
612    }
613
614    (non_empty_str(raw), "", None)
615}
616
617fn parse_route_chain_target(target: &str) -> (Option<String>, Option<String>) {
618    let target = target.trim();
619    if target.is_empty() {
620        return (None, None);
621    }
622    if target.starts_with("http://") || target.starts_with("https://") {
623        return (None, Some(target.to_string()));
624    }
625
626    if let Some(pos) = target.find(":http://").or_else(|| target.find(":https://")) {
627        let station = target[..pos].trim();
628        let base_url = target[pos + 1..].trim();
629        return (
630            non_empty_str(station).map(ToOwned::to_owned),
631            non_empty_str(base_url).map(ToOwned::to_owned),
632        );
633    }
634
635    (None, Some(target.to_string()))
636}
637
638fn first_route_chain_key_start(raw: &str) -> Option<usize> {
639    ROUTE_CHAIN_KEYS
640        .iter()
641        .filter_map(|key| find_route_chain_key(raw, format!("{key}=").as_str()))
642        .min()
643}
644
645fn route_chain_value(raw: &str, key: &str) -> Option<String> {
646    let needle = format!("{key}=");
647    let start = find_route_chain_key(raw, needle.as_str())?;
648    let value_start = start + needle.len();
649    let value_end = ROUTE_CHAIN_KEYS
650        .iter()
651        .filter(|candidate| **candidate != key)
652        .filter_map(|candidate| {
653            raw[value_start..]
654                .find(&format!(" {candidate}="))
655                .map(|offset| value_start + offset)
656        })
657        .min()
658        .unwrap_or(raw.len());
659    non_empty_str(raw[value_start..value_end].trim()).map(ToOwned::to_owned)
660}
661
662fn find_route_chain_key(raw: &str, needle: &str) -> Option<usize> {
663    let mut offset = 0usize;
664    while let Some(pos) = raw[offset..].find(needle) {
665        let absolute = offset + pos;
666        if absolute == 0 || raw[..absolute].ends_with(' ') {
667            return Some(absolute);
668        }
669        offset = absolute + needle.len();
670    }
671    None
672}
673
674fn non_empty_str(value: &str) -> Option<&str> {
675    let value = value.trim();
676    if value.is_empty() { None } else { Some(value) }
677}
678
679const ROUTE_CHAIN_KEYS: &[&str] = &[
680    "status",
681    "class",
682    "model",
683    "transport_error",
684    "target_build_error",
685    "body_read_error",
686    "body_too_large",
687    "skipped_unsupported_model",
688    "total",
689    "station",
690    "endpoint",
691    "group",
692    "compat_station",
693    "upstream_index",
694    "url",
695    "avoid_candidates",
696    "avoid",
697];
698
699#[derive(Debug, Serialize)]
700struct HttpDebugLogEntry<'a> {
701    pub id: &'a str,
702    pub timestamp_ms: u64,
703    #[serde(skip_serializing_if = "Option::is_none")]
704    pub request_id: Option<u64>,
705    #[serde(skip_serializing_if = "Option::is_none")]
706    pub trace_id: Option<String>,
707    pub service: &'a str,
708    pub method: &'a str,
709    pub path: &'a str,
710    pub status_code: u16,
711    pub duration_ms: u64,
712    #[serde(skip_serializing_if = "Option::is_none")]
713    pub ttfb_ms: Option<u64>,
714    #[serde(skip_serializing_if = "Option::is_none")]
715    pub station_name: Option<&'a str>,
716    #[serde(skip_serializing_if = "Option::is_none")]
717    pub provider_id: Option<String>,
718    #[serde(skip_serializing_if = "Option::is_none")]
719    pub endpoint_id: Option<String>,
720    #[serde(skip_serializing_if = "Option::is_none")]
721    pub provider_endpoint_key: Option<String>,
722    pub upstream_base_url: &'a str,
723    #[serde(skip_serializing_if = "Option::is_none")]
724    pub session_id: Option<String>,
725    #[serde(skip_serializing_if = "Option::is_none")]
726    pub session_identity_source: Option<SessionIdentitySource>,
727    #[serde(skip_serializing_if = "Option::is_none")]
728    pub cwd: Option<String>,
729    #[serde(skip_serializing_if = "Option::is_none")]
730    pub model: Option<String>,
731    #[serde(skip_serializing_if = "Option::is_none")]
732    pub reasoning_effort: Option<String>,
733    #[serde(skip_serializing_if = "service_tier_log_is_empty", default)]
734    pub service_tier: ServiceTierLog,
735    #[serde(skip_serializing_if = "Option::is_none")]
736    pub codex_bridge: Option<CodexBridgeLog>,
737    #[serde(skip_serializing_if = "Option::is_none")]
738    pub usage: Option<UsageMetrics>,
739    #[serde(skip_serializing_if = "Option::is_none")]
740    pub route_decision: Option<RouteDecisionProvenance>,
741    #[serde(skip_serializing_if = "Option::is_none")]
742    pub retry: Option<RetryInfo>,
743    pub http_debug: HttpDebugLog,
744}
745
746#[derive(Debug, Clone, Copy)]
747struct RequestLogOptions {
748    max_bytes: u64,
749    max_files: usize,
750    only_errors: bool,
751}
752
753pub fn request_log_path() -> PathBuf {
754    proxy_home_dir().join("logs").join("requests.jsonl")
755}
756
757fn debug_log_path() -> PathBuf {
758    proxy_home_dir().join("logs").join("requests_debug.jsonl")
759}
760
761fn log_lock() -> &'static Mutex<()> {
762    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
763    LOCK.get_or_init(|| Mutex::new(()))
764}
765
766fn http_debug_split_enabled() -> bool {
767    // When HTTP debug is enabled for all requests, splitting is strongly recommended to keep
768    // the main request log lightweight. Users can also enable splitting explicitly.
769    env_bool_default("CODEX_HELPER_HTTP_DEBUG_SPLIT", true) || http_debug_options().all
770}
771
772fn request_log_options() -> RequestLogOptions {
773    static OPT: OnceLock<RequestLogOptions> = OnceLock::new();
774    *OPT.get_or_init(|| {
775        let max_bytes = std::env::var("CODEX_HELPER_REQUEST_LOG_MAX_BYTES")
776            .ok()
777            .and_then(|s| s.trim().parse::<u64>().ok())
778            .filter(|&n| n > 0)
779            .unwrap_or(50 * 1024 * 1024);
780        let max_files = std::env::var("CODEX_HELPER_REQUEST_LOG_MAX_FILES")
781            .ok()
782            .and_then(|s| s.trim().parse::<usize>().ok())
783            .filter(|&n| n > 0)
784            .unwrap_or(10);
785        let only_errors = env_bool("CODEX_HELPER_REQUEST_LOG_ONLY_ERRORS");
786        RequestLogOptions {
787            max_bytes,
788            max_files,
789            only_errors,
790        }
791    })
792}
793
794fn ensure_log_parent(path: &Path) {
795    if let Some(parent) = path.parent() {
796        let _ = fs::create_dir_all(parent);
797    }
798}
799
800fn append_json_line(path: &PathBuf, opt: RequestLogOptions, line: &str) -> bool {
801    rotate_and_prune_if_needed(path, opt);
802    if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
803        return writeln!(file, "{}", line).is_ok();
804    }
805    false
806}
807
808fn rotate_and_prune_if_needed(path: &PathBuf, opt: RequestLogOptions) {
809    if opt.max_bytes == 0 {
810        return;
811    }
812    let Ok(meta) = fs::metadata(path) else {
813        return;
814    };
815    if meta.len() < opt.max_bytes {
816        return;
817    }
818
819    let ts = SystemTime::now()
820        .duration_since(UNIX_EPOCH)
821        .map(|d| d.as_millis() as u64)
822        .unwrap_or(0);
823    let prefix = path
824        .file_stem()
825        .and_then(|s| s.to_str())
826        .unwrap_or("requests");
827    let rotated_name = format!("{prefix}.{ts}.jsonl");
828    let rotated_path = path.with_file_name(rotated_name);
829    let _ = fs::rename(path, &rotated_path);
830
831    let Some(dir) = path.parent() else {
832        return;
833    };
834    let Ok(rd) = fs::read_dir(dir) else {
835        return;
836    };
837    let mut rotated: Vec<PathBuf> = rd
838        .filter_map(|e| e.ok())
839        .map(|e| e.path())
840        .filter(|p| {
841            p.file_name()
842                .and_then(|n| n.to_str())
843                .map(|s| s.starts_with(&format!("{prefix}.")) && s.ends_with(".jsonl"))
844                .unwrap_or(false)
845        })
846        .collect();
847    if rotated.len() <= opt.max_files {
848        return;
849    }
850    rotated.sort();
851    let remove_count = rotated.len().saturating_sub(opt.max_files);
852    for p in rotated.into_iter().take(remove_count) {
853        let _ = fs::remove_file(p);
854    }
855}
856
857#[allow(clippy::too_many_arguments)]
858pub fn log_request_with_debug(
859    request_id: Option<u64>,
860    service: &str,
861    method: &str,
862    path: &str,
863    status_code: u16,
864    duration_ms: u64,
865    ttfb_ms: Option<u64>,
866    station_name: Option<&str>,
867    provider_id: Option<String>,
868    endpoint_id: Option<String>,
869    provider_endpoint_key: Option<String>,
870    upstream_base_url: &str,
871    session_id: Option<String>,
872    session_identity_source: Option<SessionIdentitySource>,
873    cwd: Option<String>,
874    model: Option<String>,
875    reasoning_effort: Option<String>,
876    service_tier: ServiceTierLog,
877    codex_bridge: Option<CodexBridgeLog>,
878    usage: Option<UsageMetrics>,
879    route_decision: Option<RouteDecisionProvenance>,
880    retry: Option<RetryInfo>,
881    http_debug: Option<HttpDebugLog>,
882) {
883    let opt = request_log_options();
884    if opt.only_errors && (200..300).contains(&status_code) {
885        return;
886    }
887
888    let ts = now_ms();
889    let trace_id = request_id.map(|id| request_trace_id(service, id));
890
891    static DEBUG_SEQ: AtomicU64 = AtomicU64::new(0);
892    let mut http_debug_for_main = http_debug;
893    let mut http_debug_ref: Option<HttpDebugRef> = None;
894
895    let log_file_path = request_log_path();
896    ensure_log_parent(&log_file_path);
897
898    let _guard = match log_lock().lock() {
899        Ok(g) => g,
900        Err(e) => e.into_inner(),
901    };
902
903    // Optional: write large http_debug blobs to a separate file and keep only a reference in requests.jsonl.
904    if http_debug_split_enabled()
905        && let Some(h) = http_debug_for_main.take()
906    {
907        let seq = DEBUG_SEQ.fetch_add(1, Ordering::Relaxed);
908        let id = format!("{ts}-{seq}");
909        let debug_entry = HttpDebugLogEntry {
910            id: &id,
911            timestamp_ms: ts,
912            request_id,
913            trace_id: trace_id.clone(),
914            service,
915            method,
916            path,
917            status_code,
918            duration_ms,
919            ttfb_ms,
920            station_name,
921            provider_id: provider_id.clone(),
922            endpoint_id: endpoint_id.clone(),
923            provider_endpoint_key: provider_endpoint_key.clone(),
924            upstream_base_url,
925            session_id: session_id.clone(),
926            session_identity_source,
927            cwd: cwd.clone(),
928            model: model.clone(),
929            reasoning_effort: reasoning_effort.clone(),
930            service_tier: service_tier.clone(),
931            codex_bridge: codex_bridge.clone(),
932            usage: usage.clone(),
933            route_decision: route_decision.clone(),
934            retry: retry.clone(),
935            http_debug: h,
936        };
937
938        let debug_path = debug_log_path();
939        ensure_log_parent(&debug_path);
940        let mut wrote_debug = false;
941        if let Ok(line) = serde_json::to_string(&debug_entry) {
942            wrote_debug = append_json_line(&debug_path, opt, &line);
943        }
944
945        if wrote_debug {
946            http_debug_ref = Some(HttpDebugRef {
947                id,
948                file: "requests_debug.jsonl".to_string(),
949            });
950        } else {
951            // If we failed to write the debug entry, fall back to inline logging to avoid losing data.
952            let HttpDebugLogEntry { http_debug, .. } = debug_entry;
953            http_debug_for_main = Some(http_debug);
954        }
955    }
956
957    let entry = RequestLog {
958        timestamp_ms: ts,
959        request_id,
960        trace_id,
961        service,
962        method,
963        path,
964        status_code,
965        duration_ms,
966        ttfb_ms,
967        station_name,
968        provider_id,
969        endpoint_id,
970        provider_endpoint_key,
971        upstream_base_url,
972        session_id,
973        session_identity_source,
974        cwd,
975        model,
976        reasoning_effort,
977        service_tier,
978        codex_bridge,
979        usage,
980        http_debug: http_debug_for_main,
981        http_debug_ref,
982        route_decision,
983        retry,
984    };
985
986    if let Ok(line) = serde_json::to_string(&entry) {
987        let _ = append_json_line(&log_file_path, opt, &line);
988    }
989
990    if let Ok(payload) = serde_json::to_value(&entry) {
991        append_control_trace_payload(
992            opt,
993            "request_completed",
994            Some(service),
995            request_id,
996            Some("request_completed"),
997            ts,
998            payload,
999        );
1000    }
1001}
1002
1003#[cfg(test)]
1004#[path = "logging/tests.rs"]
1005mod tests;