use std::collections::BTreeMap; use std::collections::VecDeque; use std::collections::btree_map::Entry; use std::fs; use std::io::Write; use std::io::{self}; use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; use std::time::Duration; use anyhow::Result; use anyhow::anyhow; use codex_login::AuthEnvTelemetry; use codex_protocol::ThreadId; use codex_protocol::protocol::SessionSource; use tracing::Event; use tracing::Level; use tracing::field::Visit; use tracing_subscriber::Layer; use tracing_subscriber::filter::Targets; use tracing_subscriber::fmt::writer::MakeWriter; use tracing_subscriber::registry::LookupSpan; pub(crate) mod feedback_diagnostics; pub use feedback_diagnostics::FEEDBACK_DIAGNOSTICS_ATTACHMENT_FILENAME; pub use feedback_diagnostics::FeedbackDiagnostic; pub use feedback_diagnostics::FeedbackDiagnostics; const DEFAULT_MAX_BYTES: usize = 4 * 1024 * 1024; // 4 MiB const SENTRY_DSN: &str = "https://ae32ed50620d7a7792c1ce5df38b3e3e@o33249.ingest.us.sentry.io/4510195390611458"; const UPLOAD_TIMEOUT_SECS: u64 = 10; const FEEDBACK_TAGS_TARGET: &str = "feedback_tags"; const MAX_FEEDBACK_TAGS: usize = 64; /// Structured request/auth fields that should be attached to feedback uploads. pub struct FeedbackRequestTags<'a> { pub endpoint: &'a str, pub auth_header_attached: bool, pub auth_header_name: Option<&'a str>, pub auth_mode: Option<&'a str>, pub auth_retry_after_unauthorized: Option, pub auth_recovery_mode: Option<&'a str>, pub auth_recovery_phase: Option<&'a str>, pub auth_connection_reused: Option, pub auth_request_id: Option<&'a str>, pub auth_cf_ray: Option<&'a str>, pub auth_error: Option<&'a str>, pub auth_error_code: Option<&'a str>, pub auth_recovery_followup_success: Option, pub auth_recovery_followup_status: Option, } struct FeedbackRequestSnapshot<'a> { endpoint: &'a str, auth_header_attached: bool, auth_header_name: &'a str, auth_mode: &'a str, auth_retry_after_unauthorized: String, auth_recovery_mode: &'a str, auth_recovery_phase: &'a str, auth_connection_reused: String, auth_request_id: &'a str, auth_cf_ray: &'a str, auth_error: &'a str, auth_error_code: &'a str, auth_recovery_followup_success: String, auth_recovery_followup_status: String, } impl<'a> FeedbackRequestSnapshot<'a> { fn from_tags(tags: &'a FeedbackRequestTags<'a>) -> Self { Self { endpoint: tags.endpoint, auth_header_attached: tags.auth_header_attached, auth_header_name: tags.auth_header_name.unwrap_or(""), auth_mode: tags.auth_mode.unwrap_or(""), auth_retry_after_unauthorized: tags .auth_retry_after_unauthorized .map_or_else(String::new, |value| value.to_string()), auth_recovery_mode: tags.auth_recovery_mode.unwrap_or(""), auth_recovery_phase: tags.auth_recovery_phase.unwrap_or(""), auth_connection_reused: tags .auth_connection_reused .map_or_else(String::new, |value| value.to_string()), auth_request_id: tags.auth_request_id.unwrap_or(""), auth_cf_ray: tags.auth_cf_ray.unwrap_or(""), auth_error: tags.auth_error.unwrap_or(""), auth_error_code: tags.auth_error_code.unwrap_or(""), auth_recovery_followup_success: tags .auth_recovery_followup_success .map_or_else(String::new, |value| value.to_string()), auth_recovery_followup_status: tags .auth_recovery_followup_status .map_or_else(String::new, |value| value.to_string()), } } } pub fn emit_feedback_request_tags(tags: &FeedbackRequestTags<'_>) { let snapshot = FeedbackRequestSnapshot::from_tags(tags); tracing::info!( target: FEEDBACK_TAGS_TARGET, endpoint = tracing::field::debug(snapshot.endpoint), auth_header_attached = tracing::field::debug(snapshot.auth_header_attached), auth_header_name = tracing::field::debug(snapshot.auth_header_name), auth_mode = tracing::field::debug(snapshot.auth_mode), auth_retry_after_unauthorized = tracing::field::debug(&snapshot.auth_retry_after_unauthorized), auth_recovery_mode = tracing::field::debug(snapshot.auth_recovery_mode), auth_recovery_phase = tracing::field::debug(snapshot.auth_recovery_phase), auth_connection_reused = tracing::field::debug(&snapshot.auth_connection_reused), auth_request_id = tracing::field::debug(snapshot.auth_request_id), auth_cf_ray = tracing::field::debug(snapshot.auth_cf_ray), auth_error = tracing::field::debug(snapshot.auth_error), auth_error_code = tracing::field::debug(snapshot.auth_error_code), auth_recovery_followup_success = tracing::field::debug(&snapshot.auth_recovery_followup_success), auth_recovery_followup_status = tracing::field::debug(&snapshot.auth_recovery_followup_status), ); } pub fn emit_feedback_request_tags_with_auth_env( tags: &FeedbackRequestTags<'_>, auth_env: &AuthEnvTelemetry, ) { let snapshot = FeedbackRequestSnapshot::from_tags(tags); tracing::info!( target: FEEDBACK_TAGS_TARGET, endpoint = tracing::field::debug(snapshot.endpoint), auth_header_attached = tracing::field::debug(snapshot.auth_header_attached), auth_header_name = tracing::field::debug(snapshot.auth_header_name), auth_mode = tracing::field::debug(snapshot.auth_mode), auth_retry_after_unauthorized = tracing::field::debug(&snapshot.auth_retry_after_unauthorized), auth_recovery_mode = tracing::field::debug(snapshot.auth_recovery_mode), auth_recovery_phase = tracing::field::debug(snapshot.auth_recovery_phase), auth_connection_reused = tracing::field::debug(&snapshot.auth_connection_reused), auth_request_id = tracing::field::debug(snapshot.auth_request_id), auth_cf_ray = tracing::field::debug(snapshot.auth_cf_ray), auth_error = tracing::field::debug(snapshot.auth_error), auth_error_code = tracing::field::debug(snapshot.auth_error_code), auth_recovery_followup_success = tracing::field::debug(&snapshot.auth_recovery_followup_success), auth_recovery_followup_status = tracing::field::debug(&snapshot.auth_recovery_followup_status), auth_env_openai_api_key_present = tracing::field::debug(auth_env.openai_api_key_env_present), auth_env_codex_api_key_present = tracing::field::debug(auth_env.codex_api_key_env_present), auth_env_codex_api_key_enabled = tracing::field::debug(auth_env.codex_api_key_env_enabled), // Custom provider `env_key` is arbitrary config text, so emit only a safe bucket. auth_env_provider_key_name = tracing::field::debug( auth_env.provider_env_key_name.as_deref().unwrap_or("") ), auth_env_provider_key_present = tracing::field::debug( &auth_env.provider_env_key_present.map_or_else(String::new, |value| value.to_string()) ), auth_env_refresh_token_url_override_present = tracing::field::debug( auth_env.refresh_token_url_override_present ), ); } #[derive(Clone)] pub struct CodexFeedback { inner: Arc, } impl Default for CodexFeedback { fn default() -> Self { Self::new() } } impl CodexFeedback { pub fn new() -> Self { Self::with_capacity(DEFAULT_MAX_BYTES) } pub(crate) fn with_capacity(max_bytes: usize) -> Self { Self { inner: Arc::new(FeedbackInner::new(max_bytes)), } } pub fn make_writer(&self) -> FeedbackMakeWriter { FeedbackMakeWriter { inner: self.inner.clone(), } } /// Returns a [`tracing_subscriber`] layer that captures full-fidelity logs into this feedback /// ring buffer. /// /// This is intended for initialization code so call sites don't have to duplicate the exact /// `fmt::layer()` configuration and filter logic. pub fn logger_layer(&self) -> impl Layer + Send + Sync + 'static where S: tracing::Subscriber + for<'a> LookupSpan<'a>, { tracing_subscriber::fmt::layer() .with_writer(self.make_writer()) .with_timer(tracing_subscriber::fmt::time::SystemTime) .with_ansi(false) .with_target(false) // Capture everything, regardless of the caller's `RUST_LOG`, so feedback includes the // full trace when the user uploads a report. .with_filter(Targets::new().with_default(Level::TRACE)) } /// Returns a [`tracing_subscriber`] layer that collects structured metadata for feedback. /// /// Events with `target: "feedback_tags"` are treated as key/value tags to attach to feedback /// uploads later. pub fn metadata_layer(&self) -> impl Layer + Send + Sync + 'static where S: tracing::Subscriber + for<'a> LookupSpan<'a>, { FeedbackMetadataLayer { inner: self.inner.clone(), } .with_filter(Targets::new().with_target(FEEDBACK_TAGS_TARGET, Level::TRACE)) } pub fn snapshot(&self, session_id: Option) -> FeedbackSnapshot { let bytes = { #[allow(clippy::expect_used)] let guard = self.inner.ring.lock().expect("mutex poisoned"); guard.snapshot_bytes() }; let tags = { #[allow(clippy::expect_used)] let guard = self.inner.tags.lock().expect("mutex poisoned"); guard.clone() }; FeedbackSnapshot { bytes, tags, feedback_diagnostics: FeedbackDiagnostics::collect_from_env(), thread_id: session_id .map(|id| id.to_string()) .unwrap_or("no-active-thread-".to_string() + &ThreadId::new().to_string()), } } } struct FeedbackInner { ring: Mutex, tags: Mutex>, } impl FeedbackInner { fn new(max_bytes: usize) -> Self { Self { ring: Mutex::new(RingBuffer::new(max_bytes)), tags: Mutex::new(BTreeMap::new()), } } } #[derive(Clone)] pub struct FeedbackMakeWriter { inner: Arc, } impl<'a> MakeWriter<'a> for FeedbackMakeWriter { type Writer = FeedbackWriter; fn make_writer(&'a self) -> Self::Writer { FeedbackWriter { inner: self.inner.clone(), } } } pub struct FeedbackWriter { inner: Arc, } impl Write for FeedbackWriter { fn write(&mut self, buf: &[u8]) -> io::Result { let mut guard = self.inner.ring.lock().map_err(|_| io::ErrorKind::Other)?; guard.push_bytes(buf); Ok(buf.len()) } fn flush(&mut self) -> io::Result<()> { Ok(()) } } struct RingBuffer { max: usize, buf: VecDeque, } impl RingBuffer { fn new(capacity: usize) -> Self { Self { max: capacity, buf: VecDeque::with_capacity(capacity), } } fn len(&self) -> usize { self.buf.len() } fn push_bytes(&mut self, data: &[u8]) { if data.is_empty() { return; } // If the incoming chunk is larger than capacity, keep only the trailing bytes. if data.len() >= self.max { self.buf.clear(); let start = data.len() - self.max; self.buf.extend(data[start..].iter().copied()); return; } // Evict from the front if we would exceed capacity. let needed = self.len() + data.len(); if needed > self.max { let to_drop = needed - self.max; for _ in 0..to_drop { let _ = self.buf.pop_front(); } } self.buf.extend(data.iter().copied()); } fn snapshot_bytes(&self) -> Vec { self.buf.iter().copied().collect() } } pub struct FeedbackSnapshot { bytes: Vec, tags: BTreeMap, feedback_diagnostics: FeedbackDiagnostics, pub thread_id: String, } pub struct FeedbackAttachmentPath { pub path: PathBuf, /// Optional filename to use for the uploaded attachment instead of `path`'s basename. pub attachment_filename_override: Option, } pub struct FeedbackUploadOptions<'a> { pub classification: &'a str, pub reason: Option<&'a str>, pub tags: Option<&'a BTreeMap>, pub include_logs: bool, pub extra_attachment_paths: &'a [FeedbackAttachmentPath], pub session_source: Option, pub logs_override: Option>, } impl FeedbackSnapshot { pub(crate) fn as_bytes(&self) -> &[u8] { &self.bytes } pub fn feedback_diagnostics(&self) -> &FeedbackDiagnostics { &self.feedback_diagnostics } pub fn with_feedback_diagnostics(mut self, feedback_diagnostics: FeedbackDiagnostics) -> Self { self.feedback_diagnostics = feedback_diagnostics; self } pub fn feedback_diagnostics_attachment_text(&self, include_logs: bool) -> Option { if !include_logs { return None; } self.feedback_diagnostics.attachment_text() } pub fn save_to_temp_file(&self) -> io::Result { let dir = std::env::temp_dir(); let filename = format!("codex-feedback-{}.log", self.thread_id); let path = dir.join(filename); fs::write(&path, self.as_bytes())?; Ok(path) } /// Upload feedback to Sentry with optional attachments. pub fn upload_feedback(&self, options: FeedbackUploadOptions<'_>) -> Result<()> { use std::str::FromStr; use std::sync::Arc; use sentry::Client; use sentry::ClientOptions; use sentry::protocol::Envelope; use sentry::protocol::EnvelopeItem; use sentry::protocol::Event; use sentry::protocol::Level; use sentry::transports::DefaultTransportFactory; use sentry::types::Dsn; // Build Sentry client let client = Client::from_config(ClientOptions { dsn: Some(Dsn::from_str(SENTRY_DSN).map_err(|e| anyhow!("invalid DSN: {e}"))?), transport: Some(Arc::new(DefaultTransportFactory {})), ..Default::default() }); let tags = self.upload_tags( options.classification, options.reason, options.tags, options.session_source.as_ref(), ); let level = match options.classification { "bug" | "bad_result" | "safety_check" => Level::Error, _ => Level::Info, }; let mut envelope = Envelope::new(); let title = format!( "[{}]: Codex session {}", display_classification(options.classification), self.thread_id ); let mut event = Event { level, message: Some(title.clone()), tags, ..Default::default() }; if let Some(r) = options.reason { use sentry::protocol::Exception; use sentry::protocol::Values; event.exception = Values::from(vec![Exception { ty: title, value: Some(r.to_string()), ..Default::default() }]); } envelope.add_item(EnvelopeItem::Event(event)); for attachment in self.feedback_attachments( options.include_logs, options.extra_attachment_paths, options.logs_override, ) { envelope.add_item(EnvelopeItem::Attachment(attachment)); } client.send_envelope(envelope); client.flush(Some(Duration::from_secs(UPLOAD_TIMEOUT_SECS))); Ok(()) } fn upload_tags( &self, classification: &str, reason: Option<&str>, client_tags: Option<&BTreeMap>, session_source: Option<&SessionSource>, ) -> BTreeMap { let cli_version = env!("CARGO_PKG_VERSION"); let mut tags = BTreeMap::from([ (String::from("thread_id"), self.thread_id.to_string()), (String::from("classification"), classification.to_string()), (String::from("cli_version"), cli_version.to_string()), ]); if let Some(source) = session_source { tags.insert(String::from("session_source"), source.to_string()); } if let Some(r) = reason { tags.insert(String::from("reason"), r.to_string()); } let reserved = [ "thread_id", "classification", "cli_version", "session_source", "reason", ]; if let Some(client_tags) = client_tags { for (key, value) in client_tags { if reserved.contains(&key.as_str()) { continue; } if let Entry::Vacant(entry) = tags.entry(key.clone()) { entry.insert(value.clone()); } } } for (key, value) in &self.tags { if reserved.contains(&key.as_str()) { continue; } if let Entry::Vacant(entry) = tags.entry(key.clone()) { entry.insert(value.clone()); } } tags } fn feedback_attachments( &self, include_logs: bool, extra_attachment_paths: &[FeedbackAttachmentPath], logs_override: Option>, ) -> Vec { use sentry::protocol::Attachment; let mut attachments = Vec::new(); if include_logs { attachments.push(Attachment { buffer: logs_override.unwrap_or_else(|| self.bytes.clone()), filename: String::from("codex-logs.log"), content_type: Some("text/plain".to_string()), ty: None, }); } if let Some(text) = self.feedback_diagnostics_attachment_text(include_logs) { attachments.push(Attachment { buffer: text.into_bytes(), filename: FEEDBACK_DIAGNOSTICS_ATTACHMENT_FILENAME.to_string(), content_type: Some("text/plain".to_string()), ty: None, }); } for attachment_path in extra_attachment_paths { let data = match fs::read(&attachment_path.path) { Ok(data) => data, Err(err) => { tracing::warn!( path = %attachment_path.path.display(), error = %err, "failed to read log attachment; skipping" ); continue; } }; let filename = attachment_path .attachment_filename_override .clone() .unwrap_or_else(|| { attachment_path .path .file_name() .map(|s| s.to_string_lossy().to_string()) .unwrap_or_else(|| "extra-log.log".to_string()) }); attachments.push(Attachment { buffer: data, filename, content_type: Some("text/plain".to_string()), ty: None, }); } attachments } } fn display_classification(classification: &str) -> String { match classification { "bug" => "Bug".to_string(), "bad_result" => "Bad result".to_string(), "good_result" => "Good result".to_string(), "safety_check" => "Safety check".to_string(), _ => "Other".to_string(), } } #[derive(Clone)] struct FeedbackMetadataLayer { inner: Arc, } impl Layer for FeedbackMetadataLayer where S: tracing::Subscriber + for<'a> LookupSpan<'a>, { fn on_event(&self, event: &Event<'_>, _ctx: tracing_subscriber::layer::Context<'_, S>) { // This layer is filtered by `Targets`, but keep the guard anyway in case it is used without // the filter. if event.metadata().target() != FEEDBACK_TAGS_TARGET { return; } let mut visitor = FeedbackTagsVisitor::default(); event.record(&mut visitor); if visitor.tags.is_empty() { return; } #[allow(clippy::expect_used)] let mut guard = self.inner.tags.lock().expect("mutex poisoned"); for (key, value) in visitor.tags { if guard.len() >= MAX_FEEDBACK_TAGS && !guard.contains_key(&key) { continue; } guard.insert(key, value); } } } #[derive(Default)] struct FeedbackTagsVisitor { tags: BTreeMap, } impl Visit for FeedbackTagsVisitor { fn record_i64(&mut self, field: &tracing::field::Field, value: i64) { self.tags .insert(field.name().to_string(), value.to_string()); } fn record_u64(&mut self, field: &tracing::field::Field, value: u64) { self.tags .insert(field.name().to_string(), value.to_string()); } fn record_bool(&mut self, field: &tracing::field::Field, value: bool) { self.tags .insert(field.name().to_string(), value.to_string()); } fn record_f64(&mut self, field: &tracing::field::Field, value: f64) { self.tags .insert(field.name().to_string(), value.to_string()); } fn record_str(&mut self, field: &tracing::field::Field, value: &str) { self.tags .insert(field.name().to_string(), value.to_string()); } fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { self.tags .insert(field.name().to_string(), format!("{value:?}")); } } #[cfg(test)] mod tests { use std::ffi::OsStr; use std::fs; use super::*; use crate::FeedbackDiagnostic; use pretty_assertions::assert_eq; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; #[test] fn ring_buffer_drops_front_when_full() { let fb = CodexFeedback::with_capacity(/*max_bytes*/ 8); { let mut w = fb.make_writer().make_writer(); w.write_all(b"abcdefgh").unwrap(); w.write_all(b"ij").unwrap(); } let snap = fb.snapshot(/*session_id*/ None); // Capacity 8: after writing 10 bytes, we should keep the last 8. pretty_assertions::assert_eq!(std::str::from_utf8(snap.as_bytes()).unwrap(), "cdefghij"); } #[test] fn metadata_layer_records_tags_from_feedback_target() { let fb = CodexFeedback::new(); let _guard = tracing_subscriber::registry() .with(fb.metadata_layer()) .set_default(); tracing::info!(target: FEEDBACK_TAGS_TARGET, model = "gpt-5", cached = true, "tags"); let snap = fb.snapshot(/*session_id*/ None); pretty_assertions::assert_eq!(snap.tags.get("model").map(String::as_str), Some("gpt-5")); pretty_assertions::assert_eq!(snap.tags.get("cached").map(String::as_str), Some("true")); } #[test] fn feedback_attachments_gate_connectivity_diagnostics() { let extra_filename = format!("codex-feedback-extra-{}.jsonl", ThreadId::new()); let extra_path = std::env::temp_dir().join(&extra_filename); let extra_attachment_path = FeedbackAttachmentPath { path: extra_path.clone(), attachment_filename_override: None, }; fs::write(&extra_path, "rollout").expect("extra attachment should be written"); let snapshot_with_diagnostics = CodexFeedback::new() .snapshot(/*session_id*/ None) .with_feedback_diagnostics(FeedbackDiagnostics::new(vec![FeedbackDiagnostic { headline: "Proxy environment variables are set and may affect connectivity." .to_string(), details: vec!["HTTPS_PROXY = https://example.com:443".to_string()], }])); let attachments_with_diagnostics = snapshot_with_diagnostics.feedback_attachments( /*include_logs*/ true, std::slice::from_ref(&extra_attachment_path), Some(vec![1]), ); assert_eq!( attachments_with_diagnostics .iter() .map(|attachment| attachment.filename.as_str()) .collect::>(), vec![ "codex-logs.log", FEEDBACK_DIAGNOSTICS_ATTACHMENT_FILENAME, extra_filename.as_str() ] ); assert_eq!(attachments_with_diagnostics[0].buffer, vec![1]); assert_eq!( attachments_with_diagnostics[1].buffer, b"Connectivity diagnostics\n\n- Proxy environment variables are set and may affect connectivity.\n - HTTPS_PROXY = https://example.com:443".to_vec() ); assert_eq!(attachments_with_diagnostics[2].buffer, b"rollout".to_vec()); assert_eq!( OsStr::new(attachments_with_diagnostics[2].filename.as_str()), OsStr::new(extra_filename.as_str()) ); let attachments_without_diagnostics = CodexFeedback::new() .snapshot(/*session_id*/ None) .with_feedback_diagnostics(FeedbackDiagnostics::default()) .feedback_attachments(/*include_logs*/ true, &[], Some(vec![1])); assert_eq!( attachments_without_diagnostics .iter() .map(|attachment| attachment.filename.as_str()) .collect::>(), vec!["codex-logs.log"] ); assert_eq!(attachments_without_diagnostics[0].buffer, vec![1]); fs::remove_file(extra_path).expect("extra attachment should be removed"); } #[test] fn upload_tags_include_client_tags_and_preserve_reserved_fields() { let mut tags = BTreeMap::new(); tags.insert("thread_id".to_string(), "wrong-thread".to_string()); tags.insert("turn_id".to_string(), "wrong-turn".to_string()); tags.insert( "classification".to_string(), "wrong-classification".to_string(), ); tags.insert("cli_version".to_string(), "wrong-version".to_string()); tags.insert("session_source".to_string(), "wrong-source".to_string()); tags.insert("reason".to_string(), "wrong-reason".to_string()); tags.insert("account_id".to_string(), "actual-account".to_string()); tags.insert("model".to_string(), "gpt-5".to_string()); let snapshot = FeedbackSnapshot { bytes: Vec::new(), tags, feedback_diagnostics: FeedbackDiagnostics::default(), thread_id: "thread-123".to_string(), }; let mut client_tags = BTreeMap::new(); client_tags.insert("thread_id".to_string(), "wrong-client-thread".to_string()); client_tags.insert("turn_id".to_string(), "turn-456".to_string()); client_tags.insert( "classification".to_string(), "wrong-client-classification".to_string(), ); client_tags.insert( "cli_version".to_string(), "wrong-client-version".to_string(), ); client_tags.insert( "session_source".to_string(), "wrong-client-source".to_string(), ); client_tags.insert("reason".to_string(), "wrong-client-reason".to_string()); client_tags.insert("client_tag".to_string(), "from-client".to_string()); let upload_tags = snapshot.upload_tags( "bug", Some("actual reason"), Some(&client_tags), Some(&SessionSource::Cli), ); assert_eq!( upload_tags.get("thread_id").map(String::as_str), Some("thread-123") ); assert_eq!( upload_tags.get("turn_id").map(String::as_str), Some("turn-456") ); assert_eq!( upload_tags.get("classification").map(String::as_str), Some("bug") ); assert_eq!( upload_tags.get("session_source").map(String::as_str), Some("cli") ); assert_eq!( upload_tags.get("reason").map(String::as_str), Some("actual reason") ); assert_eq!( upload_tags.get("account_id").map(String::as_str), Some("actual-account") ); assert_eq!( upload_tags.get("client_tag").map(String::as_str), Some("from-client") ); assert_eq!(upload_tags.get("model").map(String::as_str), Some("gpt-5")); } }