1use std::collections::BTreeMap;
2use std::collections::VecDeque;
3use std::collections::btree_map::Entry;
4use std::fs;
5use std::io::Write;
6use std::io::{self};
7use std::path::Path;
8use std::path::PathBuf;
9use std::sync::Arc;
10use std::sync::Mutex;
11use std::time::Duration;
12
13use anyhow::Result;
14use anyhow::anyhow;
15use codex_login::AuthEnvTelemetry;
16use codex_protocol::ThreadId;
17use codex_protocol::protocol::SessionSource;
18use tracing::Event;
19use tracing::Level;
20use tracing::field::Visit;
21use tracing::level_filters::LevelFilter;
22use tracing_subscriber::Layer;
23use tracing_subscriber::filter::Targets;
24use tracing_subscriber::fmt::writer::MakeWriter;
25use tracing_subscriber::registry::LookupSpan;
26
27pub(crate) mod feedback_diagnostics;
28pub use feedback_diagnostics::FEEDBACK_DIAGNOSTICS_ATTACHMENT_FILENAME;
29pub use feedback_diagnostics::FeedbackDiagnostic;
30pub use feedback_diagnostics::FeedbackDiagnostics;
31
32pub const DOCTOR_REPORT_ATTACHMENT_FILENAME: &str = "codex-doctor-report.json";
34pub const CODEX_APPS_TOOLS_CACHE_ATTACHMENT_FILENAME: &str = "codex-apps-tools-cache.json";
36pub const CODEX_APP_DIRECTORY_CACHE_ATTACHMENT_FILENAME: &str = "codex-app-directory-cache.json";
38pub const WINDOWS_SANDBOX_LOG_ATTACHMENT_FILENAME: &str = "windows-sandbox.log";
40const DEFAULT_MAX_BYTES: usize = 4 * 1024 * 1024; const SENTRY_DSN: &str =
42 "https://ae32ed50620d7a7792c1ce5df38b3e3e@o33249.ingest.us.sentry.io/4510195390611458";
43const UPLOAD_TIMEOUT_SECS: u64 = 10;
44const FEEDBACK_TAGS_TARGET: &str = "feedback_tags";
45const MAX_FEEDBACK_TAGS: usize = 64;
46
47pub struct FeedbackRequestTags<'a> {
49 pub endpoint: &'a str,
50 pub auth_header_attached: bool,
51 pub auth_header_name: Option<&'a str>,
52 pub auth_mode: Option<&'a str>,
53 pub auth_retry_after_unauthorized: Option<bool>,
54 pub auth_recovery_mode: Option<&'a str>,
55 pub auth_recovery_phase: Option<&'a str>,
56 pub auth_connection_reused: Option<bool>,
57 pub auth_request_id: Option<&'a str>,
58 pub auth_cf_ray: Option<&'a str>,
59 pub auth_error: Option<&'a str>,
60 pub auth_error_code: Option<&'a str>,
61 pub auth_recovery_followup_success: Option<bool>,
62 pub auth_recovery_followup_status: Option<u16>,
63}
64
65struct FeedbackRequestSnapshot<'a> {
66 endpoint: &'a str,
67 auth_header_attached: bool,
68 auth_header_name: &'a str,
69 auth_mode: &'a str,
70 auth_retry_after_unauthorized: String,
71 auth_recovery_mode: &'a str,
72 auth_recovery_phase: &'a str,
73 auth_connection_reused: String,
74 auth_request_id: &'a str,
75 auth_cf_ray: &'a str,
76 auth_error: &'a str,
77 auth_error_code: &'a str,
78 auth_recovery_followup_success: String,
79 auth_recovery_followup_status: String,
80}
81
82impl<'a> FeedbackRequestSnapshot<'a> {
83 fn from_tags(tags: &'a FeedbackRequestTags<'a>) -> Self {
84 Self {
85 endpoint: tags.endpoint,
86 auth_header_attached: tags.auth_header_attached,
87 auth_header_name: tags.auth_header_name.unwrap_or(""),
88 auth_mode: tags.auth_mode.unwrap_or(""),
89 auth_retry_after_unauthorized: tags
90 .auth_retry_after_unauthorized
91 .map_or_else(String::new, |value| value.to_string()),
92 auth_recovery_mode: tags.auth_recovery_mode.unwrap_or(""),
93 auth_recovery_phase: tags.auth_recovery_phase.unwrap_or(""),
94 auth_connection_reused: tags
95 .auth_connection_reused
96 .map_or_else(String::new, |value| value.to_string()),
97 auth_request_id: tags.auth_request_id.unwrap_or(""),
98 auth_cf_ray: tags.auth_cf_ray.unwrap_or(""),
99 auth_error: tags.auth_error.unwrap_or(""),
100 auth_error_code: tags.auth_error_code.unwrap_or(""),
101 auth_recovery_followup_success: tags
102 .auth_recovery_followup_success
103 .map_or_else(String::new, |value| value.to_string()),
104 auth_recovery_followup_status: tags
105 .auth_recovery_followup_status
106 .map_or_else(String::new, |value| value.to_string()),
107 }
108 }
109}
110
111pub fn emit_feedback_request_tags(tags: &FeedbackRequestTags<'_>) {
112 let snapshot = FeedbackRequestSnapshot::from_tags(tags);
113 tracing::info!(
114 target: FEEDBACK_TAGS_TARGET,
115 endpoint = tracing::field::debug(snapshot.endpoint),
116 auth_header_attached = tracing::field::debug(snapshot.auth_header_attached),
117 auth_header_name = tracing::field::debug(snapshot.auth_header_name),
118 auth_mode = tracing::field::debug(snapshot.auth_mode),
119 auth_retry_after_unauthorized = tracing::field::debug(&snapshot.auth_retry_after_unauthorized),
120 auth_recovery_mode = tracing::field::debug(snapshot.auth_recovery_mode),
121 auth_recovery_phase = tracing::field::debug(snapshot.auth_recovery_phase),
122 auth_connection_reused = tracing::field::debug(&snapshot.auth_connection_reused),
123 auth_request_id = tracing::field::debug(snapshot.auth_request_id),
124 auth_cf_ray = tracing::field::debug(snapshot.auth_cf_ray),
125 auth_error = tracing::field::debug(snapshot.auth_error),
126 auth_error_code = tracing::field::debug(snapshot.auth_error_code),
127 auth_recovery_followup_success = tracing::field::debug(&snapshot.auth_recovery_followup_success),
128 auth_recovery_followup_status = tracing::field::debug(&snapshot.auth_recovery_followup_status),
129 );
130}
131
132pub fn emit_feedback_request_tags_with_auth_env(
133 tags: &FeedbackRequestTags<'_>,
134 auth_env: &AuthEnvTelemetry,
135) {
136 let snapshot = FeedbackRequestSnapshot::from_tags(tags);
137 tracing::info!(
138 target: FEEDBACK_TAGS_TARGET,
139 endpoint = tracing::field::debug(snapshot.endpoint),
140 auth_header_attached = tracing::field::debug(snapshot.auth_header_attached),
141 auth_header_name = tracing::field::debug(snapshot.auth_header_name),
142 auth_mode = tracing::field::debug(snapshot.auth_mode),
143 auth_retry_after_unauthorized = tracing::field::debug(&snapshot.auth_retry_after_unauthorized),
144 auth_recovery_mode = tracing::field::debug(snapshot.auth_recovery_mode),
145 auth_recovery_phase = tracing::field::debug(snapshot.auth_recovery_phase),
146 auth_connection_reused = tracing::field::debug(&snapshot.auth_connection_reused),
147 auth_request_id = tracing::field::debug(snapshot.auth_request_id),
148 auth_cf_ray = tracing::field::debug(snapshot.auth_cf_ray),
149 auth_error = tracing::field::debug(snapshot.auth_error),
150 auth_error_code = tracing::field::debug(snapshot.auth_error_code),
151 auth_recovery_followup_success = tracing::field::debug(&snapshot.auth_recovery_followup_success),
152 auth_recovery_followup_status = tracing::field::debug(&snapshot.auth_recovery_followup_status),
153 auth_env_openai_api_key_present = tracing::field::debug(auth_env.openai_api_key_env_present),
154 auth_env_codex_api_key_present = tracing::field::debug(auth_env.codex_api_key_env_present),
155 auth_env_codex_api_key_enabled = tracing::field::debug(auth_env.codex_api_key_env_enabled),
156 auth_env_provider_key_name = tracing::field::debug(
158 auth_env.provider_env_key_name.as_deref().unwrap_or("")
159 ),
160 auth_env_provider_key_present = tracing::field::debug(
161 &auth_env.provider_env_key_present.map_or_else(String::new, |value| value.to_string())
162 ),
163 auth_env_refresh_token_url_override_present = tracing::field::debug(
164 auth_env.refresh_token_url_override_present
165 ),
166 );
167}
168
169#[derive(Clone)]
170pub struct CodexFeedback {
171 inner: Arc<FeedbackInner>,
172}
173
174impl Default for CodexFeedback {
175 fn default() -> Self {
176 Self::new()
177 }
178}
179
180impl CodexFeedback {
181 pub fn new() -> Self {
182 Self::with_capacity(DEFAULT_MAX_BYTES)
183 }
184
185 pub(crate) fn with_capacity(max_bytes: usize) -> Self {
186 Self {
187 inner: Arc::new(FeedbackInner::new(max_bytes)),
188 }
189 }
190
191 pub fn make_writer(&self) -> FeedbackMakeWriter {
192 FeedbackMakeWriter {
193 inner: self.inner.clone(),
194 }
195 }
196
197 pub fn logger_layer<S>(&self) -> impl Layer<S> + Send + Sync + 'static
203 where
204 S: tracing::Subscriber + for<'a> LookupSpan<'a>,
205 {
206 tracing_subscriber::fmt::layer()
207 .with_writer(self.make_writer())
208 .with_timer(tracing_subscriber::fmt::time::SystemTime)
209 .with_ansi(false)
210 .with_target(false)
211 .with_filter(
214 Targets::new()
215 .with_default(Level::TRACE)
216 .with_target("codex_api::responses_websocket_timing", LevelFilter::OFF),
217 )
218 }
219
220 pub fn metadata_layer<S>(&self) -> impl Layer<S> + Send + Sync + 'static
225 where
226 S: tracing::Subscriber + for<'a> LookupSpan<'a>,
227 {
228 FeedbackMetadataLayer {
229 inner: self.inner.clone(),
230 }
231 .with_filter(Targets::new().with_target(FEEDBACK_TAGS_TARGET, Level::TRACE))
232 }
233
234 pub fn snapshot(&self, session_id: Option<ThreadId>) -> FeedbackSnapshot {
235 let bytes = {
236 #[allow(clippy::expect_used)]
237 let guard = self.inner.ring.lock().expect("mutex poisoned");
238 guard.snapshot_bytes()
239 };
240 let tags = {
241 #[allow(clippy::expect_used)]
242 let guard = self.inner.tags.lock().expect("mutex poisoned");
243 guard.clone()
244 };
245 FeedbackSnapshot {
246 bytes,
247 tags,
248 feedback_diagnostics: FeedbackDiagnostics::collect_from_env(),
249 thread_id: session_id
250 .map(|id| id.to_string())
251 .unwrap_or("no-active-thread-".to_string() + &ThreadId::new().to_string()),
252 }
253 }
254}
255
256struct FeedbackInner {
257 ring: Mutex<RingBuffer>,
258 tags: Mutex<BTreeMap<String, String>>,
259}
260
261impl FeedbackInner {
262 fn new(max_bytes: usize) -> Self {
263 Self {
264 ring: Mutex::new(RingBuffer::new(max_bytes)),
265 tags: Mutex::new(BTreeMap::new()),
266 }
267 }
268}
269
270#[derive(Clone)]
271pub struct FeedbackMakeWriter {
272 inner: Arc<FeedbackInner>,
273}
274
275impl<'a> MakeWriter<'a> for FeedbackMakeWriter {
276 type Writer = FeedbackWriter;
277
278 fn make_writer(&'a self) -> Self::Writer {
279 FeedbackWriter {
280 inner: self.inner.clone(),
281 }
282 }
283}
284
285pub struct FeedbackWriter {
286 inner: Arc<FeedbackInner>,
287}
288
289impl Write for FeedbackWriter {
290 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
291 let mut guard = self.inner.ring.lock().map_err(|_| io::ErrorKind::Other)?;
292 guard.push_bytes(buf);
293 Ok(buf.len())
294 }
295
296 fn flush(&mut self) -> io::Result<()> {
297 Ok(())
298 }
299}
300
301struct RingBuffer {
302 max: usize,
303 buf: VecDeque<u8>,
304}
305
306impl RingBuffer {
307 fn new(capacity: usize) -> Self {
308 Self {
309 max: capacity,
310 buf: VecDeque::with_capacity(capacity),
311 }
312 }
313
314 fn len(&self) -> usize {
315 self.buf.len()
316 }
317
318 fn push_bytes(&mut self, data: &[u8]) {
319 if data.is_empty() {
320 return;
321 }
322
323 if data.len() >= self.max {
325 self.buf.clear();
326 let start = data.len() - self.max;
327 self.buf.extend(data[start..].iter().copied());
328 return;
329 }
330
331 let needed = self.len() + data.len();
333 if needed > self.max {
334 let to_drop = needed - self.max;
335 for _ in 0..to_drop {
336 let _ = self.buf.pop_front();
337 }
338 }
339
340 self.buf.extend(data.iter().copied());
341 }
342
343 fn snapshot_bytes(&self) -> Vec<u8> {
344 self.buf.iter().copied().collect()
345 }
346}
347
348pub struct FeedbackSnapshot {
349 bytes: Vec<u8>,
350 tags: BTreeMap<String, String>,
351 feedback_diagnostics: FeedbackDiagnostics,
352 pub thread_id: String,
353}
354
355pub struct FeedbackAttachmentPath {
356 pub path: PathBuf,
357 pub attachment_filename_override: Option<String>,
359}
360
361pub struct FeedbackAttachment {
368 pub filename: String,
370 pub content_type: Option<String>,
372 pub buffer: Vec<u8>,
374}
375
376pub struct FeedbackUploadOptions<'a> {
382 pub classification: &'a str,
383 pub reason: Option<&'a str>,
384 pub tags: Option<&'a BTreeMap<String, String>>,
385 pub include_logs: bool,
386 pub extra_attachments: &'a [FeedbackAttachment],
392 pub extra_attachment_paths: &'a [FeedbackAttachmentPath],
393 pub session_source: Option<SessionSource>,
394 pub logs_override: Option<Vec<u8>>,
395}
396
397impl FeedbackSnapshot {
398 pub fn feedback_diagnostics(&self) -> &FeedbackDiagnostics {
399 &self.feedback_diagnostics
400 }
401
402 pub fn with_feedback_diagnostics(mut self, feedback_diagnostics: FeedbackDiagnostics) -> Self {
403 self.feedback_diagnostics = feedback_diagnostics;
404 self
405 }
406
407 pub fn feedback_diagnostics_attachment_text(&self, include_logs: bool) -> Option<String> {
408 if !include_logs {
409 return None;
410 }
411
412 self.feedback_diagnostics.attachment_text()
413 }
414
415 pub fn upload_feedback(&self, options: FeedbackUploadOptions<'_>) -> Result<()> {
417 use std::str::FromStr;
418 use std::sync::Arc;
419
420 use sentry::Client;
421 use sentry::ClientOptions;
422 use sentry::protocol::Envelope;
423 use sentry::protocol::EnvelopeItem;
424 use sentry::protocol::Event;
425 use sentry::protocol::Level;
426 use sentry::transports::DefaultTransportFactory;
427 use sentry::types::Dsn;
428
429 let client = Client::from_config(ClientOptions {
431 dsn: Some(Dsn::from_str(SENTRY_DSN).map_err(|e| anyhow!("invalid DSN: {e}"))?),
432 transport: Some(Arc::new(DefaultTransportFactory {})),
433 ..Default::default()
434 });
435
436 let tags = self.upload_tags(
437 options.classification,
438 options.reason,
439 options.tags,
440 options.session_source.as_ref(),
441 );
442
443 let level = match options.classification {
444 "bug" | "bad_result" | "safety_check" => Level::Error,
445 _ => Level::Info,
446 };
447
448 let mut envelope = Envelope::new();
449 let title = format!(
450 "[{}]: Codex session {}",
451 display_classification(options.classification),
452 self.thread_id
453 );
454
455 let mut event = Event {
456 level,
457 message: Some(title.clone()),
458 tags,
459 ..Default::default()
460 };
461 if let Some(r) = options.reason {
462 use sentry::protocol::Exception;
463 use sentry::protocol::Values;
464
465 event.exception = Values::from(vec![Exception {
466 ty: title,
467 value: Some(r.to_string()),
468 ..Default::default()
469 }]);
470 }
471 envelope.add_item(EnvelopeItem::Event(event));
472
473 for attachment in self.feedback_attachments(
474 options.include_logs,
475 options.extra_attachments,
476 options.extra_attachment_paths,
477 options.logs_override,
478 ) {
479 envelope.add_item(EnvelopeItem::Attachment(attachment));
480 }
481
482 client.send_envelope(envelope);
483 client.flush(Some(Duration::from_secs(UPLOAD_TIMEOUT_SECS)));
484 Ok(())
485 }
486
487 fn upload_tags(
488 &self,
489 classification: &str,
490 reason: Option<&str>,
491 client_tags: Option<&BTreeMap<String, String>>,
492 session_source: Option<&SessionSource>,
493 ) -> BTreeMap<String, String> {
494 let cli_version = env!("CARGO_PKG_VERSION");
495 let mut tags = BTreeMap::from([
496 (String::from("thread_id"), self.thread_id.to_string()),
497 (String::from("classification"), classification.to_string()),
498 (String::from("cli_version"), cli_version.to_string()),
499 ]);
500 if let Some(source) = session_source {
501 tags.insert(String::from("session_source"), source.to_string());
502 }
503 if let Some(r) = reason {
504 tags.insert(String::from("reason"), r.to_string());
505 }
506
507 let reserved = [
508 "thread_id",
509 "classification",
510 "cli_version",
511 "session_source",
512 "reason",
513 ];
514 if let Some(client_tags) = client_tags {
515 for (key, value) in client_tags {
516 if reserved.contains(&key.as_str()) {
517 continue;
518 }
519 if let Entry::Vacant(entry) = tags.entry(key.clone()) {
520 entry.insert(value.clone());
521 }
522 }
523 }
524 for (key, value) in &self.tags {
525 if reserved.contains(&key.as_str()) {
526 continue;
527 }
528 if let Entry::Vacant(entry) = tags.entry(key.clone()) {
529 entry.insert(value.clone());
530 }
531 }
532
533 tags
534 }
535
536 fn feedback_attachments(
537 &self,
538 include_logs: bool,
539 extra_attachments: &[FeedbackAttachment],
540 extra_attachment_paths: &[FeedbackAttachmentPath],
541 logs_override: Option<Vec<u8>>,
542 ) -> Vec<sentry::protocol::Attachment> {
543 use sentry::protocol::Attachment;
544
545 let mut attachments = Vec::new();
546
547 if include_logs {
548 attachments.push(Attachment {
549 buffer: logs_override.unwrap_or_else(|| self.bytes.clone()),
550 filename: String::from("codex-logs.log"),
551 content_type: Some("text/plain".to_string()),
552 ty: None,
553 });
554 }
555
556 attachments.extend(extra_attachments.iter().map(|attachment| Attachment {
557 buffer: attachment.buffer.clone(),
558 filename: attachment.filename.clone(),
559 content_type: attachment.content_type.clone(),
560 ty: None,
561 }));
562
563 if let Some(text) = self.feedback_diagnostics_attachment_text(include_logs) {
564 attachments.push(Attachment {
565 buffer: text.into_bytes(),
566 filename: FEEDBACK_DIAGNOSTICS_ATTACHMENT_FILENAME.to_string(),
567 content_type: Some("text/plain".to_string()),
568 ty: None,
569 });
570 }
571
572 for attachment_path in extra_attachment_paths {
573 let data = match fs::read(&attachment_path.path) {
574 Ok(data) => data,
575 Err(err) => {
576 tracing::warn!(
577 path = %attachment_path.path.display(),
578 error = %err,
579 "failed to read log attachment; skipping"
580 );
581 continue;
582 }
583 };
584 let filename = attachment_path
585 .attachment_filename_override
586 .clone()
587 .unwrap_or_else(|| {
588 attachment_path
589 .path
590 .file_name()
591 .map(|s| s.to_string_lossy().to_string())
592 .unwrap_or_else(|| "extra-log.log".to_string())
593 });
594 let content_type = match Path::new(&filename)
595 .extension()
596 .and_then(|extension| extension.to_str())
597 {
598 Some(extension) if extension.eq_ignore_ascii_case("jsonl") => {
599 "text/plain".to_string()
600 }
601 _ => mime_guess::from_path(&filename)
602 .first_or_octet_stream()
603 .essence_str()
604 .to_string(),
605 };
606 attachments.push(Attachment {
607 buffer: data,
608 filename,
609 content_type: Some(content_type),
610 ty: None,
611 });
612 }
613
614 attachments
615 }
616}
617
618fn display_classification(classification: &str) -> String {
619 match classification {
620 "bug" => "Bug".to_string(),
621 "bad_result" => "Bad result".to_string(),
622 "good_result" => "Good result".to_string(),
623 "safety_check" => "Safety check".to_string(),
624 _ => "Other".to_string(),
625 }
626}
627
628#[derive(Clone)]
629struct FeedbackMetadataLayer {
630 inner: Arc<FeedbackInner>,
631}
632
633impl<S> Layer<S> for FeedbackMetadataLayer
634where
635 S: tracing::Subscriber + for<'a> LookupSpan<'a>,
636{
637 fn on_event(&self, event: &Event<'_>, _ctx: tracing_subscriber::layer::Context<'_, S>) {
638 if event.metadata().target() != FEEDBACK_TAGS_TARGET {
641 return;
642 }
643
644 let mut visitor = FeedbackTagsVisitor::default();
645 event.record(&mut visitor);
646 if visitor.tags.is_empty() {
647 return;
648 }
649
650 #[allow(clippy::expect_used)]
651 let mut guard = self.inner.tags.lock().expect("mutex poisoned");
652 for (key, value) in visitor.tags {
653 if guard.len() >= MAX_FEEDBACK_TAGS && !guard.contains_key(&key) {
654 continue;
655 }
656 guard.insert(key, value);
657 }
658 }
659}
660
661#[derive(Default)]
662struct FeedbackTagsVisitor {
663 tags: BTreeMap<String, String>,
664}
665
666impl Visit for FeedbackTagsVisitor {
667 fn record_i64(&mut self, field: &tracing::field::Field, value: i64) {
668 self.tags
669 .insert(field.name().to_string(), value.to_string());
670 }
671
672 fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
673 self.tags
674 .insert(field.name().to_string(), value.to_string());
675 }
676
677 fn record_bool(&mut self, field: &tracing::field::Field, value: bool) {
678 self.tags
679 .insert(field.name().to_string(), value.to_string());
680 }
681
682 fn record_f64(&mut self, field: &tracing::field::Field, value: f64) {
683 self.tags
684 .insert(field.name().to_string(), value.to_string());
685 }
686
687 fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
688 self.tags
689 .insert(field.name().to_string(), value.to_string());
690 }
691
692 fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
693 self.tags
694 .insert(field.name().to_string(), format!("{value:?}"));
695 }
696}
697
698#[cfg(test)]
699mod tests {
700 use std::ffi::OsStr;
701 use std::fs;
702
703 use super::*;
704 use crate::FeedbackDiagnostic;
705 use pretty_assertions::assert_eq;
706 use tracing_subscriber::layer::SubscriberExt;
707 use tracing_subscriber::util::SubscriberInitExt;
708
709 #[test]
710 fn ring_buffer_drops_front_when_full() {
711 let fb = CodexFeedback::with_capacity(8);
712 {
713 let mut w = fb.make_writer().make_writer();
714 w.write_all(b"abcdefgh").unwrap();
715 w.write_all(b"ij").unwrap();
716 }
717 let snap = fb.snapshot(None);
718 pretty_assertions::assert_eq!(std::str::from_utf8(&snap.bytes).unwrap(), "cdefghij");
720 }
721
722 #[test]
723 fn logger_layer_excludes_responses_websocket_timing_payloads() {
724 let fb = CodexFeedback::new();
725 let _guard = tracing_subscriber::registry()
726 .with(fb.logger_layer())
727 .set_default();
728
729 tracing::trace!(target: "codex_api::responses_websocket_timing", payload = "secret");
730 tracing::trace!(target: "codex_feedback_test", "retained");
731
732 let logs = String::from_utf8(fb.snapshot(None).bytes).unwrap();
733 assert!(!logs.contains("secret"));
734 assert!(logs.contains("retained"));
735 }
736
737 #[test]
738 fn metadata_layer_records_tags_from_feedback_target() {
739 let fb = CodexFeedback::new();
740 let _guard = tracing_subscriber::registry()
741 .with(fb.metadata_layer())
742 .set_default();
743
744 tracing::info!(target: FEEDBACK_TAGS_TARGET, model = "gpt-5", cached = true, "tags");
745
746 let snap = fb.snapshot(None);
747 pretty_assertions::assert_eq!(snap.tags.get("model").map(String::as_str), Some("gpt-5"));
748 pretty_assertions::assert_eq!(snap.tags.get("cached").map(String::as_str), Some("true"));
749 }
750
751 #[test]
752 fn feedback_attachments_gate_connectivity_diagnostics() {
753 let extra_filename = format!("codex-feedback-extra-{}.jsonl", ThreadId::new());
754 let extra_path = std::env::temp_dir().join(&extra_filename);
755 let extra_attachment_path = FeedbackAttachmentPath {
756 path: extra_path.clone(),
757 attachment_filename_override: None,
758 };
759 fs::write(&extra_path, "rollout").expect("extra attachment should be written");
760
761 let snapshot_with_diagnostics = CodexFeedback::new()
762 .snapshot(None)
763 .with_feedback_diagnostics(FeedbackDiagnostics::new(vec![FeedbackDiagnostic {
764 headline: "Proxy environment variables are set and may affect connectivity."
765 .to_string(),
766 details: vec!["HTTPS_PROXY = https://example.com:443".to_string()],
767 }]));
768
769 let attachments_with_diagnostics = snapshot_with_diagnostics.feedback_attachments(
770 true,
771 &[FeedbackAttachment {
772 filename: DOCTOR_REPORT_ATTACHMENT_FILENAME.to_string(),
773 content_type: Some("application/json".to_string()),
774 buffer: b"{\"overallStatus\":\"ok\"}".to_vec(),
775 }],
776 std::slice::from_ref(&extra_attachment_path),
777 Some(vec![1]),
778 );
779
780 assert_eq!(
781 attachments_with_diagnostics
782 .iter()
783 .map(|attachment| attachment.filename.as_str())
784 .collect::<Vec<_>>(),
785 vec![
786 "codex-logs.log",
787 DOCTOR_REPORT_ATTACHMENT_FILENAME,
788 FEEDBACK_DIAGNOSTICS_ATTACHMENT_FILENAME,
789 extra_filename.as_str()
790 ]
791 );
792 assert_eq!(attachments_with_diagnostics[0].buffer, vec![1]);
793 assert_eq!(
794 attachments_with_diagnostics[1].buffer,
795 b"{\"overallStatus\":\"ok\"}".to_vec()
796 );
797 assert_eq!(
798 attachments_with_diagnostics[2].buffer,
799 b"Connectivity diagnostics\n\n- Proxy environment variables are set and may affect connectivity.\n - HTTPS_PROXY = https://example.com:443".to_vec()
800 );
801 assert_eq!(attachments_with_diagnostics[3].buffer, b"rollout".to_vec());
802 assert_eq!(
803 attachments_with_diagnostics[3].content_type.as_deref(),
804 Some("text/plain")
805 );
806 assert_eq!(
807 OsStr::new(attachments_with_diagnostics[3].filename.as_str()),
808 OsStr::new(extra_filename.as_str())
809 );
810 let attachments_without_diagnostics = CodexFeedback::new()
811 .snapshot(None)
812 .with_feedback_diagnostics(FeedbackDiagnostics::default())
813 .feedback_attachments(true, &[], &[], Some(vec![1]));
814
815 assert_eq!(
816 attachments_without_diagnostics
817 .iter()
818 .map(|attachment| attachment.filename.as_str())
819 .collect::<Vec<_>>(),
820 vec!["codex-logs.log"]
821 );
822 assert_eq!(attachments_without_diagnostics[0].buffer, vec![1]);
823 fs::remove_file(extra_path).expect("extra attachment should be removed");
824 }
825
826 #[test]
827 fn path_backed_attachments_use_binary_content_types() {
828 let suffix = ThreadId::new();
829 let gzip_filename = format!("codex-desktop-app-logs-{suffix}.tar.gz");
830 let unknown_filename = format!("codex-feedback-extra-{suffix}.binunknown");
831 let gzip_path = std::env::temp_dir().join(&gzip_filename);
832 let unknown_path = std::env::temp_dir().join(&unknown_filename);
833 let gzip_bytes = b"\x1f\x8b\x08\x00\xff";
834 let unknown_bytes = b"\x00\x9f\x92\x96";
835 fs::write(&gzip_path, gzip_bytes).expect("gzip attachment should be written");
836 fs::write(&unknown_path, unknown_bytes).expect("unknown attachment should be written");
837
838 let attachments = CodexFeedback::new()
839 .snapshot(None)
840 .feedback_attachments(
841 false,
842 &[],
843 &[
844 FeedbackAttachmentPath {
845 path: gzip_path.clone(),
846 attachment_filename_override: None,
847 },
848 FeedbackAttachmentPath {
849 path: unknown_path.clone(),
850 attachment_filename_override: None,
851 },
852 ],
853 None,
854 );
855
856 fs::remove_file(gzip_path).expect("gzip attachment should be removed");
857 fs::remove_file(unknown_path).expect("unknown attachment should be removed");
858 assert_eq!(
859 attachments
860 .iter()
861 .map(|attachment| (
862 attachment.filename.as_str(),
863 attachment.content_type.as_deref(),
864 attachment.buffer.as_slice(),
865 ))
866 .collect::<Vec<_>>(),
867 vec![
868 (
869 gzip_filename.as_str(),
870 Some("application/gzip"),
871 gzip_bytes.as_slice(),
872 ),
873 (
874 unknown_filename.as_str(),
875 Some("application/octet-stream"),
876 unknown_bytes.as_slice(),
877 ),
878 ]
879 );
880 }
881
882 #[test]
883 fn upload_tags_include_client_tags_and_preserve_reserved_fields() {
884 let mut tags = BTreeMap::new();
885 tags.insert("thread_id".to_string(), "wrong-thread".to_string());
886 tags.insert("turn_id".to_string(), "wrong-turn".to_string());
887 tags.insert(
888 "classification".to_string(),
889 "wrong-classification".to_string(),
890 );
891 tags.insert("cli_version".to_string(), "wrong-version".to_string());
892 tags.insert("session_source".to_string(), "wrong-source".to_string());
893 tags.insert("reason".to_string(), "wrong-reason".to_string());
894 tags.insert("account_id".to_string(), "actual-account".to_string());
895 tags.insert("model".to_string(), "gpt-5".to_string());
896 let snapshot = FeedbackSnapshot {
897 bytes: Vec::new(),
898 tags,
899 feedback_diagnostics: FeedbackDiagnostics::default(),
900 thread_id: "thread-123".to_string(),
901 };
902 let mut client_tags = BTreeMap::new();
903 client_tags.insert("thread_id".to_string(), "wrong-client-thread".to_string());
904 client_tags.insert("turn_id".to_string(), "turn-456".to_string());
905 client_tags.insert(
906 "classification".to_string(),
907 "wrong-client-classification".to_string(),
908 );
909 client_tags.insert(
910 "cli_version".to_string(),
911 "wrong-client-version".to_string(),
912 );
913 client_tags.insert(
914 "session_source".to_string(),
915 "wrong-client-source".to_string(),
916 );
917 client_tags.insert("reason".to_string(), "wrong-client-reason".to_string());
918 client_tags.insert("client_tag".to_string(), "from-client".to_string());
919
920 let upload_tags = snapshot.upload_tags(
921 "bug",
922 Some("actual reason"),
923 Some(&client_tags),
924 Some(&SessionSource::Cli),
925 );
926
927 assert_eq!(
928 upload_tags.get("thread_id").map(String::as_str),
929 Some("thread-123")
930 );
931 assert_eq!(
932 upload_tags.get("turn_id").map(String::as_str),
933 Some("turn-456")
934 );
935 assert_eq!(
936 upload_tags.get("classification").map(String::as_str),
937 Some("bug")
938 );
939 assert_eq!(
940 upload_tags.get("session_source").map(String::as_str),
941 Some("cli")
942 );
943 assert_eq!(
944 upload_tags.get("reason").map(String::as_str),
945 Some("actual reason")
946 );
947 assert_eq!(
948 upload_tags.get("account_id").map(String::as_str),
949 Some("actual-account")
950 );
951 assert_eq!(
952 upload_tags.get("client_tag").map(String::as_str),
953 Some("from-client")
954 );
955 assert_eq!(upload_tags.get("model").map(String::as_str), Some("gpt-5"));
956 }
957}