Skip to main content

codex_feedback/
lib.rs

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
32/// Filename used for the redacted `codex doctor --json` feedback attachment.
33pub const DOCTOR_REPORT_ATTACHMENT_FILENAME: &str = "codex-doctor-report.json";
34/// Filename used for the raw Codex Apps MCP tools cache feedback attachment.
35pub const CODEX_APPS_TOOLS_CACHE_ATTACHMENT_FILENAME: &str = "codex-apps-tools-cache.json";
36/// Filename used for the raw connector directory cache feedback attachment.
37pub const CODEX_APP_DIRECTORY_CACHE_ATTACHMENT_FILENAME: &str = "codex-app-directory-cache.json";
38/// Filename used for the Windows sandbox log feedback attachment.
39pub const WINDOWS_SANDBOX_LOG_ATTACHMENT_FILENAME: &str = "windows-sandbox.log";
40const DEFAULT_MAX_BYTES: usize = 4 * 1024 * 1024; // 4 MiB
41const 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
47/// Structured request/auth fields that should be attached to feedback uploads.
48pub 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        // Custom provider `env_key` is arbitrary config text, so emit only a safe bucket.
157        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    /// Returns a [`tracing_subscriber`] layer that captures full-fidelity logs into this feedback
198    /// ring buffer.
199    ///
200    /// This is intended for initialization code so call sites don't have to duplicate the exact
201    /// `fmt::layer()` configuration and filter logic.
202    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            // Capture everything, regardless of the caller's `RUST_LOG`, so feedback includes the
212            // full trace when the user uploads a report.
213            .with_filter(
214                Targets::new()
215                    .with_default(Level::TRACE)
216                    .with_target("codex_api::responses_websocket_timing", LevelFilter::OFF),
217            )
218    }
219
220    /// Returns a [`tracing_subscriber`] layer that collects structured metadata for feedback.
221    ///
222    /// Events with `target: "feedback_tags"` are treated as key/value tags to attach to feedback
223    /// uploads later.
224    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 the incoming chunk is larger than capacity, keep only the trailing bytes.
324        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        // Evict from the front if we would exceed capacity.
332        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    /// Optional filename to use for the uploaded attachment instead of `path`'s basename.
358    pub attachment_filename_override: Option<String>,
359}
360
361/// In-memory attachment to include in a feedback upload.
362///
363/// Use this for generated diagnostics that should not be materialized on disk,
364/// such as the redacted doctor report. File-backed artifacts should use
365/// `FeedbackAttachmentPath` so upload-time read failures can be logged and
366/// skipped independently.
367pub struct FeedbackAttachment {
368    /// Attachment filename shown in Sentry and in the feedback consent UI.
369    pub filename: String,
370    /// Optional MIME type for consumers that render or classify attachments.
371    pub content_type: Option<String>,
372    /// Attachment bytes captured before the upload starts.
373    pub buffer: Vec<u8>,
374}
375
376/// Inputs that control one feedback upload to Sentry.
377///
378/// The caller is responsible for applying any user-consent gate before setting
379/// `include_logs` or passing diagnostic attachments. This type only describes
380/// what to upload once that decision has been made.
381pub 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    /// Generated attachments that are already buffered and safe to upload.
387    ///
388    /// These are included after `codex-logs.log` and before path-backed rollout
389    /// attachments. They are only passed by the caller after any user consent
390    /// gate has decided logs and diagnostics should be uploaded.
391    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    /// Upload feedback to Sentry with optional attachments.
416    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        // Build Sentry client
430        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        // This layer is filtered by `Targets`, but keep the guard anyway in case it is used without
639        // the filter.
640        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(/*max_bytes*/ 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(/*session_id*/ None);
718        // Capacity 8: after writing 10 bytes, we should keep the last 8.
719        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(/*session_id*/ 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(/*session_id*/ 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(/*session_id*/ 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            /*include_logs*/ 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(/*session_id*/ None)
812            .with_feedback_diagnostics(FeedbackDiagnostics::default())
813            .feedback_attachments(/*include_logs*/ 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(/*session_id*/ None)
840            .feedback_attachments(
841                /*include_logs*/ 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                /*logs_override*/ 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}