mirror of
https://github.com/openai/codex.git
synced 2026-08-23 13:09:46 +00:00
Report diagnostic upload failures (#39287)
## Why Submitting a diagnostic report could appear successful without confirming that Sentry accepted the upload, leaving callers unable to detect transport or HTTP failures. ## What changed - Send report envelopes through the route-aware HTTP client and await the response. - Reject redirects, propagate transport and non-success HTTP responses, and include the full error chain in upload JSON-RPC errors. - Add structured logs for upload attempts, successes, and failures without including report contents. ## Testing Added coverage for successful uploads, rejected responses, blocked redirects, transport failures, and app-server JSON-RPC error reporting. GitOrigin-RevId: 07b5cfccd7a65f35d51a720537d2cd8962ed6cdb
This commit is contained in:
3
codex-rs/Cargo.lock
generated
3
codex-rs/Cargo.lock
generated
@@ -3179,14 +3179,17 @@ name = "codex-feedback"
|
|||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"codex-http-client",
|
||||||
"codex-login",
|
"codex-login",
|
||||||
"codex-protocol",
|
"codex-protocol",
|
||||||
"log",
|
"log",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"pretty_assertions",
|
"pretty_assertions",
|
||||||
"sentry",
|
"sentry",
|
||||||
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"wiremock",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -261,19 +261,24 @@ impl FeedbackRequestProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let session_source = self.thread_manager.session_source();
|
let session_source = self.thread_manager.session_source();
|
||||||
|
let http_client_factory = self.config.http_client_factory();
|
||||||
|
let runtime_handle = tokio::runtime::Handle::current();
|
||||||
|
|
||||||
let upload_result = tokio::task::spawn_blocking(move || {
|
let upload_result = tokio::task::spawn_blocking(move || {
|
||||||
let tags = (!upload_tags.is_empty()).then_some(&upload_tags);
|
let tags = (!upload_tags.is_empty()).then_some(&upload_tags);
|
||||||
snapshot.upload_feedback(FeedbackUploadOptions {
|
runtime_handle.block_on(snapshot.upload_feedback(
|
||||||
classification: &classification,
|
FeedbackUploadOptions {
|
||||||
reason: reason.as_deref(),
|
classification: &classification,
|
||||||
tags,
|
reason: reason.as_deref(),
|
||||||
include_logs,
|
tags,
|
||||||
extra_attachments: &extra_attachments,
|
include_logs,
|
||||||
extra_attachment_paths: &attachment_paths,
|
extra_attachments: &extra_attachments,
|
||||||
session_source: Some(session_source),
|
extra_attachment_paths: &attachment_paths,
|
||||||
logs_override: sqlite_feedback_logs,
|
session_source: Some(session_source),
|
||||||
})
|
logs_override: sqlite_feedback_logs,
|
||||||
|
},
|
||||||
|
&http_client_factory,
|
||||||
|
))
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -286,7 +291,8 @@ impl FeedbackRequestProcessor {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
upload_result.map_err(|err| internal_error(format!("failed to upload feedback: {err}")))?;
|
upload_result
|
||||||
|
.map_err(|err| internal_error(format!("failed to upload feedback: {err:#}")))?;
|
||||||
Ok(FeedbackUploadResponse { thread_id })
|
Ok(FeedbackUploadResponse { thread_id })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
48
codex-rs/app-server/tests/suite/v2/feedback.rs
Normal file
48
codex-rs/app-server/tests/suite/v2/feedback.rs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use app_test_support::TestAppServer;
|
||||||
|
use codex_app_server_protocol::RequestId;
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
use serde_json::json;
|
||||||
|
use tokio::time::timeout;
|
||||||
|
use wiremock::Mock;
|
||||||
|
use wiremock::MockServer;
|
||||||
|
use wiremock::ResponseTemplate;
|
||||||
|
use wiremock::matchers::method;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn feedback_upload_reports_transport_failure_as_json_rpc_error() -> Result<()> {
|
||||||
|
let proxy = MockServer::start().await;
|
||||||
|
Mock::given(method("CONNECT"))
|
||||||
|
.respond_with(ResponseTemplate::new(503))
|
||||||
|
.expect(1)
|
||||||
|
.mount(&proxy)
|
||||||
|
.await;
|
||||||
|
let proxy_uri = proxy.uri();
|
||||||
|
let mut app_server = TestAppServer::builder()
|
||||||
|
.with_env_overrides(&[
|
||||||
|
("HTTPS_PROXY", Some(proxy_uri.as_str())),
|
||||||
|
("https_proxy", Some(proxy_uri.as_str())),
|
||||||
|
("NO_PROXY", Some("")),
|
||||||
|
("no_proxy", Some("")),
|
||||||
|
])
|
||||||
|
.build_initialized()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let request_id = app_server
|
||||||
|
.send_raw_request(
|
||||||
|
"feedback/upload",
|
||||||
|
Some(json!({ "classification": "bug", "includeLogs": false })),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let error = timeout(
|
||||||
|
Duration::from_secs(15),
|
||||||
|
app_server.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||||
|
)
|
||||||
|
.await??;
|
||||||
|
|
||||||
|
assert_eq!(error.error.code, -32603);
|
||||||
|
assert!(error.error.message.contains("failed to upload feedback"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ mod experimental_api;
|
|||||||
mod experimental_feature_list;
|
mod experimental_feature_list;
|
||||||
mod external_agent_config;
|
mod external_agent_config;
|
||||||
mod external_agent_import_sync;
|
mod external_agent_import_sync;
|
||||||
|
mod feedback;
|
||||||
mod fs;
|
mod fs;
|
||||||
mod git_attribution;
|
mod git_attribution;
|
||||||
mod guardian_v2;
|
mod guardian_v2;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
|
codex-http-client = { workspace = true }
|
||||||
codex-login = { workspace = true }
|
codex-login = { workspace = true }
|
||||||
codex-protocol = { workspace = true }
|
codex-protocol = { workspace = true }
|
||||||
mime_guess = { workspace = true }
|
mime_guess = { workspace = true }
|
||||||
@@ -19,6 +20,8 @@ tracing-subscriber = { workspace = true }
|
|||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
pretty_assertions = { workspace = true }
|
pretty_assertions = { workspace = true }
|
||||||
|
tokio = { workspace = true, features = ["macros", "rt"] }
|
||||||
|
wiremock = { workspace = true }
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
doctest = false
|
doctest = false
|
||||||
|
|||||||
@@ -9,9 +9,14 @@ use std::path::PathBuf;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use anyhow::anyhow;
|
use anyhow::anyhow;
|
||||||
|
use codex_http_client::ClientRouteClass;
|
||||||
|
use codex_http_client::HttpClientFactory;
|
||||||
|
use codex_http_client::RouteAwareClientPool;
|
||||||
use codex_login::AuthEnvTelemetry;
|
use codex_login::AuthEnvTelemetry;
|
||||||
use codex_protocol::ThreadId;
|
use codex_protocol::ThreadId;
|
||||||
use codex_protocol::protocol::SessionSource;
|
use codex_protocol::protocol::SessionSource;
|
||||||
@@ -420,25 +425,35 @@ impl FeedbackSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Upload feedback to Sentry with optional attachments.
|
/// Upload feedback to Sentry with optional attachments.
|
||||||
pub fn upload_feedback(&self, options: FeedbackUploadOptions<'_>) -> Result<()> {
|
pub async fn upload_feedback(
|
||||||
use std::str::FromStr;
|
&self,
|
||||||
use std::sync::Arc;
|
options: FeedbackUploadOptions<'_>,
|
||||||
|
http_client_factory: &HttpClientFactory,
|
||||||
|
) -> Result<()> {
|
||||||
|
self.upload_feedback_with_dsn(options, http_client_factory, SENTRY_DSN)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upload_feedback_with_dsn(
|
||||||
|
&self,
|
||||||
|
options: FeedbackUploadOptions<'_>,
|
||||||
|
http_client_factory: &HttpClientFactory,
|
||||||
|
dsn: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
use sentry::Client;
|
|
||||||
use sentry::ClientOptions;
|
use sentry::ClientOptions;
|
||||||
use sentry::protocol::Envelope;
|
use sentry::protocol::Envelope;
|
||||||
use sentry::protocol::EnvelopeItem;
|
use sentry::protocol::EnvelopeItem;
|
||||||
use sentry::protocol::Event;
|
use sentry::protocol::Event;
|
||||||
use sentry::protocol::Level;
|
use sentry::protocol::Level;
|
||||||
use sentry::transports::DefaultTransportFactory;
|
|
||||||
use sentry::types::Dsn;
|
use sentry::types::Dsn;
|
||||||
|
|
||||||
// Build Sentry client
|
let started_at = Instant::now();
|
||||||
let client = Client::from_config(ClientOptions {
|
let dsn = Dsn::from_str(dsn).map_err(|error| anyhow!("invalid DSN: {error}"))?;
|
||||||
dsn: Some(Dsn::from_str(SENTRY_DSN).map_err(|e| anyhow!("invalid DSN: {e}"))?),
|
let upload_url = dsn.envelope_api_url();
|
||||||
transport: Some(Arc::new(DefaultTransportFactory {})),
|
let sentry_options = ClientOptions::default();
|
||||||
..Default::default()
|
let sentry_auth = dsn.to_auth(Some(sentry_options.user_agent.as_ref()));
|
||||||
});
|
|
||||||
|
|
||||||
let tags = self.upload_tags(
|
let tags = self.upload_tags(
|
||||||
options.classification,
|
options.classification,
|
||||||
@@ -477,18 +492,80 @@ impl FeedbackSnapshot {
|
|||||||
}
|
}
|
||||||
envelope.add_item(EnvelopeItem::Event(event));
|
envelope.add_item(EnvelopeItem::Event(event));
|
||||||
|
|
||||||
for attachment in self.feedback_attachments(
|
let attachments = self.feedback_attachments(
|
||||||
options.include_logs,
|
options.include_logs,
|
||||||
options.extra_attachments,
|
options.extra_attachments,
|
||||||
options.extra_attachment_paths,
|
options.extra_attachment_paths,
|
||||||
options.logs_override,
|
options.logs_override,
|
||||||
) {
|
);
|
||||||
|
let attachment_count = attachments.len();
|
||||||
|
for attachment in attachments {
|
||||||
envelope.add_item(EnvelopeItem::Attachment(attachment));
|
envelope.add_item(EnvelopeItem::Attachment(attachment));
|
||||||
}
|
}
|
||||||
|
|
||||||
client.send_envelope(envelope);
|
let mut body = Vec::new();
|
||||||
client.flush(Some(Duration::from_secs(UPLOAD_TIMEOUT_SECS)));
|
envelope
|
||||||
Ok(())
|
.to_writer(&mut body)
|
||||||
|
.context("failed to serialize feedback upload")?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
thread_id = %self.thread_id,
|
||||||
|
classification = options.classification,
|
||||||
|
include_logs = options.include_logs,
|
||||||
|
attachment_count,
|
||||||
|
payload_bytes = body.len(),
|
||||||
|
"uploading feedback to Sentry"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut status = None;
|
||||||
|
let result: Result<()> = async {
|
||||||
|
let client_pool = RouteAwareClientPool::new_without_redirects(
|
||||||
|
http_client_factory.clone(),
|
||||||
|
ClientRouteClass::Other,
|
||||||
|
);
|
||||||
|
let response = client_pool
|
||||||
|
.post(upload_url.as_str())
|
||||||
|
.header("X-Sentry-Auth", sentry_auth.to_string())
|
||||||
|
.body(body)
|
||||||
|
.timeout(Duration::from_secs(UPLOAD_TIMEOUT_SECS))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("failed to upload feedback to Sentry")?;
|
||||||
|
let response_status = response.status();
|
||||||
|
status = Some(response_status.as_u16());
|
||||||
|
response
|
||||||
|
.error_for_status()
|
||||||
|
.context("failed to upload feedback to Sentry")?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
response_status.is_success(),
|
||||||
|
"Sentry rejected feedback upload with HTTP status {response_status}"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match &result {
|
||||||
|
Ok(()) => tracing::info!(
|
||||||
|
thread_id = %self.thread_id,
|
||||||
|
classification = options.classification,
|
||||||
|
include_logs = options.include_logs,
|
||||||
|
status,
|
||||||
|
elapsed_ms = started_at.elapsed().as_millis(),
|
||||||
|
"feedback uploaded to Sentry"
|
||||||
|
),
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(
|
||||||
|
thread_id = %self.thread_id,
|
||||||
|
classification = options.classification,
|
||||||
|
include_logs = options.include_logs,
|
||||||
|
status,
|
||||||
|
elapsed_ms = started_at.elapsed().as_millis(),
|
||||||
|
error = %format!("{error:#}"),
|
||||||
|
"feedback upload failed"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
fn upload_tags(
|
fn upload_tags(
|
||||||
@@ -709,9 +786,16 @@ mod tests {
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::FeedbackDiagnostic;
|
use crate::FeedbackDiagnostic;
|
||||||
|
use codex_http_client::OutboundProxyPolicy;
|
||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
use tracing_subscriber::layer::SubscriberExt;
|
use tracing_subscriber::layer::SubscriberExt;
|
||||||
use tracing_subscriber::util::SubscriberInitExt;
|
use tracing_subscriber::util::SubscriberInitExt;
|
||||||
|
use wiremock::Mock;
|
||||||
|
use wiremock::MockServer;
|
||||||
|
use wiremock::ResponseTemplate;
|
||||||
|
use wiremock::matchers::header_exists;
|
||||||
|
use wiremock::matchers::method;
|
||||||
|
use wiremock::matchers::path;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ring_buffer_drops_front_when_full() {
|
fn ring_buffer_drops_front_when_full() {
|
||||||
@@ -787,6 +871,114 @@ mod tests {
|
|||||||
pretty_assertions::assert_eq!(snap.tags.get("cached").map(String::as_str), Some("true"));
|
pretty_assertions::assert_eq!(snap.tags.get("cached").map(String::as_str), Some("true"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn upload_test_feedback(feedback: &CodexFeedback, dsn: &str) -> Result<()> {
|
||||||
|
feedback
|
||||||
|
.snapshot(/*session_id*/ None)
|
||||||
|
.upload_feedback_with_dsn(
|
||||||
|
FeedbackUploadOptions {
|
||||||
|
classification: "bug",
|
||||||
|
reason: Some("private feedback"),
|
||||||
|
tags: None,
|
||||||
|
include_logs: true,
|
||||||
|
extra_attachments: &[],
|
||||||
|
extra_attachment_paths: &[],
|
||||||
|
session_source: Some(SessionSource::Cli),
|
||||||
|
logs_override: Some(b"private log contents".to_vec()),
|
||||||
|
},
|
||||||
|
&HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
|
||||||
|
dsn,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn feedback_upload_waits_for_successful_sentry_response() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path("/api/42/envelope/"))
|
||||||
|
.and(header_exists("X-Sentry-Auth"))
|
||||||
|
.respond_with(ResponseTemplate::new(200))
|
||||||
|
.expect(1)
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let dsn = format!("http://public@{}/42", server.address());
|
||||||
|
upload_test_feedback(&CodexFeedback::new(), &dsn)
|
||||||
|
.await
|
||||||
|
.expect("successful Sentry response should complete feedback upload");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn feedback_upload_reports_rejected_sentry_response_without_exposing_feedback() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path("/api/42/envelope/"))
|
||||||
|
.respond_with(ResponseTemplate::new(503))
|
||||||
|
.expect(1)
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let feedback = CodexFeedback::new();
|
||||||
|
let dsn = format!("http://public@{}/42", server.address());
|
||||||
|
|
||||||
|
let error = upload_test_feedback(&feedback, &dsn)
|
||||||
|
.await
|
||||||
|
.expect_err("rejected Sentry responses must fail feedback uploads");
|
||||||
|
|
||||||
|
let error = format!("{error:#}");
|
||||||
|
assert!(error.contains("503"));
|
||||||
|
assert!(!error.contains("private feedback"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn feedback_upload_does_not_forward_private_data_to_redirect_target() {
|
||||||
|
let sentry_server = MockServer::start().await;
|
||||||
|
let redirect_target = MockServer::start().await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path("/api/42/envelope/"))
|
||||||
|
.respond_with(
|
||||||
|
ResponseTemplate::new(307)
|
||||||
|
.insert_header("Location", format!("{}/capture", redirect_target.uri())),
|
||||||
|
)
|
||||||
|
.expect(1)
|
||||||
|
.mount(&sentry_server)
|
||||||
|
.await;
|
||||||
|
let dsn = format!("http://public@{}/42", sentry_server.address());
|
||||||
|
let error = upload_test_feedback(&CodexFeedback::new(), &dsn)
|
||||||
|
.await
|
||||||
|
.expect_err("redirected feedback uploads must be rejected");
|
||||||
|
|
||||||
|
assert!(error.to_string().contains("307"));
|
||||||
|
assert!(
|
||||||
|
redirect_target
|
||||||
|
.received_requests()
|
||||||
|
.await
|
||||||
|
.expect("redirect target should record requests")
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn feedback_upload_reports_transport_failures() {
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0")
|
||||||
|
.expect("unused local address should be available");
|
||||||
|
let address = listener
|
||||||
|
.local_addr()
|
||||||
|
.expect("listener should have an address");
|
||||||
|
drop(listener);
|
||||||
|
|
||||||
|
let dsn = format!("http://public@{address}/42");
|
||||||
|
let error = upload_test_feedback(&CodexFeedback::new(), &dsn)
|
||||||
|
.await
|
||||||
|
.expect_err("transport failures must fail feedback uploads");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
error
|
||||||
|
.downcast_ref::<codex_http_client::RouteAwareRequestError>()
|
||||||
|
.is_some_and(codex_http_client::RouteAwareRequestError::is_connect)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn feedback_attachments_gate_connectivity_diagnostics() {
|
fn feedback_attachments_gate_connectivity_diagnostics() {
|
||||||
let extra_filename = format!("codex-feedback-extra-{}.jsonl", ThreadId::new());
|
let extra_filename = format!("codex-feedback-extra-{}.jsonl", ThreadId::new());
|
||||||
|
|||||||
Reference in New Issue
Block a user