Add outbound HTTP header tracing logs

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
nicholasclark-openai
2026-03-17 19:37:38 -07:00
parent bccce0f2d8
commit 33f5447387
5 changed files with 268 additions and 14 deletions

View File

@@ -5,6 +5,7 @@ use crate::types::RateLimitStatusPayload;
use crate::types::TurnAttemptsSiblingTurnsResponse;
use anyhow::Result;
use codex_client::build_reqwest_client_with_custom_ca;
use codex_client::log_http_request;
use codex_core::auth::CodexAuth;
use codex_core::default_client::get_codex_user_agent;
use codex_protocol::account::PlanType as AccountPlanType;
@@ -259,7 +260,9 @@ impl Client {
PathStyle::CodexApi => format!("{}/api/codex/usage", self.base_url),
PathStyle::ChatGptApi => format!("{}/wham/usage", self.base_url),
};
let req = self.http.get(&url).headers(self.headers());
let headers = self.headers();
log_http_request("GET", &url, &headers);
let req = self.http.get(&url).headers(headers);
let (body, ct) = self.exec_request(req, "GET", &url).await?;
let payload: RateLimitStatusPayload = self.decode_json(&url, &ct, &body)?;
Ok(Self::rate_limit_snapshots_from_payload(payload))
@@ -276,7 +279,9 @@ impl Client {
PathStyle::CodexApi => format!("{}/api/codex/tasks/list", self.base_url),
PathStyle::ChatGptApi => format!("{}/wham/tasks/list", self.base_url),
};
let req = self.http.get(&url).headers(self.headers());
let headers = self.headers();
log_http_request("GET", &url, &headers);
let req = self.http.get(&url).headers(headers);
let req = if let Some(lim) = limit {
req.query(&[("limit", lim)])
} else {
@@ -314,7 +319,9 @@ impl Client {
PathStyle::CodexApi => format!("{}/api/codex/tasks/{}", self.base_url, task_id),
PathStyle::ChatGptApi => format!("{}/wham/tasks/{}", self.base_url, task_id),
};
let req = self.http.get(&url).headers(self.headers());
let headers = self.headers();
log_http_request("GET", &url, &headers);
let req = self.http.get(&url).headers(headers);
let (body, ct) = self.exec_request(req, "GET", &url).await?;
let parsed: CodeTaskDetailsResponse = self.decode_json(&url, &ct, &body)?;
Ok((parsed, body, ct))
@@ -335,7 +342,9 @@ impl Client {
self.base_url, task_id, turn_id
),
};
let req = self.http.get(&url).headers(self.headers());
let headers = self.headers();
log_http_request("GET", &url, &headers);
let req = self.http.get(&url).headers(headers);
let (body, ct) = self.exec_request(req, "GET", &url).await?;
self.decode_json::<TurnAttemptsSiblingTurnsResponse>(&url, &ct, &body)
}
@@ -351,7 +360,9 @@ impl Client {
PathStyle::CodexApi => format!("{}/api/codex/config/requirements", self.base_url),
PathStyle::ChatGptApi => format!("{}/wham/config/requirements", self.base_url),
};
let req = self.http.get(&url).headers(self.headers());
let headers = self.headers();
log_http_request("GET", &url, &headers);
let req = self.http.get(&url).headers(headers);
let (body, ct) = self.exec_request_detailed(req, "GET", &url).await?;
self.decode_json::<ConfigFileResponse>(&url, &ct, &body)
.map_err(RequestError::from)
@@ -364,12 +375,10 @@ impl Client {
PathStyle::CodexApi => format!("{}/api/codex/tasks", self.base_url),
PathStyle::ChatGptApi => format!("{}/wham/tasks", self.base_url),
};
let req = self
.http
.post(&url)
.headers(self.headers())
.header(CONTENT_TYPE, HeaderValue::from_static("application/json"))
.json(&request_body);
let mut headers = self.headers();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
log_http_request("POST", &url, &headers);
let req = self.http.post(&url).headers(headers).json(&request_body);
let (body, ct) = self.exec_request(req, "POST", &url).await?;
// Extract id from JSON: prefer `task.id`; fallback to top-level `id` when present.
match serde_json::from_str::<serde_json::Value>(&body) {

View File

@@ -8,6 +8,7 @@ use reqwest::IntoUrl;
use reqwest::Method;
use reqwest::Response;
use serde::Serialize;
use std::collections::BTreeMap;
use std::fmt::Display;
use std::time::Duration;
use tracing::Span;
@@ -18,6 +19,11 @@ pub struct CodexHttpClient {
inner: reqwest::Client,
}
const CODEX_TRACE_HTTP_HEADERS_ENV: &str = "CODEX_TRACE_HTTP_HEADERS";
const CODEX_TRACE_HTTP_HEADERS_INCLUDE_SENSITIVE_ENV: &str =
"CODEX_TRACE_HTTP_HEADERS_INCLUDE_SENSITIVE";
const REDACTED_HEADER_VALUE: &str = "<redacted>";
impl CodexHttpClient {
pub fn new(inner: reqwest::Client) -> Self {
Self { inner }
@@ -111,9 +117,18 @@ impl CodexRequestBuilder {
}
pub async fn send(self) -> Result<Response, reqwest::Error> {
let headers = trace_headers();
let builder = self.builder.headers(trace_headers());
if let Some(request_builder) = builder.try_clone()
&& let Ok(request) = request_builder.build()
{
log_http_request(
self.method.as_str(),
request.url().as_str(),
request.headers(),
);
}
match self.builder.headers(headers).send().await {
match builder.send().await {
Ok(response) => {
tracing::debug!(
method = %self.method,
@@ -165,6 +180,63 @@ fn trace_headers() -> HeaderMap {
headers
}
pub fn log_http_request(method: &str, url: &str, headers: &HeaderMap) {
if !http_trace_headers_enabled() {
return;
}
tracing::info!(
method,
url,
headers = ?format_headers_for_log(headers),
"Outbound HTTP request"
);
}
pub fn format_headers_for_log(headers: &HeaderMap) -> BTreeMap<String, String> {
let include_sensitive = http_trace_include_sensitive_headers();
headers
.iter()
.map(|(name, value)| {
let name_str = name.as_str().to_ascii_lowercase();
let value_str = if include_sensitive || !is_sensitive_header(&name_str) {
value.to_str().unwrap_or("<binary>").to_string()
} else {
REDACTED_HEADER_VALUE.to_string()
};
(name_str, value_str)
})
.collect()
}
fn http_trace_headers_enabled() -> bool {
env_flag_enabled(CODEX_TRACE_HTTP_HEADERS_ENV)
}
fn http_trace_include_sensitive_headers() -> bool {
env_flag_enabled(CODEX_TRACE_HTTP_HEADERS_INCLUDE_SENSITIVE_ENV)
}
fn env_flag_enabled(name: &str) -> bool {
std::env::var(name)
.map(|value| {
let normalized = value.trim().to_ascii_lowercase();
!normalized.is_empty()
&& normalized != "0"
&& normalized != "false"
&& normalized != "no"
&& normalized != "off"
})
.unwrap_or(false)
}
fn is_sensitive_header(name: &str) -> bool {
matches!(
name,
"authorization" | "proxy-authorization" | "cookie" | "set-cookie" | "x-api-key" | "api-key"
)
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -19,6 +19,8 @@ pub use crate::custom_ca::build_reqwest_client_with_custom_ca;
pub use crate::custom_ca::maybe_build_rustls_client_config_with_custom_ca;
pub use crate::default_client::CodexHttpClient;
pub use crate::default_client::CodexRequestBuilder;
pub use crate::default_client::format_headers_for_log;
pub use crate::default_client::log_http_request;
pub use crate::error::StreamError;
pub use crate::error::TransportError;
pub use crate::request::Request;

View File

@@ -74,6 +74,7 @@ use tokio::sync::Mutex;
use tokio::sync::oneshot;
use tokio::task::JoinSet;
use tokio_util::sync::CancellationToken;
use tracing::info;
use tracing::instrument;
use tracing::warn;
use url::Url;
@@ -1440,6 +1441,11 @@ async fn start_server_task(
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
log_startup_request_headers(
&server_name,
"initialize",
initialize_request_headers.as_ref(),
);
let initialize_result = client
.initialize(
params,
@@ -1456,6 +1462,7 @@ async fn start_server_task(
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
log_startup_request_headers(&server_name, "tools/list", list_request_headers.as_ref());
let tools = list_tools_for_client_uncached(
&server_name,
&client,
@@ -1666,6 +1673,48 @@ fn transport_origin(transport: &McpServerTransportConfig) -> Option<String> {
}
}
fn log_startup_request_headers(
server_name: &str,
phase: &str,
headers: Option<&reqwest::header::HeaderMap>,
) {
if std::env::var_os("CODEX_TRACE_HTTP_HEADERS").is_none() {
return;
}
let session_id = headers.and_then(|headers| {
headers
.get("session_id")
.and_then(|value| value.to_str().ok())
.map(str::to_string)
});
let client_request_id = headers.and_then(|headers| {
headers
.get("x-client-request-id")
.and_then(|value| value.to_str().ok())
.map(str::to_string)
});
let turn_metadata = headers.and_then(|headers| {
headers
.get(crate::X_CODEX_TURN_METADATA_HEADER)
.and_then(|value| value.to_str().ok())
.map(str::to_string)
});
info!(
server = server_name,
phase,
has_headers = headers.is_some(),
has_session_id = session_id.is_some(),
session_id,
has_client_request_id = client_request_id.is_some(),
client_request_id,
has_turn_metadata = turn_metadata.is_some(),
turn_metadata,
"MCP startup request headers snapshot"
);
}
async fn list_tools_for_client_uncached(
server_name: &str,
client: &Arc<RmcpClient>,

View File

@@ -11,6 +11,8 @@ use std::time::Duration;
use anyhow::Result;
use anyhow::anyhow;
use codex_client::build_reqwest_client_with_custom_ca;
use codex_client::format_headers_for_log;
use codex_client::log_http_request;
use futures::FutureExt;
use futures::StreamExt;
use futures::future::BoxFuture;
@@ -20,6 +22,7 @@ use reqwest::header::ACCEPT;
use reqwest::header::AUTHORIZATION;
use reqwest::header::CONTENT_TYPE;
use reqwest::header::HeaderMap;
use reqwest::header::HeaderValue;
use reqwest::header::WWW_AUTHENTICATE;
use rmcp::model::CallToolRequestParams;
use rmcp::model::CallToolResult;
@@ -83,6 +86,81 @@ const JSON_MIME_TYPE: &str = "application/json";
const HEADER_LAST_EVENT_ID: &str = "Last-Event-Id";
const HEADER_SESSION_ID: &str = "Mcp-Session-Id";
const NON_JSON_RESPONSE_BODY_PREVIEW_BYTES: usize = 8_192;
const CODEX_TRACE_HTTP_HEADERS_ENV: &str = "CODEX_TRACE_HTTP_HEADERS";
const CODEX_TRACE_HTTP_BODIES_ENV: &str = "CODEX_TRACE_HTTP_BODIES";
fn http_trace_headers_enabled() -> bool {
std::env::var_os(CODEX_TRACE_HTTP_HEADERS_ENV).is_some_and(|value| value != "0")
}
fn http_trace_bodies_enabled() -> bool {
std::env::var_os(CODEX_TRACE_HTTP_BODIES_ENV).is_some_and(|value| value != "0")
}
fn log_mcp_http_request(
method: &str,
url: &str,
headers: &HeaderMap,
message: &rmcp::model::ClientJsonRpcMessage,
) {
log_http_request(method, url, headers);
if !http_trace_headers_enabled() && !http_trace_bodies_enabled() {
return;
}
let body = if http_trace_bodies_enabled() {
serde_json::to_string(message).ok()
} else {
None
};
match message {
rmcp::model::JsonRpcMessage::Request(request) => {
tracing::info!(
method,
url,
rpc_method = request.request.method(),
rpc_id = ?request.id,
headers = ?format_headers_for_log(headers),
body,
"Outbound MCP HTTP request"
);
}
rmcp::model::JsonRpcMessage::Notification(_) => {
tracing::info!(
method,
url,
rpc_kind = "notification",
headers = ?format_headers_for_log(headers),
body,
"Outbound MCP HTTP request"
);
}
rmcp::model::JsonRpcMessage::Response(response) => {
tracing::info!(
method,
url,
rpc_kind = "response",
rpc_id = ?response.id,
headers = ?format_headers_for_log(headers),
body,
"Outbound MCP HTTP request"
);
}
rmcp::model::JsonRpcMessage::Error(error) => {
tracing::info!(
method,
url,
rpc_kind = "error",
rpc_id = ?error.id,
headers = ?format_headers_for_log(headers),
body,
"Outbound MCP HTTP request"
);
}
}
}
fn apply_request_scoped_headers(
mut request: reqwest::RequestBuilder,
request_headers_state: &Arc<StdMutex<Option<HeaderMap>>>,
@@ -102,16 +180,19 @@ fn apply_request_scoped_headers(
#[derive(Clone)]
struct StreamableHttpResponseClient {
inner: reqwest::Client,
default_headers: HeaderMap,
request_headers_state: Arc<StdMutex<Option<HeaderMap>>>,
}
impl StreamableHttpResponseClient {
fn new(
inner: reqwest::Client,
default_headers: HeaderMap,
request_headers_state: Arc<StdMutex<Option<HeaderMap>>>,
) -> Self {
Self {
inner,
default_headers,
request_headers_state,
}
}
@@ -150,13 +231,25 @@ impl StreamableHttpClient for StreamableHttpResponseClient {
.inner
.post(uri.as_ref())
.header(ACCEPT, [EVENT_STREAM_MIME_TYPE, JSON_MIME_TYPE].join(", "));
let mut request_headers = self.default_headers.clone();
request_headers.insert(
ACCEPT,
HeaderValue::from_static("text/event-stream, application/json"),
);
if let Some(auth_header) = auth_token {
if let Ok(value) = HeaderValue::from_str(&format!("Bearer {auth_header}")) {
request_headers.insert(AUTHORIZATION, value);
}
request = request.bearer_auth(auth_header);
}
if let Some(session_id_value) = session_id.as_ref() {
if let Ok(value) = HeaderValue::from_str(session_id_value.as_ref()) {
request_headers.insert(HEADER_SESSION_ID, value);
}
request = request.header(HEADER_SESSION_ID, session_id_value.as_ref());
}
request = apply_request_scoped_headers(request, &self.request_headers_state);
log_mcp_http_request("POST", uri.as_ref(), &request_headers, &message);
let response = request
.json(&message)
@@ -249,11 +342,19 @@ impl StreamableHttpClient for StreamableHttpResponseClient {
auth_token: Option<String>,
) -> std::result::Result<(), StreamableHttpError<Self::Error>> {
let mut request_builder = self.inner.delete(uri.as_ref());
let mut request_headers = self.default_headers.clone();
if let Some(auth_header) = auth_token {
if let Ok(value) = HeaderValue::from_str(&format!("Bearer {auth_header}")) {
request_headers.insert(AUTHORIZATION, value);
}
request_builder = request_builder.bearer_auth(auth_header);
}
if let Ok(value) = HeaderValue::from_str(session.as_ref()) {
request_headers.insert(HEADER_SESSION_ID, value);
}
request_builder =
apply_request_scoped_headers(request_builder, &self.request_headers_state);
log_http_request("DELETE", uri.as_ref(), &request_headers);
let response = request_builder
.header(HEADER_SESSION_ID, session.as_ref())
.send()
@@ -285,14 +386,29 @@ impl StreamableHttpClient for StreamableHttpResponseClient {
.get(uri.as_ref())
.header(ACCEPT, [EVENT_STREAM_MIME_TYPE, JSON_MIME_TYPE].join(", "))
.header(HEADER_SESSION_ID, session_id.as_ref());
let mut request_headers = self.default_headers.clone();
request_headers.insert(
ACCEPT,
HeaderValue::from_static("text/event-stream, application/json"),
);
if let Ok(value) = HeaderValue::from_str(session_id.as_ref()) {
request_headers.insert(HEADER_SESSION_ID, value);
}
if let Some(last_event_id) = last_event_id {
if let Ok(value) = HeaderValue::from_str(&last_event_id) {
request_headers.insert(HEADER_LAST_EVENT_ID, value);
}
request_builder = request_builder.header(HEADER_LAST_EVENT_ID, last_event_id);
}
if let Some(auth_header) = auth_token {
if let Ok(value) = HeaderValue::from_str(&format!("Bearer {auth_header}")) {
request_headers.insert(AUTHORIZATION, value);
}
request_builder = request_builder.bearer_auth(auth_header);
}
request_builder =
apply_request_scoped_headers(request_builder, &self.request_headers_state);
log_http_request("GET", uri.as_ref(), &request_headers);
let response = request_builder
.send()
@@ -1023,6 +1139,7 @@ impl RmcpClient {
let transport = StreamableHttpClientTransport::with_client(
StreamableHttpResponseClient::new(
http_client,
default_headers.clone(),
request_headers
.clone()
.unwrap_or_else(|| Arc::new(StdMutex::new(None))),
@@ -1045,6 +1162,7 @@ impl RmcpClient {
let transport = StreamableHttpClientTransport::with_client(
StreamableHttpResponseClient::new(
http_client,
default_headers.clone(),
request_headers
.clone()
.unwrap_or_else(|| Arc::new(StdMutex::new(None))),
@@ -1254,7 +1372,11 @@ async fn create_oauth_transport_and_runtime(
};
let auth_client = AuthClient::new(
StreamableHttpResponseClient::new(http_client, Arc::new(StdMutex::new(None))),
StreamableHttpResponseClient::new(
http_client,
default_headers.clone(),
Arc::new(StdMutex::new(None)),
),
manager,
);
let auth_manager = auth_client.auth_manager.clone();