mirror of
https://github.com/openai/codex.git
synced 2026-10-29 19:08:43 +00:00
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:
Generated
+1
@@ -2856,6 +2856,7 @@ dependencies = [
|
||||
"serde_path_to_error",
|
||||
"sha2 0.10.9",
|
||||
"tempfile",
|
||||
"test-case",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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: _,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -1001,6 +1001,7 @@ interrupt_message = false
|
||||
)])),
|
||||
plugins: None,
|
||||
marketplaces: None,
|
||||
otel: None,
|
||||
apps: None,
|
||||
rules: None,
|
||||
enforce_residency: Some(ResidencyRequirement::Us),
|
||||
|
||||
Reference in New Issue
Block a user