From 727283cea5ba30e8cf8bbac6b68a03a4d91cf1b7 Mon Sep 17 00:00:00 2001 From: Abhinav Date: Wed, 28 Oct 2026 05:02:34 +0000 Subject: [PATCH] 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 --- codex-rs/Cargo.lock | 1 + codex-rs/app-server/README.md | 6 + .../src/config_manager_service_tests.rs | 173 +++++++++++++ codex-rs/app-server/src/lib.rs | 1 + codex-rs/app-server/src/otel_config.rs | 64 +++++ .../request_processors/config_processor.rs | 5 +- .../src/request_processors/turn_processor.rs | 3 +- .../app-server/tests/suite/v2/config_rpc.rs | 83 +++++++ codex-rs/config/Cargo.toml | 1 + codex-rs/config/src/config_requirements.rs | 227 +++++++++++++++++- codex-rs/config/src/lib.rs | 2 + .../config/src/requirements_layers/layer.rs | 4 + .../config/src/requirements_layers/stack.rs | 20 ++ .../src/requirements_layers/stack_tests.rs | 98 ++++++++ codex-rs/config/src/types.rs | 3 +- codex-rs/core/config.schema.json | 3 - codex-rs/core/src/config/config_tests.rs | 130 +++++++++- codex-rs/core/src/config/mod.rs | 1 + codex-rs/core/src/config/otel.rs | 2 +- codex-rs/core/src/config/requirements.rs | 5 + codex-rs/tui/src/debug_config.rs | 1 + 21 files changed, 823 insertions(+), 10 deletions(-) create mode 100644 codex-rs/app-server/src/otel_config.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index cc7e0d30f9..b164e44c15 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2856,6 +2856,7 @@ dependencies = [ "serde_path_to_error", "sha2 0.10.9", "tempfile", + "test-case", "thiserror 2.0.18", "tokio", "tokio-stream", diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index a557a984d9..71135843bb 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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. diff --git a/codex-rs/app-server/src/config_manager_service_tests.rs b/codex-rs/app-server/src/config_manager_service_tests.rs index 2948434c95..f8e17eff3c 100644 --- a/codex-rs/app-server/src/config_manager_service_tests.rs +++ b/codex-rs/app-server/src/config_manager_service_tests.rs @@ -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"); diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 819048a63c..b3aa3b978b 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -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; diff --git a/codex-rs/app-server/src/otel_config.rs b/codex-rs/app-server/src/otel_config.rs new file mode 100644 index 0000000000..b9f5122d35 --- /dev/null +++ b/codex-rs/app-server/src/otel_config.rs @@ -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), + } +} diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index dc8a3e02d7..b31f910fe2 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -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), diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index c78613c0ed..c4264fc28d 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -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) } diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index 20476284ec..7e271bad87 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -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()?; diff --git a/codex-rs/config/Cargo.toml b/codex-rs/config/Cargo.toml index 0663cb9e4b..9db5c0711e 100644 --- a/codex-rs/config/Cargo.toml +++ b/codex-rs/config/Cargo.toml @@ -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"] } diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 3cfc55cad3..b487c4a1b7 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -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>>, pub plugins: Option>>, pub marketplaces: Option>, + // Keep OTEL boxed in both forms so unset values do not inflate ConfigLayerStack/Config stack frames. + pub otel: Option>>, pub exec_policy: Option>, pub enforce_residency: ConstrainedWithSource>, /// 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>, pub plugins: Option>, pub marketplaces: Option, + pub otel: Option>, pub apps: Option, pub rules: Option, pub enforce_residency: Option, @@ -1100,6 +1107,36 @@ pub struct ConfigRequirementsToml { pub guardian_extra_policy: Option, } +/// 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, + pub log_user_prompt: Option, + pub log_agent_responses: Option, + pub log_guardian_assessments: Option, + pub environment: Option, + pub exporter: Option, + pub trace_exporter: Option, + pub metrics_exporter: Option, +} + +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>, @@ -1195,6 +1232,7 @@ pub struct ConfigRequirementsWithSources { pub mcp_servers: Option>>, pub plugins: Option>>, pub marketplaces: Option>, + pub otel: Option>>, pub apps: Option>, pub rules: Option>, pub enforce_residency: Option>, @@ -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, + 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 for ConfigRequirements { mcp_servers, plugins, marketplaces, + otel, apps: _apps, rules, enforce_residency, @@ -2208,6 +2347,7 @@ impl TryFrom 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::( + 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::>(); + 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)), diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index 9b6cd84638..6bff2b4f88 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -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; diff --git a/codex-rs/config/src/requirements_layers/layer.rs b/codex-rs/config/src/requirements_layers/layer.rs index f4a08d972c..9fca525118 100644 --- a/codex-rs/config/src/requirements_layers/layer.rs +++ b/codex-rs/config/src/requirements_layers/layer.rs @@ -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, pub(super) permissions: Option, pub(super) auto_review: Option, + pub(super) otel: Option>, } fn parse_layer_toml( @@ -285,6 +288,7 @@ fn toml_value_from_serializable( 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"]); } diff --git a/codex-rs/config/src/requirements_layers/stack.rs b/codex-rs/config/src/requirements_layers/stack.rs index d3510bddb1..e37dce2dc8 100644 --- a/codex-rs/config/src/requirements_layers/stack.rs +++ b/codex-rs/config/src/requirements_layers/stack.rs @@ -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, diff --git a/codex-rs/config/src/requirements_layers/stack_tests.rs b/codex-rs/config/src/requirements_layers/stack_tests.rs index 9d4cb927db..64fe0dc958 100644 --- a/codex-rs/config/src/requirements_layers/stack_tests.rs +++ b/codex-rs/config/src/requirements_layers/stack_tests.rs @@ -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![ diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index a16ad96b34..15ede5c942 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -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, /// Log user prompt in traces pub log_user_prompt: Option, /// Opt in to logging final main-agent and spawned-subagent responses to an OTLP log exporter. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index f11dfba29c..0e741b619f 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -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": { diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 0c4e222207..1f5f0250a9 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -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()?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index a6640ad139..74750d0a13 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -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: _, diff --git a/codex-rs/core/src/config/otel.rs b/codex-rs/core/src/config/otel.rs index 1d0636e993..2710ebf9d5 100644 --- a/codex-rs/core/src/config/otel.rs +++ b/codex-rs/core/src/config/otel.rs @@ -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), diff --git a/codex-rs/core/src/config/requirements.rs b/codex-rs/core/src/config/requirements.rs index c6a4e01499..e7ee937135 100644 --- a/codex-rs/core/src/config/requirements.rs +++ b/codex-rs/core/src/config/requirements.rs @@ -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( diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index daacb4b24b..e7eb4d814a 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -1001,6 +1001,7 @@ interrupt_message = false )])), plugins: None, marketplaces: None, + otel: None, apps: None, rules: None, enforce_residency: Some(ResidencyRequirement::Us),