Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions codex-rs/app-server-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ use codex_config::ThreadConfigLoader;
use codex_config::config_toml::ConfigToml;
use codex_core::config::Config;
pub use codex_core::otel_init::build_provider as build_otel_provider;
pub use codex_core::otel_init::has_required_exporter as has_required_otel_exporter;
use codex_core::personality_migration::PersonalityMigrationStatus;
use codex_core::personality_migration::maybe_migrate_personality;
pub use codex_exec_server::EnvironmentManager;
Expand Down
17 changes: 17 additions & 0 deletions codex-rs/config/src/config_requirements.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ use crate::mcp_types::AppToolApproval;
use crate::permissions_toml::PermissionProfileToml;
use crate::types::AuthCredentialsStoreMode;
use crate::types::FeedbackConfigToml;
use crate::types::OtelConfigToml;
use crate::types::WindowsSandboxModeToml;

#[derive(Debug, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -152,6 +153,7 @@ pub struct ConfigRequirements {
pub log_dir: Option<Sourced<AbsolutePathBuf>>,
pub model_catalog_json: Option<Sourced<AbsolutePathBuf>>,
pub check_for_update_on_startup: Option<Sourced<bool>>,
pub otel: Option<Sourced<OtelConfigToml>>,
pub allow_login_shell: Option<Sourced<bool>>,
pub feedback: Option<Sourced<FeedbackConfigToml>>,
pub approval_policy: ConstrainedWithSource<AskForApproval>,
Expand Down Expand Up @@ -189,6 +191,7 @@ impl Default for ConfigRequirements {
log_dir: None,
model_catalog_json: None,
check_for_update_on_startup: None,
otel: None,
allow_login_shell: None,
feedback: None,
approval_policy: ConstrainedWithSource::new(
Expand Down Expand Up @@ -854,6 +857,7 @@ pub struct ConfigRequirementsToml {
pub log_dir: Option<AbsolutePathBuf>,
pub model_catalog_json: Option<AbsolutePathBuf>,
pub check_for_update_on_startup: Option<bool>,
pub otel: Option<OtelConfigToml>,
pub allow_login_shell: Option<bool>,
pub feedback: Option<FeedbackConfigToml>,
pub allowed_approval_policies: Option<Vec<AskForApproval>>,
Expand Down Expand Up @@ -920,6 +924,7 @@ pub struct ConfigRequirementsWithSources {
pub log_dir: Option<Sourced<AbsolutePathBuf>>,
pub model_catalog_json: Option<Sourced<AbsolutePathBuf>>,
pub check_for_update_on_startup: Option<Sourced<bool>>,
pub otel: Option<Sourced<OtelConfigToml>>,
pub allow_login_shell: Option<Sourced<bool>>,
pub feedback: Option<Sourced<FeedbackConfigToml>>,
pub allowed_approval_policies: Option<Sourced<Vec<AskForApproval>>>,
Expand Down Expand Up @@ -972,6 +977,7 @@ impl ConfigRequirementsWithSources {
log_dir: _,
model_catalog_json: _,
check_for_update_on_startup: _,
otel: _,
allow_login_shell: _,
feedback: _,
allowed_approval_policies: _,
Expand Down Expand Up @@ -1019,6 +1025,7 @@ impl ConfigRequirementsWithSources {
log_dir,
model_catalog_json,
check_for_update_on_startup,
otel,
allow_login_shell,
feedback,
allowed_approval_policies,
Expand Down Expand Up @@ -1063,6 +1070,7 @@ impl ConfigRequirementsWithSources {
log_dir,
model_catalog_json,
check_for_update_on_startup,
otel,
allow_login_shell,
feedback,
allowed_approval_policies,
Expand Down Expand Up @@ -1096,6 +1104,7 @@ impl ConfigRequirementsWithSources {
log_dir: log_dir.map(|sourced| sourced.value),
model_catalog_json: model_catalog_json.map(|sourced| sourced.value),
check_for_update_on_startup: check_for_update_on_startup.map(|sourced| sourced.value),
otel: otel.map(|sourced| sourced.value),
allow_login_shell: allow_login_shell.map(|sourced| sourced.value),
feedback: feedback.map(|sourced| sourced.value),
allowed_approval_policies: allowed_approval_policies.map(|sourced| sourced.value),
Expand Down Expand Up @@ -1196,6 +1205,10 @@ impl ConfigRequirementsToml {
&& self.log_dir.is_none()
&& self.model_catalog_json.is_none()
&& self.check_for_update_on_startup.is_none()
&& self
.otel
.as_ref()
.is_none_or(|otel| otel == &OtelConfigToml::default())
&& self.allow_login_shell.is_none()
&& self
.feedback
Expand Down Expand Up @@ -1263,6 +1276,7 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
log_dir,
model_catalog_json,
check_for_update_on_startup,
otel,
allow_login_shell,
feedback,
allowed_approval_policies,
Expand Down Expand Up @@ -1602,6 +1616,7 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
log_dir,
model_catalog_json,
check_for_update_on_startup,
otel,
allow_login_shell,
feedback,
approval_policy,
Expand Down Expand Up @@ -1703,6 +1718,7 @@ mod tests {
log_dir,
model_catalog_json,
check_for_update_on_startup,
otel,
allow_login_shell,
feedback,
allowed_approval_policies,
Expand Down Expand Up @@ -1743,6 +1759,7 @@ mod tests {
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
check_for_update_on_startup: check_for_update_on_startup
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
otel: otel.map(|value| Sourced::new(value, RequirementSource::Unknown)),
allow_login_shell: allow_login_shell
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
feedback: feedback.map(|value| Sourced::new(value, RequirementSource::Unknown)),
Expand Down
4 changes: 4 additions & 0 deletions codex-rs/config/src/requirements_layers/layer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use crate::ConfigRequirementsToml;
use crate::ManagedHooksRequirementsToml;
use crate::RequirementSource;
use crate::RequirementsExecPolicyToml;
use crate::types::OtelConfigToml;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::AbsolutePathBufGuard;
use toml::Value as TomlValue;
Expand Down Expand Up @@ -80,6 +81,7 @@ impl ComposableRequirementsLayer {
domain_fields: DomainMergedRequirementsFields {
rules: requirements.rules,
hooks: requirements.hooks,
otel: requirements.otel,
permissions: requirements.permissions,
},
})
Expand All @@ -90,6 +92,7 @@ impl ComposableRequirementsLayer {
pub(super) struct DomainMergedRequirementsFields {
pub(super) rules: Option<RequirementsExecPolicyToml>,
pub(super) hooks: Option<ManagedHooksRequirementsToml>,
pub(super) otel: Option<OtelConfigToml>,
pub(super) permissions: Option<crate::config_requirements::PermissionsRequirementsToml>,
}

Expand Down Expand Up @@ -163,6 +166,7 @@ fn toml_value_from_serializable<T: serde::Serialize>(
fn strip_special_fields(layer_toml: &mut TomlValue) {
remove_top_level_field(layer_toml, "rules");
remove_top_level_field(layer_toml, "hooks");
remove_top_level_field(layer_toml, "otel");
remove_nested_field_and_prune_empty(layer_toml, &["permissions", "filesystem", "deny_read"]);
}

Expand Down
1 change: 1 addition & 0 deletions codex-rs/config/src/requirements_layers/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
mod hooks;
mod layer;
mod otel;
mod permissions;
mod rules;
mod stack;
Expand Down
81 changes: 81 additions & 0 deletions codex-rs/config/src/requirements_layers/otel.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
use crate::RequirementSource;
use crate::Sourced;
use crate::types::OtelConfigToml;
use std::collections::BTreeMap;

/// Merges one OTEL requirements layer into an output assembled high-to-low.
///
/// `log_user_prompt` and `environment` use the first value supplied by a
/// higher-priority layer. Each exporter is also selected as a complete value:
/// for example, the endpoint from one layer is never combined with the headers
/// or TLS settings from another. Span attributes and tracestate are merged by
/// key instead.
pub(super) fn merge(
output: &mut Option<Sourced<OtelConfigToml>>,
incoming: Option<OtelConfigToml>,
source: &RequirementSource,
) {
let Some(incoming) = incoming.filter(|value| value != &OtelConfigToml::default()) else {
return;
};
let Some(output) = output.as_mut() else {
*output = Some(Sourced::new(incoming, source.clone()));
return;
};

let OtelConfigToml {
log_user_prompt,
environment,
exporter,
trace_exporter,
metrics_exporter,
span_attributes,
tracestate,
} = incoming;

macro_rules! fill_exact_leaf {
($field:ident) => {
if output.value.$field.is_none() {
output.value.$field = $field;
}
};
}

fill_exact_leaf!(log_user_prompt);
fill_exact_leaf!(environment);
fill_exact_leaf!(exporter);
fill_exact_leaf!(trace_exporter);
fill_exact_leaf!(metrics_exporter);
merge_map(&mut output.value.span_attributes, span_attributes);
merge_nested_map(&mut output.value.tracestate, tracestate);
super::stack::merge_output_source(&mut output.source, source);
}

fn merge_map(
output: &mut Option<BTreeMap<String, String>>,
incoming: Option<BTreeMap<String, String>>,
) {
let Some(incoming) = incoming else {
return;
};
let output = output.get_or_insert_default();
for (key, value) in incoming {
output.entry(key).or_insert(value);
}
}

fn merge_nested_map(
output: &mut Option<BTreeMap<String, BTreeMap<String, String>>>,
incoming: Option<BTreeMap<String, BTreeMap<String, String>>>,
) {
let Some(incoming) = incoming else {
return;
};
let output = output.get_or_insert_default();
for (member, fields) in incoming {
let output_fields = output.entry(member).or_default();
for (key, value) in fields {
output_fields.entry(key).or_insert(value);
}
}
}
8 changes: 5 additions & 3 deletions codex-rs/config/src/requirements_layers/stack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//!
//! A few fields carry domain-specific meaning that raw TOML replacement would
//! break:
//! - `otel` combines recursively merged metadata with atomic exporter leaves.
//! - `remote_sandbox_config` is evaluated within each layer before merging.
//! - `rules.prefix_rules` append high-priority rules first.
//! - `hooks` append high-priority event groups first while failing closed on
Expand Down Expand Up @@ -132,11 +133,11 @@ impl RequirementsLayerStack {
let mut hooks = HookMergeState::new(hook_directory_field);
let mut hooks_output = None;
let mut deny_read = DenyReadMergeState::default();
// Regular TOML fields are folded low-to-high like config. These custom
// fields append or union values, so process them high-to-low to keep
// priority order visible in the output.
// Domain-specific fields are processed high-to-low so exact leaves and
// keyed values naturally retain the highest-priority contribution.
for layer in layers.iter().rev() {
let domain_fields = &layer.domain_fields;
super::otel::merge(&mut output.otel, domain_fields.otel.clone(), &layer.source);
super::rules::merge(&mut rules, domain_fields.rules.clone(), &layer.source);
hooks.merge(
&mut hooks_output,
Expand Down Expand Up @@ -181,6 +182,7 @@ fn populate_merged_regular_fields_with_sources(
log_dir,
model_catalog_json,
check_for_update_on_startup,
otel: _,
allow_login_shell,
feedback,
allowed_approval_policies,
Expand Down
83 changes: 83 additions & 0 deletions codex-rs/config/src/requirements_layers/stack_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -991,3 +991,86 @@ api = false
))
);
}

#[test]
fn otel_exporters_replace_atomically_across_layers() {
let high_source = RequirementSource::EnterpriseManaged {
id: "req_high".to_string(),
name: "High".to_string(),
};
let low_source = RequirementSource::EnterpriseManaged {
id: "req_low".to_string(),
name: "Low".to_string(),
};
let composed = compose_requirements_for_hostname(
vec![
RequirementsLayerEntry::from_toml(
low_source.clone(),
r#"
[otel]
log_user_prompt = true
environment = "low"
exporter = { otlp-http = { endpoint = "https://logs.low.example", protocol = "binary", headers = { Authorization = "old-secret" } } }
trace_exporter = { otlp-http = { endpoint = "https://traces.low.example", protocol = "json", headers = { Trace = "old-trace" } } }
metrics_exporter = { otlp-grpc = { endpoint = "https://metrics.low.example", headers = { Metrics = "old-metrics" } } }

[otel.span_attributes]
region = "us"
team = "low"

[otel.tracestate.vendor]
low = "one"
shared = "low"
"#,
),
RequirementsLayerEntry::from_toml(
high_source.clone(),
r#"
[otel]
environment = "high"
exporter = { otlp-grpc = { endpoint = "https://logs.high.example" } }
trace_exporter = { otlp-http = { endpoint = "https://traces.high.example", protocol = "binary" } }
metrics_exporter = "none"

[otel.span_attributes]
team = "high"

[otel.tracestate.vendor]
high = "two"
shared = "high"
"#,
),
],
/*hostname*/ None,
)
.expect("compose requirements")
.expect("requirements present");

assert_eq!(
composed.otel,
Some(Sourced::new(
expected_requirements(
r#"
[otel]
log_user_prompt = true
environment = "high"
exporter = { otlp-grpc = { endpoint = "https://logs.high.example" } }
trace_exporter = { otlp-http = { endpoint = "https://traces.high.example", protocol = "binary" } }
metrics_exporter = "none"

[otel.span_attributes]
region = "us"
team = "high"

[otel.tracestate.vendor]
high = "two"
low = "one"
shared = "high"
"#,
)
.otel
.expect("expected OTEL requirements"),
RequirementSource::composite([high_source, low_source]),
))
);
}
Loading
Loading