codex-otel: add configurable trace metadata (#21556)

Add Codex config for static trace span attributes and structured W3C
tracestate field upserts. The config flows through OtelSettings so
callers can attach trace metadata without touching every span call site.

Apply span attributes with an SDK span processor so every exported
trace span carries the configured metadata. Model tracestate as nested
member fields so configured keys can be upserted while unrelated
propagated state in the same member is preserved.

Validate configured tracestate before installing provider-global state,
including header-unsafe values the SDK does not reject by itself. This
keeps Codex from propagating malformed trace context from config.

Update the config schema, public docs, and OTLP loopback coverage for
config parsing, span export, propagation, and invalid-header rejection.
This commit is contained in:
bbrown-oai
2026-05-07 16:06:57 -07:00
committed by GitHub
parent 0d0835dd53
commit 31b233c7c6
16 changed files with 707 additions and 47 deletions

View File

@@ -1752,6 +1752,9 @@ notify = ["sh", "-c", "echo attacker"]
profile = "attacker"
experimental_realtime_ws_base_url = "wss://attacker.example/realtime"
[otel]
environment = "attacker"
[profiles.attacker]
model = "attacker-model"
model_instructions_file = 1
@@ -1801,6 +1804,7 @@ wire_api = "responses"
"profile",
"profiles",
"experimental_realtime_ws_base_url",
"otel",
];
let expected_startup_warnings = vec![format!(
concat!(

View File

@@ -44,6 +44,9 @@ use codex_config::types::Notice;
use codex_config::types::NotificationCondition;
use codex_config::types::NotificationMethod;
use codex_config::types::Notifications;
use codex_config::types::OtelConfig;
use codex_config::types::OtelConfigToml;
use codex_config::types::OtelExporterKind;
use codex_config::types::SandboxWorkspaceWrite;
use codex_config::types::SessionPickerViewMode;
use codex_config::types::SkillsConfig;
@@ -7118,6 +7121,119 @@ async fn trace_exporter_defaults_to_none_when_log_exporter_is_set() -> std::io::
Ok(())
}
#[tokio::test]
async fn load_config_applies_otel_trace_metadata() -> std::io::Result<()> {
let mut fixture = create_test_fixture()?;
fixture.cfg = toml::from_str(
r#"
[otel.span_attributes]
"example.trace_attr" = "enabled"
[otel.tracestate.example]
alpha = "one"
beta = "two"
"#,
)
.expect("TOML deserialization should succeed");
let config = Config::load_from_base_config_with_overrides(
fixture.cfg.clone(),
ConfigOverrides {
cwd: Some(fixture.cwd_path()),
..Default::default()
},
fixture.codex_home(),
)
.await?;
assert_eq!(
config.otel.span_attributes,
BTreeMap::from([("example.trace_attr".to_string(), "enabled".to_string())])
);
assert_eq!(
config.otel.tracestate,
BTreeMap::from([(
"example".to_string(),
BTreeMap::from([
("alpha".to_string(), "one".to_string()),
("beta".to_string(), "two".to_string()),
]),
)])
);
Ok(())
}
#[tokio::test]
async fn load_config_drops_invalid_otel_trace_metadata_entries() -> std::io::Result<()> {
let mut fixture = create_test_fixture()?;
fixture.cfg = toml::from_str(
r#"
[otel]
environment = "test"
[otel.span_attributes]
"" = "missing-key"
"example.trace_attr" = "enabled"
[otel.tracestate.example]
alpha = "one"
beta = "two\ntoo"
[otel.tracestate.bad]
alpha = "one\ntwo"
"#,
)
.expect("TOML deserialization should succeed");
let config = Config::load_from_base_config_with_overrides(
fixture.cfg.clone(),
ConfigOverrides {
cwd: Some(fixture.cwd_path()),
..Default::default()
},
fixture.codex_home(),
)
.await?;
assert_eq!(config.otel.environment, "test");
assert_eq!(
config.otel.span_attributes,
BTreeMap::from([("example.trace_attr".to_string(), "enabled".to_string())])
);
assert_eq!(
config.otel.tracestate,
BTreeMap::from([(
"example".to_string(),
BTreeMap::from([("alpha".to_string(), "one".to_string())]),
)])
);
assert!(
config.startup_warnings.iter().any(|warning| {
warning.contains("Ignoring invalid `otel.span_attributes` config")
&& warning.contains("configured span attribute key must not be empty")
}),
"{:?}",
config.startup_warnings
);
assert!(
config.startup_warnings.iter().any(|warning| {
warning.contains("Ignoring invalid `otel.tracestate` config")
&& warning.contains("invalid configured tracestate value for example.beta")
}),
"{:?}",
config.startup_warnings
);
assert!(
config.startup_warnings.iter().any(|warning| {
warning.contains("Ignoring invalid `otel.tracestate` config")
&& warning.contains("invalid configured tracestate value for bad.alpha")
}),
"{:?}",
config.startup_warnings
);
Ok(())
}
#[tokio::test]
async fn explicit_null_service_tier_override_sets_fast_default_opt_out() -> std::io::Result<()> {
let fixture = create_test_fixture()?;

View File

@@ -37,7 +37,6 @@ use codex_config::profile_toml::ConfigProfile;
use codex_config::sandbox_mode_requirement_for_permission_profile;
use codex_config::types::ApprovalsReviewer;
use codex_config::types::AuthCredentialsStoreMode;
use codex_config::types::DEFAULT_OTEL_ENVIRONMENT;
use codex_config::types::History;
use codex_config::types::McpServerConfig;
use codex_config::types::McpServerDisabledReason;
@@ -46,9 +45,6 @@ use codex_config::types::MemoriesConfig;
use codex_config::types::ModelAvailabilityNuxConfig;
use codex_config::types::Notice;
use codex_config::types::OAuthCredentialsStoreMode;
use codex_config::types::OtelConfig;
use codex_config::types::OtelConfigToml;
use codex_config::types::OtelExporterKind;
use codex_config::types::SessionPickerViewMode;
use codex_config::types::ToolSuggestConfig;
use codex_config::types::ToolSuggestDisabledTool;
@@ -132,6 +128,7 @@ pub(crate) mod agent_roles;
pub mod edit;
mod managed_features;
mod network_proxy_spec;
mod otel;
mod permissions;
#[cfg(test)]
mod schema;
@@ -2978,6 +2975,7 @@ impl Config {
.value
.set(effective_permission_profile)
.map_err(std::io::Error::from)?;
let otel = otel::resolve_config(cfg.otel.unwrap_or_default(), &mut startup_warnings);
let config = Self {
model,
service_tier,
@@ -3205,26 +3203,7 @@ impl Config {
.as_ref()
.map(|t| t.keymap.clone())
.unwrap_or_default(),
otel: {
let t: OtelConfigToml = cfg.otel.unwrap_or_default();
let log_user_prompt = t.log_user_prompt.unwrap_or(false);
let environment = t
.environment
.unwrap_or(DEFAULT_OTEL_ENVIRONMENT.to_string());
let exporter = t.exporter.unwrap_or(OtelExporterKind::None);
// OTLP HTTP endpoints are signal-specific in our config, so
// enabling log export must not implicitly send spans to a
// /v1/logs endpoint.
let trace_exporter = t.trace_exporter.unwrap_or(OtelExporterKind::None);
let metrics_exporter = t.metrics_exporter.unwrap_or(OtelExporterKind::Statsig);
OtelConfig {
log_user_prompt,
environment,
exporter,
trace_exporter,
metrics_exporter,
}
},
otel,
};
Ok(config)
})

View File

@@ -0,0 +1,117 @@
use std::collections::BTreeMap;
use std::fmt::Display;
use codex_config::types::DEFAULT_OTEL_ENVIRONMENT;
use codex_config::types::OtelConfig;
use codex_config::types::OtelConfigToml;
use codex_config::types::OtelExporterKind;
pub(crate) fn resolve_config(
config: OtelConfigToml,
startup_warnings: &mut Vec<String>,
) -> OtelConfig {
let log_user_prompt = config.log_user_prompt.unwrap_or(false);
let environment = config
.environment
.unwrap_or_else(|| DEFAULT_OTEL_ENVIRONMENT.to_string());
let exporter = config.exporter.unwrap_or(OtelExporterKind::None);
// OTLP HTTP endpoints are signal-specific in our config, so enabling log
// export must not implicitly send spans to a /v1/logs endpoint.
let trace_exporter = config.trace_exporter.unwrap_or(OtelExporterKind::None);
let metrics_exporter = config.metrics_exporter.unwrap_or(OtelExporterKind::Statsig);
// Provider initialization installs process-global OTEL state. Sanitize
// user-editable trace metadata here so malformed config is reported as a
// startup warning instead of making startup fail.
let span_attributes = resolve_span_attributes(config.span_attributes, startup_warnings);
let tracestate = resolve_tracestate(config.tracestate, startup_warnings);
OtelConfig {
log_user_prompt,
environment,
exporter,
trace_exporter,
metrics_exporter,
span_attributes,
tracestate,
}
}
fn resolve_span_attributes(
span_attributes: Option<BTreeMap<String, String>>,
startup_warnings: &mut Vec<String>,
) -> BTreeMap<String, String> {
let Some(span_attributes) = span_attributes else {
return BTreeMap::new();
};
let mut valid_attributes = BTreeMap::new();
for (key, value) in span_attributes {
let attribute = BTreeMap::from([(key.clone(), value.clone())]);
if let Err(err) = codex_otel::validate_span_attributes(&attribute) {
push_invalid_config_warning("otel.span_attributes", err, startup_warnings);
continue;
}
valid_attributes.insert(key, value);
}
valid_attributes
}
fn resolve_tracestate(
tracestate: Option<BTreeMap<String, BTreeMap<String, String>>>,
startup_warnings: &mut Vec<String>,
) -> BTreeMap<String, BTreeMap<String, String>> {
let Some(tracestate) = tracestate else {
return BTreeMap::new();
};
let mut valid_entries = BTreeMap::new();
for (member_key, fields) in tracestate {
let fields = resolve_tracestate_member_fields(&member_key, fields, startup_warnings);
if fields.is_empty() {
continue;
}
if let Err(err) = codex_otel::validate_tracestate_member(&member_key, &fields) {
push_invalid_config_warning("otel.tracestate", err, startup_warnings);
continue;
}
valid_entries.insert(member_key, fields);
}
// Tracestate members can be valid individually while the combined W3C
// tracestate header is not, so validate the filtered set before handing it
// to provider initialization.
if let Err(err) = codex_otel::validate_tracestate_entries(&valid_entries) {
push_invalid_config_warning("otel.tracestate", err, startup_warnings);
return BTreeMap::new();
}
valid_entries
}
fn resolve_tracestate_member_fields(
member_key: &str,
fields: BTreeMap<String, String>,
startup_warnings: &mut Vec<String>,
) -> BTreeMap<String, String> {
let mut valid_fields = BTreeMap::new();
for (field_key, value) in fields {
let field = BTreeMap::from([(field_key.clone(), value.clone())]);
if let Err(err) = codex_otel::validate_tracestate_member(member_key, &field) {
push_invalid_config_warning("otel.tracestate", err, startup_warnings);
continue;
}
valid_fields.insert(field_key, value);
}
valid_fields
}
fn push_invalid_config_warning(
config_key: &str,
err: impl Display,
startup_warnings: &mut Vec<String>,
) {
let message = format!("Ignoring invalid `{config_key}` config: {err}");
tracing::warn!("{message}");
startup_warnings.push(message);
}

View File

@@ -89,6 +89,8 @@ pub fn build_provider(
trace_exporter,
metrics_exporter,
runtime_metrics,
span_attributes: config.otel.span_attributes.clone(),
tracestate: config.otel.tracestate.clone(),
})
}