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 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 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 #[serde(skip_serializing_if = "Option::is_none")]
151 pub authorization: Option<String>,
152 #[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 #[serde(skip_serializing_if = "Option::is_none")]
222 pub upstream_headers_ms: Option<u64>,
223 #[serde(skip_serializing_if = "Option::is_none")]
225 pub upstream_first_chunk_ms: Option<u64>,
226 #[serde(skip_serializing_if = "Option::is_none")]
228 pub upstream_body_read_ms: Option<u64>,
229 #[serde(skip_serializing_if = "Option::is_none")]
231 pub upstream_error_class: Option<String>,
232 #[serde(skip_serializing_if = "Option::is_none")]
234 pub upstream_error_hint: Option<String>,
235 #[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 #[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 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 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 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;