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