mirror of
https://github.com/openai/codex.git
synced 2026-11-06 20:20:34 +00:00
Feedback uploads collect rollouts for the requested thread and its complete agent subtree. A long-running thread on macOS had 1,224 rollout files totaling 107.2 GiB, so collecting attachments exhausted memory before the upload could complete. The Sentry transport then serializes the envelope into another contiguous buffer, doubling the pressure. Limit subtree collection to the requested thread plus three descendants. Read at most 2 MiB from the tail of each file-backed attachment and 8 MiB total. Record the original subtree size in feedback tags so truncated reports remain diagnosable.
976 lines
35 KiB
Rust
976 lines
35 KiB
Rust
use std::collections::BTreeMap;
|
|
use std::collections::VecDeque;
|
|
use std::collections::btree_map::Entry;
|
|
use std::fs;
|
|
use std::fs::File;
|
|
use std::io::Read;
|
|
use std::io::Seek;
|
|
use std::io::SeekFrom;
|
|
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;
|
|
|
|
/// Filename used for the redacted `codex doctor --json` feedback attachment.
|
|
pub const DOCTOR_REPORT_ATTACHMENT_FILENAME: &str = "codex-doctor-report.json";
|
|
/// Filename used for the Windows sandbox log feedback attachment.
|
|
pub const WINDOWS_SANDBOX_LOG_ATTACHMENT_FILENAME: &str = "windows-sandbox.log";
|
|
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;
|
|
// Sentry attachments own their bytes, then the transport serializes the complete envelope into a
|
|
// second contiguous buffer. Bound file-backed inputs before either allocation can scale with disk.
|
|
const MAX_FILE_ATTACHMENT_BYTES: usize = 2 * 1024 * 1024;
|
|
const MAX_FILE_ATTACHMENTS_BYTES: usize = 8 * 1024 * 1024;
|
|
|
|
/// 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<bool>,
|
|
pub auth_recovery_mode: Option<&'a str>,
|
|
pub auth_recovery_phase: Option<&'a str>,
|
|
pub auth_connection_reused: Option<bool>,
|
|
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<bool>,
|
|
pub auth_recovery_followup_status: Option<u16>,
|
|
}
|
|
|
|
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<FeedbackInner>,
|
|
}
|
|
|
|
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<S>(&self) -> impl Layer<S> + 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<S>(&self) -> impl Layer<S> + 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<ThreadId>) -> 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<RingBuffer>,
|
|
tags: Mutex<BTreeMap<String, String>>,
|
|
}
|
|
|
|
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<FeedbackInner>,
|
|
}
|
|
|
|
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<FeedbackInner>,
|
|
}
|
|
|
|
impl Write for FeedbackWriter {
|
|
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
|
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<u8>,
|
|
}
|
|
|
|
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<u8> {
|
|
self.buf.iter().copied().collect()
|
|
}
|
|
}
|
|
|
|
pub struct FeedbackSnapshot {
|
|
bytes: Vec<u8>,
|
|
tags: BTreeMap<String, String>,
|
|
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<String>,
|
|
}
|
|
|
|
/// In-memory attachment to include in a feedback upload.
|
|
///
|
|
/// Use this for generated diagnostics that should not be materialized on disk,
|
|
/// such as the redacted doctor report. File-backed artifacts should use
|
|
/// `FeedbackAttachmentPath` so upload-time read failures can be logged and
|
|
/// skipped independently.
|
|
pub struct FeedbackAttachment {
|
|
/// Attachment filename shown in Sentry and in the feedback consent UI.
|
|
pub filename: String,
|
|
/// Optional MIME type for consumers that render or classify attachments.
|
|
pub content_type: Option<String>,
|
|
/// Attachment bytes captured before the upload starts.
|
|
pub buffer: Vec<u8>,
|
|
}
|
|
|
|
/// Inputs that control one feedback upload to Sentry.
|
|
///
|
|
/// The caller is responsible for applying any user-consent gate before setting
|
|
/// `include_logs` or passing diagnostic attachments. This type only describes
|
|
/// what to upload once that decision has been made.
|
|
pub struct FeedbackUploadOptions<'a> {
|
|
pub classification: &'a str,
|
|
pub reason: Option<&'a str>,
|
|
pub tags: Option<&'a BTreeMap<String, String>>,
|
|
pub include_logs: bool,
|
|
/// Generated attachments that are already buffered and safe to upload.
|
|
///
|
|
/// These are included after `codex-logs.log` and before path-backed rollout
|
|
/// attachments. They are only passed by the caller after any user consent
|
|
/// gate has decided logs and diagnostics should be uploaded.
|
|
pub extra_attachments: &'a [FeedbackAttachment],
|
|
pub extra_attachment_paths: &'a [FeedbackAttachmentPath],
|
|
pub session_source: Option<SessionSource>,
|
|
pub logs_override: Option<Vec<u8>>,
|
|
}
|
|
|
|
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<String> {
|
|
if !include_logs {
|
|
return None;
|
|
}
|
|
|
|
self.feedback_diagnostics.attachment_text()
|
|
}
|
|
|
|
pub fn save_to_temp_file(&self) -> io::Result<PathBuf> {
|
|
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_attachments,
|
|
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<String, String>>,
|
|
session_source: Option<&SessionSource>,
|
|
) -> BTreeMap<String, String> {
|
|
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_attachments: &[FeedbackAttachment],
|
|
extra_attachment_paths: &[FeedbackAttachmentPath],
|
|
logs_override: Option<Vec<u8>>,
|
|
) -> Vec<sentry::protocol::Attachment> {
|
|
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,
|
|
});
|
|
}
|
|
|
|
attachments.extend(extra_attachments.iter().map(|attachment| Attachment {
|
|
buffer: attachment.buffer.clone(),
|
|
filename: attachment.filename.clone(),
|
|
content_type: attachment.content_type.clone(),
|
|
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,
|
|
});
|
|
}
|
|
|
|
let mut file_attachment_bytes = 0usize;
|
|
for (index, attachment_path) in extra_attachment_paths.iter().enumerate() {
|
|
let remaining_bytes = MAX_FILE_ATTACHMENTS_BYTES.saturating_sub(file_attachment_bytes);
|
|
if remaining_bytes == 0 {
|
|
tracing::warn!(
|
|
skipped_attachment_count = extra_attachment_paths.len() - index,
|
|
max_bytes = MAX_FILE_ATTACHMENTS_BYTES,
|
|
"feedback file attachment budget exhausted; skipping remaining files"
|
|
);
|
|
break;
|
|
}
|
|
|
|
let mut file = match File::open(&attachment_path.path) {
|
|
Ok(file) => file,
|
|
Err(err) => {
|
|
tracing::warn!(
|
|
path = %attachment_path.path.display(),
|
|
error = %err,
|
|
"failed to read log attachment; skipping"
|
|
);
|
|
continue;
|
|
}
|
|
};
|
|
let file_len = match file.metadata() {
|
|
Ok(metadata) => metadata.len(),
|
|
Err(err) => {
|
|
tracing::warn!(
|
|
path = %attachment_path.path.display(),
|
|
error = %err,
|
|
"failed to inspect log attachment; skipping"
|
|
);
|
|
continue;
|
|
}
|
|
};
|
|
let max_bytes = remaining_bytes.min(MAX_FILE_ATTACHMENT_BYTES);
|
|
let max_bytes_u64 = u64::try_from(max_bytes).unwrap_or(u64::MAX);
|
|
let truncated = file_len > max_bytes_u64;
|
|
if truncated && let Err(err) = file.seek(SeekFrom::Start(file_len - max_bytes_u64)) {
|
|
tracing::warn!(
|
|
path = %attachment_path.path.display(),
|
|
error = %err,
|
|
"failed to seek log attachment; skipping"
|
|
);
|
|
continue;
|
|
}
|
|
let mut data =
|
|
Vec::with_capacity(max_bytes.min(usize::try_from(file_len).unwrap_or(max_bytes)));
|
|
if let Err(err) = file.take(max_bytes_u64).read_to_end(&mut data) {
|
|
tracing::warn!(
|
|
path = %attachment_path.path.display(),
|
|
error = %err,
|
|
"failed to read log attachment; skipping"
|
|
);
|
|
continue;
|
|
}
|
|
file_attachment_bytes += data.len();
|
|
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())
|
|
});
|
|
let filename = if truncated {
|
|
tracing::warn!(
|
|
path = %attachment_path.path.display(),
|
|
file_bytes = file_len,
|
|
included_bytes = data.len(),
|
|
"truncated feedback file attachment to its newest bytes"
|
|
);
|
|
format!("{filename}.tail")
|
|
} else {
|
|
filename
|
|
};
|
|
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<FeedbackInner>,
|
|
}
|
|
|
|
impl<S> Layer<S> 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<String, String>,
|
|
}
|
|
|
|
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,
|
|
&[FeedbackAttachment {
|
|
filename: DOCTOR_REPORT_ATTACHMENT_FILENAME.to_string(),
|
|
content_type: Some("application/json".to_string()),
|
|
buffer: b"{\"overallStatus\":\"ok\"}".to_vec(),
|
|
}],
|
|
std::slice::from_ref(&extra_attachment_path),
|
|
Some(vec![1]),
|
|
);
|
|
|
|
assert_eq!(
|
|
attachments_with_diagnostics
|
|
.iter()
|
|
.map(|attachment| attachment.filename.as_str())
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
"codex-logs.log",
|
|
DOCTOR_REPORT_ATTACHMENT_FILENAME,
|
|
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"{\"overallStatus\":\"ok\"}".to_vec()
|
|
);
|
|
assert_eq!(
|
|
attachments_with_diagnostics[2].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[3].buffer, b"rollout".to_vec());
|
|
assert_eq!(
|
|
OsStr::new(attachments_with_diagnostics[3].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<_>>(),
|
|
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 file_attachments_are_tail_sampled_with_a_total_budget() {
|
|
let filename = format!("codex-feedback-large-{}.jsonl", ThreadId::new());
|
|
let path = std::env::temp_dir().join(&filename);
|
|
let file = File::create(&path).expect("large attachment should be created");
|
|
file.set_len(
|
|
u64::try_from(MAX_FILE_ATTACHMENT_BYTES + 1)
|
|
.expect("attachment limit should fit in u64"),
|
|
)
|
|
.expect("large attachment should be sized");
|
|
let paths = (0..5)
|
|
.map(|_| FeedbackAttachmentPath {
|
|
path: path.clone(),
|
|
attachment_filename_override: None,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
let attachments = CodexFeedback::new()
|
|
.snapshot(/*session_id*/ None)
|
|
.feedback_attachments(
|
|
/*include_logs*/ false,
|
|
&[],
|
|
&paths,
|
|
/*logs_override*/ None,
|
|
);
|
|
|
|
assert_eq!(attachments.len(), 4);
|
|
assert!(
|
|
attachments
|
|
.iter()
|
|
.all(|attachment| attachment.buffer.len() == MAX_FILE_ATTACHMENT_BYTES)
|
|
);
|
|
assert!(
|
|
attachments
|
|
.iter()
|
|
.all(|attachment| attachment.filename == format!("{filename}.tail"))
|
|
);
|
|
fs::remove_file(path).expect("large 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"));
|
|
}
|
|
}
|