Support managed OpenTelemetry configuration requirements (#57649)

## What changed

- Allow `requirements.toml` to manage OpenTelemetry logging flags, tool-result byte limits, environment, and log, trace, and metrics exporters.
- Apply higher-priority managed values over user configuration, replacing exporters atomically and warning when configured values conflict. Preserve unmanaged settings, including `span_attributes` and `tracestate`.
- Expose managed `otel` settings through `configRequirements/read`, apply them in `config/read` without ordinary field origins, and reject configuration writes that overlap managed fields.
- Distinguish omitted tool-result limits from explicit values while preserving the runtime default.

## Testing

Add coverage for requirement precedence, relative TLS paths, override warnings, managed write protection, effective configuration reads, and exporter serialization through the app-server API.

GitOrigin-RevId: 6a3343563bd3acb53a366ea6723684e3d0a4a01d
This commit is contained in:
Abhinav
2026-10-28 05:11:45 +00:00
committed by copyberry
parent d63fe536ee
commit 727283cea5
21 changed files with 823 additions and 10 deletions
+1
View File
@@ -2856,6 +2856,7 @@ dependencies = [
"serde_path_to_error",
"sha2 0.10.9",
"tempfile",
"test-case",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
+6
View File
@@ -702,3 +702,9 @@ Credential failures include `error.data.reason`.
For personal sign-in, users can run `gcloud auth application-default login` on
the app-server host and retry setup.
## Managed OpenTelemetry configuration
`configRequirements/read` exposes managed `otel` settings. `config/read` applies
those settings to the effective configuration and omits ordinary origins for
managed fields. Configuration writes that overlap managed settings are rejected.
@@ -1704,6 +1704,54 @@ async fn write_value_rejects_exact_managed_requirement() {
);
}
#[tokio::test]
async fn write_value_rejects_managed_otel_values_and_preserves_unmanaged_siblings() {
let tmp = tempdir().expect("tempdir");
let path = tmp.path().join(CONFIG_TOML_FILE);
std::fs::write(&path, "").expect("write user config");
let service = ConfigManager::new_for_tests(
tmp.path().to_path_buf(),
vec![],
LoaderOverrides::without_managed_config_for_tests(),
CloudConfigBundleFixture::loader_with_enterprise_requirement(
r#"[otel]
tool_result = { max_bytes = 1024 }
log_user_prompt = false
log_agent_responses = true
log_guardian_assessments = false
exporter = "none"
trace_exporter = "none"
metrics_exporter = "none""#,
),
);
for (key_path, managed) in [
("otel", true),
("otel.log_user_prompt", true),
("otel.log_agent_responses", true),
("otel.log_guardian_assessments", true),
("otel.exporter.otlp-http.endpoint", true),
("otel.trace_exporter.otlp-grpc.endpoint", true),
("otel.metrics_exporter.otlp-http.headers", true),
("otel.environment", false),
("otel.tool_result.max_bytes", true),
] {
let result = service
.write_value(ConfigValueWriteParams {
file_path: Some(path.display().to_string()),
key_path: key_path.to_string(),
value: serde_json::json!("user"),
merge_strategy: MergeStrategy::Replace,
expected_version: None,
})
.await;
assert_eq!(
result.err().and_then(|error| error.write_error_code()),
managed.then_some(ConfigWriteErrorCode::ConfigRequirementReadonly),
"OTEL path {key_path}"
);
}
}
fn toml_path(tmp: &Path, name: &str) -> String {
tmp.join(name).to_string_lossy().replace('\\', "\\\\")
}
@@ -1771,6 +1819,131 @@ enabled = false
}
}
#[tokio::test]
async fn read_projects_managed_otel_values_and_preserves_unmanaged_settings() {
let tmp = tempdir().expect("tempdir");
std::fs::write(
tmp.path().join(CONFIG_TOML_FILE),
r#"[otel]
tool_result = { max_bytes = 1234 }
log_user_prompt = true
environment = "user"
exporter = "statsig""#,
)
.expect("write user config");
let service = ConfigManager::new_for_tests(
tmp.path().to_path_buf(),
vec![],
LoaderOverrides::without_managed_config_for_tests(),
CloudConfigBundleFixture::loader_with_enterprise_requirement(
r#"[otel]
log_user_prompt = false
exporter = "none""#,
),
);
let response = service
.read(ConfigReadParams {
include_layers: false,
cwd: None,
})
.await
.expect("config read should succeed");
let otel = response
.config
.additional
.get("otel")
.expect("effective OTEL config");
assert_eq!(
otel,
&serde_json::json!({
"tool_result": { "max_bytes": 1234 },
"log_user_prompt": false,
"log_agent_responses": null,
"log_guardian_assessments": null,
"environment": "user",
"exporter": "none",
"trace_exporter": null,
"metrics_exporter": null,
"span_attributes": null,
"tracestate": null,
}),
);
for (path, user_owned) in [
("otel.log_user_prompt", false),
("otel.exporter", false),
("otel.environment", true),
("otel.tool_result.max_bytes", true),
] {
assert_eq!(response.origins.contains_key(path), user_owned, "{path}");
}
}
#[tokio::test]
async fn read_projects_managed_otel_tool_result_and_omits_its_origin() {
let tmp = tempdir().expect("tempdir");
std::fs::write(
tmp.path().join(CONFIG_TOML_FILE),
r#"[otel]
tool_result = { max_bytes = 1234 }"#,
)
.expect("write user config");
let service = ConfigManager::new_for_tests(
tmp.path().to_path_buf(),
vec![],
LoaderOverrides::without_managed_config_for_tests(),
CloudConfigBundleFixture::loader_with_enterprise_requirement(
r#"[otel]
tool_result = { max_bytes = 4096 }"#,
),
);
let response = service
.read(ConfigReadParams {
include_layers: false,
cwd: None,
})
.await
.expect("config read should succeed");
assert_eq!(
response
.config
.additional
.get("otel")
.and_then(|otel| otel.get("tool_result")),
Some(&serde_json::json!({ "max_bytes": 4096 }))
);
assert!(!response.origins.contains_key("otel.tool_result.max_bytes"));
}
#[tokio::test]
async fn read_keeps_omitted_otel_tool_result_unset() {
let tmp = tempdir().expect("tempdir");
std::fs::write(
tmp.path().join(CONFIG_TOML_FILE),
r#"[otel]
environment = "user""#,
)
.expect("write user config");
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
let response = service
.read(ConfigReadParams {
include_layers: false,
cwd: None,
})
.await
.expect("config read should succeed");
assert_eq!(
response
.config
.additional
.get("otel")
.and_then(|otel| otel.get("tool_result")),
Some(&serde_json::Value::Null)
);
}
#[tokio::test]
async fn read_materializes_default_allow_login_shell() {
let tmp = tempdir().expect("tempdir");
+1
View File
@@ -136,6 +136,7 @@ mod model_catalog;
mod models;
mod models_refresh_worker;
mod notification_media;
mod otel_config;
mod otel_reloader;
mod outgoing_message;
mod plugin_config_reload;
+64
View File
@@ -0,0 +1,64 @@
//! Maps OTEL requirements into app-server responses.
use codex_app_server_protocol::OtelExporter;
use codex_app_server_protocol::OtelHttpProtocol;
use codex_app_server_protocol::OtelRequirements;
use codex_app_server_protocol::OtelTlsConfig;
use codex_app_server_protocol::OtelToolResultLogConfig;
use codex_config::OtelRequirementsToml;
use codex_config::types::OtelExporterKind;
use codex_config::types::OtelHttpProtocol as CoreOtelHttpProtocol;
use codex_config::types::OtelTlsConfig as CoreOtelTlsConfig;
pub(crate) fn map_otel_requirements_to_api(otel: OtelRequirementsToml) -> OtelRequirements {
OtelRequirements {
tool_result: otel.tool_result.map(|value| OtelToolResultLogConfig {
max_bytes: value.max_bytes,
}),
log_user_prompt: otel.log_user_prompt,
log_agent_responses: otel.log_agent_responses,
log_guardian_assessments: otel.log_guardian_assessments,
environment: otel.environment,
exporter: otel.exporter.map(map_otel_exporter_to_api),
trace_exporter: otel.trace_exporter.map(map_otel_exporter_to_api),
metrics_exporter: otel.metrics_exporter.map(map_otel_exporter_to_api),
}
}
fn map_otel_exporter_to_api(exporter: OtelExporterKind) -> OtelExporter {
match exporter {
OtelExporterKind::None => OtelExporter::None,
OtelExporterKind::Statsig => OtelExporter::Statsig,
OtelExporterKind::OtlpHttp {
endpoint,
headers,
protocol,
tls,
} => OtelExporter::OtlpHttp {
endpoint,
headers,
protocol: match protocol {
CoreOtelHttpProtocol::Binary => OtelHttpProtocol::Binary,
CoreOtelHttpProtocol::Json => OtelHttpProtocol::Json,
},
tls: tls.map(map_otel_tls_to_api),
},
OtelExporterKind::OtlpGrpc {
endpoint,
headers,
tls,
} => OtelExporter::OtlpGrpc {
endpoint,
headers,
tls: tls.map(map_otel_tls_to_api),
},
}
}
fn map_otel_tls_to_api(tls: CoreOtelTlsConfig) -> OtelTlsConfig {
OtelTlsConfig {
ca_certificate: tls.ca_certificate.map(Into::into),
client_certificate: tls.client_certificate.map(Into::into),
client_private_key: tls.client_private_key.map(Into::into),
}
}
@@ -6,6 +6,7 @@ use crate::config_manager_service::ConfigManagerError;
use crate::error_code::internal_error;
use crate::error_code::invalid_request;
use crate::model_catalog::ModelCatalog;
use crate::otel_config::map_otel_requirements_to_api;
use crate::outgoing_message::ConnectionRequestId;
use crate::outgoing_message::OutgoingMessageSender;
use codex_analytics::AnalyticsEventsClient;
@@ -591,7 +592,9 @@ pub(crate) fn map_requirements_to_api(
service_tier: new_thread.service_tier,
}),
}),
otel: None,
otel: requirements
.otel
.map(|otel| map_otel_requirements_to_api(*otel)),
sqlite_home: requirements.sqlite_home.map(Into::into),
log_dir: requirements.log_dir.map(Into::into),
model_catalog_json: requirements.model_catalog_json.map(Into::into),
@@ -351,7 +351,8 @@ impl TurnRequestProcessor {
)
.await;
}
self.review_start_inner(request_id, params)
// Keep review setup from inflating the shared request dispatcher's stack frame.
Box::pin(self.review_start_inner(request_id, params))
.await
.map(|()| None)
}
@@ -599,6 +599,89 @@ async fn config_read_disables_guardian_v2_when_managed_config_requires_guardian_
Ok(())
}
#[test_case::test_case("json", "none"; "json_and_disabled_metrics")]
#[test_case::test_case("binary", "statsig"; "binary_and_statsig_metrics")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_requirements_read_includes_complete_otel_policy_and_headers(
protocol: &str,
metrics_exporter: &str,
) -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
// Read policy without starting exporters or opening the example TLS files.
std::fs::write(
codex_home.path().join("requirements.toml"),
r#"[otel]
tool_result = { max_bytes = 1024 }
log_user_prompt = false
log_agent_responses = true
log_guardian_assessments = false
environment = "managed"
exporter = { otlp-http = { endpoint = "http://127.0.0.1:1/v1/logs", protocol = "$PROTOCOL", headers = { Authorization = "Bearer managed-token" }, tls = { ca-certificate = "ca.pem", client-certificate = "client.pem", client-private-key = "client-key.pem" } } }
trace_exporter = { otlp-grpc = { endpoint = "http://127.0.0.1:4317", headers = { "x-api-key" = "managed-grpc-secret" }, tls = { ca-certificate = "ca.pem" } } }
metrics_exporter = "$METRICS_EXPORTER""#
.replace("$PROTOCOL", protocol)
.replace("$METRICS_EXPORTER", metrics_exporter),
)?;
let request_id = mcp.send_config_requirements_read_request().await?;
let response: serde_json::Value =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
assert_eq!(
response["requirements"]["otel"],
json!({
"tool_result": { "max_bytes": 1024 },
"log_user_prompt": false,
"log_agent_responses": true,
"log_guardian_assessments": false,
"environment": "managed",
"exporter": {
"otlp-http": {
"endpoint": "http://127.0.0.1:1/v1/logs",
"headers": { "Authorization": "Bearer managed-token" },
"protocol": protocol,
"tls": {
"ca-certificate": codex_home.path().join("ca.pem"),
"client-certificate": codex_home.path().join("client.pem"),
"client-private-key": codex_home.path().join("client-key.pem"),
},
},
},
"trace_exporter": {
"otlp-grpc": {
"endpoint": "http://127.0.0.1:4317",
"headers": { "x-api-key": "managed-grpc-secret" },
"tls": {
"ca-certificate": codex_home.path().join("ca.pem"),
"client-certificate": null,
"client-private-key": null,
},
},
},
"metrics_exporter": metrics_exporter,
})
);
// API clients must preserve certificate paths from servers on either OS.
let mut response = response;
response["requirements"]["otel"]["exporter"]["otlp-http"]["tls"]["ca-certificate"] =
json!(r"C:\certs\ca.pem");
response["requirements"]["otel"]["trace_exporter"]["otlp-grpc"]["tls"]["ca-certificate"] =
json!("/etc/certs/ca.pem");
let typed: ConfigRequirementsReadResponse = serde_json::from_value(response.clone())?;
assert_eq!(
serde_json::to_value(
typed
.requirements
.and_then(|requirements| requirements.otel)
)?,
response["requirements"]["otel"]
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_read_returns_effective_and_layers() -> Result<()> {
let codex_home = TempDir::new()?;
+1
View File
@@ -69,6 +69,7 @@ winreg = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
test-case = "3.3.1"
tokio = { workspace = true, features = ["full"] }
tokio-stream = { workspace = true, features = ["net"] }
tonic = { workspace = true, features = ["router", "transport"] }
+226 -1
View File
@@ -5,6 +5,7 @@ pub use codex_model_provider_info::ResidencyRequirement;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::config_types::ToolResultLogConfig;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ReasoningEffort;
@@ -39,6 +40,8 @@ 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::OtelExporterKind;
use crate::types::WindowsSandboxModeToml;
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -189,6 +192,8 @@ pub struct ConfigRequirements {
pub mcp_servers: Option<Sourced<BTreeMap<String, McpServerRequirement>>>,
pub plugins: Option<Sourced<BTreeMap<String, PluginRequirementsToml>>>,
pub marketplaces: Option<Sourced<MarketplaceRequirementsToml>>,
// Keep OTEL boxed in both forms so unset values do not inflate ConfigLayerStack/Config stack frames.
pub otel: Option<Box<Sourced<OtelRequirementsToml>>>,
pub exec_policy: Option<Sourced<RequirementsExecPolicy>>,
pub enforce_residency: ConstrainedWithSource<Option<ResidencyRequirement>>,
/// Managed network constraints derived from requirements.
@@ -250,6 +255,7 @@ impl Default for ConfigRequirements {
mcp_servers: None,
plugins: None,
marketplaces: None,
otel: None,
exec_policy: None,
enforce_residency: ConstrainedWithSource::new(
Constrained::allow_any(/*initial_value*/ None),
@@ -1085,6 +1091,7 @@ pub struct ConfigRequirementsToml {
pub mcp_servers: Option<BTreeMap<String, McpServerRequirement>>,
pub plugins: Option<BTreeMap<String, PluginRequirementsToml>>,
pub marketplaces: Option<MarketplaceRequirementsToml>,
pub otel: Option<Box<OtelRequirementsToml>>,
pub apps: Option<AppsRequirementsToml>,
pub rules: Option<RequirementsExecPolicyToml>,
pub enforce_residency: Option<ResidencyRequirement>,
@@ -1100,6 +1107,36 @@ pub struct ConfigRequirementsToml {
pub guardian_extra_policy: Option<String>,
}
/// OTEL settings that may be managed through `requirements.toml`.
///
/// Tool-result limits are optional here because `config.toml` defaults them,
/// while requirements must distinguish unset from managed-to-default.
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct OtelRequirementsToml {
pub tool_result: Option<ToolResultLogConfig>,
pub log_user_prompt: Option<bool>,
pub log_agent_responses: Option<bool>,
pub log_guardian_assessments: Option<bool>,
pub environment: Option<String>,
pub exporter: Option<OtelExporterKind>,
pub trace_exporter: Option<OtelExporterKind>,
pub metrics_exporter: Option<OtelExporterKind>,
}
impl OtelRequirementsToml {
pub(crate) fn is_empty(&self) -> bool {
self.tool_result.is_none()
&& self.log_user_prompt.is_none()
&& self.log_agent_responses.is_none()
&& self.log_guardian_assessments.is_none()
&& self.environment.is_none()
&& self.exporter.is_none()
&& self.trace_exporter.is_none()
&& self.metrics_exporter.is_none()
}
}
#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
pub struct AutoReviewRequirementsToml {
pub required_on_models: Option<Vec<String>>,
@@ -1195,6 +1232,7 @@ pub struct ConfigRequirementsWithSources {
pub mcp_servers: Option<Sourced<BTreeMap<String, McpServerRequirement>>>,
pub plugins: Option<Sourced<BTreeMap<String, PluginRequirementsToml>>>,
pub marketplaces: Option<Sourced<MarketplaceRequirementsToml>>,
pub otel: Option<Box<Sourced<OtelRequirementsToml>>>,
pub apps: Option<Sourced<AppsRequirementsToml>>,
pub rules: Option<Sourced<RequirementsExecPolicyToml>>,
pub enforce_residency: Option<Sourced<ResidencyRequirement>>,
@@ -1261,6 +1299,7 @@ impl ConfigRequirementsWithSources {
mcp_servers: _,
plugins: _,
marketplaces: _,
otel: _,
apps: _,
rules: _,
enforce_residency: _,
@@ -1339,6 +1378,12 @@ impl ConfigRequirementsWithSources {
}
);
if self.otel.is_none()
&& let Some(value) = other.otel.take()
{
self.otel = Some(Box::new(Sourced::new(*value, source.clone())));
}
if let Some(incoming_auto_review) = other.auto_review.take() {
if let Some(existing_auto_review) = self.auto_review.as_mut() {
let mut source_contributed = false;
@@ -1415,6 +1460,7 @@ impl ConfigRequirementsWithSources {
mcp_servers,
plugins,
marketplaces,
otel,
apps,
rules,
enforce_residency,
@@ -1464,6 +1510,7 @@ impl ConfigRequirementsWithSources {
mcp_servers: mcp_servers.map(|sourced| sourced.value),
plugins: plugins.map(|sourced| sourced.value),
marketplaces: marketplaces.map(|sourced| sourced.value),
otel: otel.map(|sourced| Box::new(sourced.value)),
apps: apps.map(|sourced| sourced.value),
rules: rules.map(|sourced| sourced.value),
enforce_residency: enforce_residency.map(|sourced| sourced.value),
@@ -1604,6 +1651,10 @@ impl ConfigRequirementsToml {
.marketplaces
.as_ref()
.is_none_or(MarketplaceRequirementsToml::is_empty)
&& self
.otel
.as_deref()
.is_none_or(OtelRequirementsToml::is_empty)
&& self
.apps
.as_ref()
@@ -1683,6 +1734,11 @@ impl ConfigRequirementsToml {
if let Some(enabled) = self.feedback.as_ref().and_then(|feedback| feedback.enabled) {
config.feedback.get_or_insert_default().enabled = Some(enabled);
}
if let Some(requirements) = &self.otel
&& !requirements.is_empty()
{
apply_otel_requirements(config.otel.get_or_insert_default(), requirements);
}
}
/// Returns the exact managed field affected by editing `segments`.
@@ -1702,7 +1758,8 @@ impl ConfigRequirementsToml {
}) {
return Some("model_providers");
}
let managed_fields: [(bool, &[&str], &'static str); 9] = [
let otel = self.otel.as_ref();
let managed_fields: [(bool, &[&str], &'static str); 17] = [
(
self.model_provider.is_some(),
&["model_provider"],
@@ -1743,6 +1800,46 @@ impl ConfigRequirementsToml {
&["chatgpt_base_url"],
"chatgpt_base_url",
),
(
otel.is_some_and(|otel| otel.tool_result.is_some()),
&["otel", "tool_result"],
"otel.tool_result",
),
(
otel.is_some_and(|otel| otel.log_user_prompt.is_some()),
&["otel", "log_user_prompt"],
"otel.log_user_prompt",
),
(
otel.is_some_and(|otel| otel.log_agent_responses.is_some()),
&["otel", "log_agent_responses"],
"otel.log_agent_responses",
),
(
otel.is_some_and(|otel| otel.log_guardian_assessments.is_some()),
&["otel", "log_guardian_assessments"],
"otel.log_guardian_assessments",
),
(
otel.is_some_and(|otel| otel.environment.is_some()),
&["otel", "environment"],
"otel.environment",
),
(
otel.is_some_and(|otel| otel.exporter.is_some()),
&["otel", "exporter"],
"otel.exporter",
),
(
otel.is_some_and(|otel| otel.trace_exporter.is_some()),
&["otel", "trace_exporter"],
"otel.trace_exporter",
),
(
otel.is_some_and(|otel| otel.metrics_exporter.is_some()),
&["otel", "metrics_exporter"],
"otel.metrics_exporter",
),
];
managed_fields
@@ -1753,6 +1850,47 @@ impl ConfigRequirementsToml {
}
}
macro_rules! otel_merge_functions {
($($field:ident),+ $(,)?) => {
/// Merges the supplied fields and reports whether this layer contributed a requirement.
pub(crate) fn merge_otel_requirements(
requirements: &mut Option<OtelRequirementsToml>,
higher_priority: &OtelRequirementsToml,
) -> bool {
let mut contributed = false;
$(if let Some(value) = &higher_priority.$field {
requirements.get_or_insert_default().$field = Some(value.clone());
contributed = true;
})+
contributed
}
/// Applies managed OTEL values and reports whether any configured value was overridden.
pub fn apply_otel_requirements(
config: &mut OtelConfigToml,
requirements: &OtelRequirementsToml,
) -> bool {
let mut conflict = false;
$(if let Some(value) = &requirements.$field {
conflict |= config.$field.as_ref().is_some_and(|current| current != value);
config.$field = Some(value.clone());
})+
conflict
}
};
}
otel_merge_functions!(
tool_result,
log_user_prompt,
log_agent_responses,
log_guardian_assessments,
environment,
exporter,
trace_exporter,
metrics_exporter,
);
fn config_paths_overlap(segments: &[String], managed_path: &[&str]) -> bool {
segments
.iter()
@@ -1821,6 +1959,7 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
mcp_servers,
plugins,
marketplaces,
otel,
apps: _apps,
rules,
enforce_residency,
@@ -2208,6 +2347,7 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
mcp_servers,
plugins,
marketplaces,
otel,
exec_policy,
enforce_residency,
network,
@@ -2323,6 +2463,78 @@ mod tests {
}
}
#[test_case::test_case(&["otel"], Some("otel.tool_result"); "otel_parent")]
#[test_case::test_case(&["otel", "tool_result"], Some("otel.tool_result"); "tool_result")]
#[test_case::test_case(&["otel", "tool_result", "max_bytes"], Some("otel.tool_result"); "tool_result_max_bytes")]
#[test_case::test_case(&["otel", "log_user_prompt"], Some("otel.log_user_prompt"); "log_user_prompt")]
#[test_case::test_case(&["otel", "environment"], Some("otel.environment"); "environment")]
#[test_case::test_case(&["otel", "exporter", "otlp-http", "endpoint"], Some("otel.exporter"); "exporter_endpoint")]
#[test_case::test_case(&["otel", "trace_exporter"], Some("otel.trace_exporter"); "trace_exporter")]
#[test_case::test_case(&["otel", "metrics_exporter"], Some("otel.metrics_exporter"); "metrics_exporter")]
#[test_case::test_case(&["otel", "span_attributes", "service.name"], None; "unmanaged_span_attribute")]
#[test_case::test_case(&["otel", "tracestate", "user"], None; "unmanaged_tracestate")]
fn otel_requirements_project_and_lock_exact_scalar_and_exporter_values(
segments: &[&str],
expected: Option<&str>,
) -> Result<()> {
let requirements: ConfigRequirementsToml = from_str(
r#"
[otel]
tool_result = { max_bytes = 1024 }
log_user_prompt = false
environment = "managed"
exporter = "none"
trace_exporter = "none"
metrics_exporter = "none"
"#,
)?;
let mut configured: ConfigToml = from_str(
r#"
[otel]
tool_result = { max_bytes = 4096 }
log_user_prompt = true
environment = "user"
exporter = "statsig"
trace_exporter = { otlp-grpc = { endpoint = "https://user.example" } }
metrics_exporter = "statsig"
span_attributes = { "service.name" = "user" }
tracestate = { user = { tenant = "preserved" } }
"#,
)?;
requirements.apply_exact_to_config(&mut configured);
assert_eq!(
configured,
from_str::<ConfigToml>(
r#"
[otel]
tool_result = { max_bytes = 1024 }
log_user_prompt = false
environment = "managed"
exporter = "none"
trace_exporter = "none"
metrics_exporter = "none"
span_attributes = { "service.name" = "user" }
tracestate = { user = { tenant = "preserved" } }
"#
)?
);
assert!(!requirements.is_empty());
let segments = segments.iter().map(ToString::to_string).collect::<Vec<_>>();
assert_eq!(
requirements.exact_requirement_for_config_path(&segments),
expected,
);
let empty: ConfigRequirementsToml = from_str("[otel]")?;
assert!(empty.is_empty());
let mut configured = ConfigToml::default();
empty.apply_exact_to_config(&mut configured);
assert_eq!(configured.otel, None);
Ok(())
}
#[test]
fn composite_requirement_source_flattens_and_deduplicates_sources() {
let mdm_source = RequirementSource::MdmManagedPreferences {
@@ -2378,6 +2590,7 @@ mod tests {
mcp_servers,
plugins,
marketplaces,
otel,
apps,
rules,
enforce_residency,
@@ -2447,6 +2660,7 @@ mod tests {
mcp_servers: mcp_servers.map(|value| Sourced::new(value, RequirementSource::Unknown)),
plugins: plugins.map(|value| Sourced::new(value, RequirementSource::Unknown)),
marketplaces: marketplaces.map(|value| Sourced::new(value, RequirementSource::Unknown)),
otel: otel.map(|value| Box::new(Sourced::new(*value, RequirementSource::Unknown))),
apps: apps.map(|value| Sourced::new(value, RequirementSource::Unknown)),
rules: rules.map(|value| Sourced::new(value, RequirementSource::Unknown)),
enforce_residency: enforce_residency
@@ -2963,6 +3177,10 @@ mod tests {
mcp_servers: None,
plugins: None,
marketplaces: None,
otel: Some(Box::new(OtelRequirementsToml {
environment: Some("managed".to_string()),
..Default::default()
})),
apps: None,
rules: None,
enforce_residency: Some(enforce_residency),
@@ -3056,6 +3274,13 @@ mod tests {
mcp_servers: None,
plugins: None,
marketplaces: None,
otel: Some(Box::new(Sourced::new(
OtelRequirementsToml {
environment: Some("managed".to_string()),
..Default::default()
},
source.clone(),
))),
apps: None,
rules: None,
enforce_residency: Some(Sourced::new(enforce_residency, enforce_source)),
+2
View File
@@ -138,6 +138,7 @@ pub use config_requirements::NetworkRequirementsToml;
pub use config_requirements::NetworkUnixSocketPermissionToml;
pub use config_requirements::NetworkUnixSocketPermissionsToml;
pub use config_requirements::NewThreadModelDefaultsToml;
pub use config_requirements::OtelRequirementsToml;
pub use config_requirements::RemoteSandboxConfigToml;
pub use config_requirements::RequirementSource;
pub use config_requirements::ResidencyRequirement;
@@ -146,6 +147,7 @@ pub use config_requirements::Sourced;
pub use config_requirements::WebSearchModeRequirement;
pub use config_requirements::WindowsRequirementsToml;
pub use config_requirements::WindowsSandboxImplementationToml;
pub use config_requirements::apply_otel_requirements;
pub use config_requirements::sandbox_mode_requirement_for_permission_profile;
pub use config_server::ConfigServer;
pub use config_server::ConfigServerOAuth;
@@ -1,6 +1,7 @@
use crate::ConfigPathContext;
use crate::ConfigRequirementsToml;
use crate::ManagedHooksRequirementsToml;
use crate::OtelRequirementsToml;
use crate::RequirementSource;
use crate::RequirementsExecPolicyToml;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -178,6 +179,7 @@ impl ComposableRequirementsLayer {
hooks: requirements.hooks,
permissions: requirements.permissions,
auto_review: requirements.auto_review,
otel: requirements.otel,
},
})
}
@@ -189,6 +191,7 @@ pub(super) struct DomainMergedRequirementsFields {
pub(super) hooks: Option<ManagedHooksRequirementsToml>,
pub(super) permissions: Option<crate::config_requirements::PermissionsRequirementsToml>,
pub(super) auto_review: Option<crate::config_requirements::AutoReviewRequirementsToml>,
pub(super) otel: Option<Box<OtelRequirementsToml>>,
}
fn parse_layer_toml(
@@ -285,6 +288,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"]);
remove_nested_field_and_prune_empty(layer_toml, &["auto_review", "required_on_models"]);
}
@@ -10,6 +10,7 @@
//! - `rules.prefix_rules` append high-priority rules first.
//! - `hooks` append high-priority event groups first while failing closed on
//! active managed-dir conflicts.
//! - `otel` replaces exact managed values, treating exporters atomically.
//! - `permissions.filesystem.deny_read` is a high-priority-first union across
//! layers.
//! - `auto_review.required_on_models` is a high-priority-first union across layers.
@@ -18,6 +19,7 @@ use crate::ConfigRequirementsToml;
use crate::ConfigRequirementsWithSources;
use crate::RequirementSource;
use crate::Sourced;
use crate::config_requirements::merge_otel_requirements;
use crate::merge::merge_toml_values;
use std::cell::OnceCell;
use std::io;
@@ -169,6 +171,23 @@ impl RequirementsLayerStack {
})?;
let mut output = ConfigRequirementsWithSources::default();
populate_merged_regular_fields_with_sources(&mut output, requirements, &layers);
// Merge OTEL requirements so higher-priority exact values replace
// lower-priority values while exporters stay atomic.
let mut otel = None;
let mut otel_sources = Vec::new();
for layer in &layers {
if let Some(incoming) = layer.domain_fields.otel.as_deref()
&& merge_otel_requirements(&mut otel, incoming)
{
otel_sources.push(layer.source.clone());
}
}
output.otel = otel.map(|value| {
Box::new(Sourced::new(
value,
RequirementSource::composite(otel_sources.into_iter().rev()),
))
});
let mut rules = None;
let mut hooks = HookMergeState::new(hook_directory_field);
let mut hooks_output = None;
@@ -251,6 +270,7 @@ fn populate_merged_regular_fields_with_sources(
mcp_servers,
plugins,
marketplaces,
otel: _,
apps,
rules: _,
enforce_residency,
@@ -340,6 +340,104 @@ refresh_interval_ms = 12345
}
}
#[test]
fn otel_requirements_compose_exact_values_and_atomic_exporters_with_relative_tls_paths() {
let low_dir = tempdir().expect("low-priority requirements directory");
let high_dir = tempdir().expect("high-priority requirements directory");
let low_base = AbsolutePathBuf::from_absolute_path(low_dir.path()).expect("absolute low base");
let high_base =
AbsolutePathBuf::from_absolute_path(high_dir.path()).expect("absolute high base");
let low = layer(
"req_low",
"Low",
r#"
[otel]
tool_result = { max_bytes = 1024 }
log_user_prompt = true
environment = "low"
exporter = { otlp-http = { endpoint = "https://low.example", protocol = "json", headers = { stale = "low" }, tls = { ca-certificate = "low-ca.pem" } } }
trace_exporter = { otlp-grpc = { endpoint = "https://low.example", tls = { client-certificate = "low-client.pem" } } }
metrics_exporter = "statsig"
"#,
)
.with_base_dir(low_base);
let high = layer(
"req_high",
"High",
r#"
[otel]
tool_result = { max_bytes = 4096 }
log_user_prompt = false
environment = "high"
exporter = { otlp-http = { endpoint = "https://high.example", protocol = "binary", tls = { ca-certificate = "high-ca.pem" } } }
metrics_exporter = "none"
"#,
)
.with_base_dir(high_base);
let expected_source = RequirementSource::composite([high.source.clone(), low.source.clone()]);
let composed = compose_requirements_for_hostname(
vec![low, high, layer("empty", "Empty", "[otel]")],
/*hostname*/ None,
)
.expect("compose OTEL requirements")
.expect("requirements present")
.otel
.expect("OTEL requirements present");
assert_eq!(composed.source, expected_source);
let expected = r#"
[otel]
tool_result = { max_bytes = 4096 }
log_user_prompt = false
environment = "high"
exporter = { otlp-http = { endpoint = "https://high.example", protocol = "binary", tls = { ca-certificate = '$HIGH_CA' } } }
trace_exporter = { otlp-grpc = { endpoint = "https://low.example", tls = { client-certificate = '$LOW_CLIENT' } } }
metrics_exporter = "none"
"#
.replace(
"$HIGH_CA",
&high_dir.path().join("high-ca.pem").display().to_string(),
)
.replace(
"$LOW_CLIENT",
&low_dir.path().join("low-client.pem").display().to_string(),
);
assert_eq!(
composed.value,
*expected_requirements(expected).otel.unwrap()
);
assert_eq!(compose(vec![layer("empty", "Empty", "[otel]")]), Ok(None));
}
#[test]
fn managed_otel_trace_metadata_is_rejected_with_its_requirements_source() {
for field in ["span_attributes", "tracestate"] {
let error = compose(vec![layer(
"req_metadata",
"Managed policy",
&format!("[otel]\n{field} = {{}}"),
)])
.expect_err("metadata is unsupported");
let RequirementsCompositionError::Parse {
layer_source,
message,
} = error
else {
panic!("expected a layer parse error: {error:?}");
};
assert_eq!(
layer_source,
RequirementSource::EnterpriseManaged {
id: "req_metadata".to_string(),
name: "Managed policy".to_string(),
},
);
assert!(message.contains(&format!("unknown field `{field}`")));
}
}
#[test]
fn provider_auth_missing_command_is_rejected_after_composition() {
let err = compose(vec![
+1 -2
View File
@@ -635,8 +635,7 @@ pub enum OtelExporterKind {
#[schemars(deny_unknown_fields)]
pub struct OtelConfigToml {
/// Byte limit for tool-result log output; independent of model-visible output.
#[serde(default)]
pub tool_result: codex_protocol::config_types::ToolResultLogConfig,
pub tool_result: Option<codex_protocol::config_types::ToolResultLogConfig>,
/// Log user prompt in traces
pub log_user_prompt: Option<bool>,
/// Opt in to logging final main-agent and spawned-subagent responses to an OTLP log exporter.
-3
View File
@@ -3796,9 +3796,6 @@
"$ref": "#/definitions/ToolResultLogConfig"
}
],
"default": {
"max_bytes": 2048
},
"description": "Byte limit for tool-result log output; independent of model-visible output."
},
"trace_exporter": {
+129 -1
View File
@@ -10337,7 +10337,7 @@ async fn trace_exporter_defaults_to_none_when_log_exporter_is_set() -> std::io::
let fixture = create_test_fixture()?;
let mut cfg = fixture.cfg.clone();
cfg.otel = Some(OtelConfigToml {
tool_result: toml::from_str("max_bytes = 8192").expect("tool-result logging config"),
tool_result: Some(toml::from_str("max_bytes = 8192").expect("tool-result logging config")),
log_agent_responses: Some(true),
log_guardian_assessments: Some(true),
exporter: Some(OtelExporterKind::OtlpHttp {
@@ -10690,6 +10690,7 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset()
mcp_servers: None,
plugins: None,
marketplaces: None,
otel: None,
apps: None,
rules: None,
enforce_residency: None,
@@ -13919,6 +13920,133 @@ enabled = false
Ok(())
}
#[test_case::test_case(
r#"
[otel]
tool_result = { max_bytes = 4096 }
log_user_prompt = true
environment = "user"
exporter = { otlp-http = { endpoint = "https://user.example/logs", protocol = "json", headers = { Authorization = "user-secret" } } }
trace_exporter = { otlp-grpc = { endpoint = "https://user.example:4317" } }
metrics_exporter = "statsig"
span_attributes = { "deployment.environment" = "user", machine = "laptop" }
tracestate = { vendor = { tenant = "user", region = "us" }, other = { keep = "yes" } }
"#,
r#"
[otel]
tool_result = { max_bytes = 1024 }
log_user_prompt = false
environment = "production"
exporter = { otlp-http = { endpoint = "https://managed.example/logs", protocol = "binary", headers = { Authorization = "managed-secret" } } }
trace_exporter = "none"
metrics_exporter = { otlp-grpc = { endpoint = "https://managed.example:4317" } }
"#,
r#"
[otel]
tool_result = { max_bytes = 1024 }
log_user_prompt = false
environment = "production"
exporter = { otlp-http = { endpoint = "https://managed.example/logs", protocol = "binary", headers = { Authorization = "managed-secret" } } }
trace_exporter = "none"
metrics_exporter = { otlp-grpc = { endpoint = "https://managed.example:4317" } }
span_attributes = { "deployment.environment" = "user", machine = "laptop" }
tracestate = { vendor = { tenant = "user", region = "us" }, other = { keep = "yes" } }
"#,
true; "managed_exact_values_and_user_metadata"
)]
#[test_case::test_case(
"[otel]\nenvironment = 'user'", "[otel]\ntool_result = { max_bytes = 1024 }",
"[otel]\nenvironment = 'user'\ntool_result = { max_bytes = 1024 }", false; "unset_user_limit"
)]
#[test_case::test_case(
"[otel]\ntool_result = { max_bytes = 2048 }", "[otel]\ntool_result = { max_bytes = 1024 }",
"[otel]\ntool_result = { max_bytes = 1024 }", true; "explicit_default_user_limit"
)]
#[test_case::test_case(
"", "[otel]\ntool_result = {}", "[otel]\ntool_result = { max_bytes = 2048 }", false; "managed_default_limit"
)]
#[test_case::test_case(
"", "[otel]\ntool_result = { max_bytes = 0 }", "[otel]\ntool_result = { max_bytes = 0 }", false; "managed_zero_limit"
)]
#[test_case::test_case(
"[otel]\nlog_agent_responses = false\nlog_guardian_assessments = true",
"[otel]\nlog_agent_responses = true\nlog_guardian_assessments = false",
"[otel]\nlog_agent_responses = true\nlog_guardian_assessments = false", true; "managed_logging_flags"
)]
#[tokio::test]
async fn otel_requirements_resolve_config(
user_config: &str,
requirements: &str,
expected_config: &str,
expect_override_warning: bool,
) -> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(codex_home.path().join(CONFIG_TOML_FILE), user_config)?;
let config = load_with_enterprise_requirement(&codex_home, requirements).await?;
let expected: ConfigToml =
toml::from_str(expected_config).expect("expected OTEL config should deserialize");
let mut warnings = Vec::new();
assert_eq!(
config.otel,
super::otel::resolve_config(expected.otel.unwrap_or_default(), &mut warnings)
);
assert_eq!(
config
.startup_warnings
.iter()
.find(|warning| warning.contains("Configured values under `otel` are overridden"))
.map(String::as_str),
expect_override_warning.then_some(
"Configured values under `otel` are overridden by requirements from enterprise-managed requirements Base requirements (req_1).",
),
);
assert!(
config
.startup_warnings
.iter()
.all(|warning| !warning.contains("managed-secret"))
);
Ok(())
}
#[tokio::test]
async fn analytics_disabled_preserves_managed_metrics_exporter() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"
[analytics]
enabled = false
"#,
)?;
let config = load_with_enterprise_requirement(
&codex_home,
r#"
[otel]
metrics_exporter = { otlp-http = { endpoint = "http://127.0.0.1:1/v1/metrics", protocol = "json" } }
"#,
)
.await?;
assert!(matches!(
config.otel.metrics_exporter,
OtelExporterKind::OtlpHttp { .. }
));
assert!(
crate::otel_init::build_provider(
&config,
env!("CARGO_PKG_VERSION"),
/*service_name_override*/ None,
/*default_analytics_enabled*/ true,
)
.expect("managed metrics configuration should remain valid")
.is_some(),
"analytics.enabled=false should preserve the custom managed metrics exporter"
);
Ok(())
}
#[tokio::test]
async fn absent_allow_login_shell_does_not_report_an_override() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
+1
View File
@@ -3441,6 +3441,7 @@ impl Config {
check_for_update_on_startup: _,
allow_login_shell: _,
feedback: _,
otel: _,
approval_policy: mut constrained_approval_policy,
approvals_reviewer: mut constrained_approvals_reviewer,
auto_review_required_models: _,
+1 -1
View File
@@ -26,7 +26,7 @@ pub(crate) fn resolve_config(
let tracestate = resolve_tracestate(config.tracestate, startup_warnings);
OtelConfig {
tool_result: config.tool_result,
tool_result: config.tool_result.unwrap_or_default(),
log_user_prompt,
log_agent_responses: config.log_agent_responses.unwrap_or(false),
log_guardian_assessments: config.log_guardian_assessments.unwrap_or(false),
+5
View File
@@ -71,6 +71,11 @@ pub(super) fn apply_to_config(
}
}
}
if let Some(Sourced { value, source }) = requirements.otel.as_deref() {
let conflict =
codex_config::apply_otel_requirements(config.otel.get_or_insert_default(), value);
push_structured_requirement_override_warning("otel", conflict, source, startup_warnings);
}
}
fn apply_exact_requirement<T>(
+1
View File
@@ -1001,6 +1001,7 @@ interrupt_message = false
)])),
plugins: None,
marketplaces: None,
otel: None,
apps: None,
rules: None,
enforce_residency: Some(ResidencyRequirement::Us),