mirror of
https://github.com/openai/codex.git
synced 2026-10-29 19:08:43 +00:00
Redact OTEL exporter headers in app-server config responses (#57654)
## Why OTEL exporter headers can contain credentials. Configuration responses exposed these values in effective config, source layers, managed requirements, and write-override metadata. ## What changed Replace header values with `[redacted]` across these responses for log, trace, and metrics exporters while preserving header names. Keep the original values available to runtime exporters, and redact them in debug output. Update protocol schemas and generated client documentation to describe the redacted values. ## Testing Add coverage for effective config and source-layer redaction, managed requirements, and single and batch write-override metadata, including header names containing dots. Extend telemetry integration coverage to verify that runtime requests still carry the configured header value. GitOrigin-RevId: ad24e8b91e615bd36c03c4c0ca205ac64bbd8cbf
This commit is contained in:
committed by
copyberry
parent
727283cea5
commit
bea47c06df
+2
-2
@@ -16553,7 +16553,7 @@
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Exporter request headers.",
|
||||
"description": "Header names are retained; values are redacted by the server.",
|
||||
"type": "object"
|
||||
},
|
||||
"protocol": {
|
||||
@@ -16596,7 +16596,7 @@
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Exporter request headers.",
|
||||
"description": "Header names are retained; values are redacted by the server.",
|
||||
"type": "object"
|
||||
},
|
||||
"tls": {
|
||||
|
||||
+2
-2
@@ -12545,7 +12545,7 @@
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Exporter request headers.",
|
||||
"description": "Header names are retained; values are redacted by the server.",
|
||||
"type": "object"
|
||||
},
|
||||
"protocol": {
|
||||
@@ -12588,7 +12588,7 @@
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Exporter request headers.",
|
||||
"description": "Header names are retained; values are redacted by the server.",
|
||||
"type": "object"
|
||||
},
|
||||
"tls": {
|
||||
|
||||
+2
-2
@@ -1169,7 +1169,7 @@
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Exporter request headers.",
|
||||
"description": "Header names are retained; values are redacted by the server.",
|
||||
"type": "object"
|
||||
},
|
||||
"protocol": {
|
||||
@@ -1212,7 +1212,7 @@
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Exporter request headers.",
|
||||
"description": "Header names are retained; values are redacted by the server.",
|
||||
"type": "object"
|
||||
},
|
||||
"tls": {
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -6,10 +6,10 @@ import type { OtelTlsConfig } from "./OtelTlsConfig";
|
||||
|
||||
export type OtelExporter = "none" | "statsig" | { "otlp-http": { endpoint: string,
|
||||
/**
|
||||
* Exporter request headers.
|
||||
* Header names are retained; values are redacted by the server.
|
||||
*/
|
||||
headers: { [key in string]?: string }, protocol: OtelHttpProtocol, tls: OtelTlsConfig | null, } } | { "otlp-grpc": { endpoint: string,
|
||||
/**
|
||||
* Exporter request headers.
|
||||
* Header names are retained; values are redacted by the server.
|
||||
*/
|
||||
headers: { [key in string]?: string }, tls: OtelTlsConfig | null, } };
|
||||
|
||||
@@ -19,6 +19,7 @@ use codex_protocol::config_types::WebSearchToolConfig;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
pub use codex_utils_redacted_string::SerdeRedactedString;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
@@ -529,15 +530,19 @@ pub enum OtelExporter {
|
||||
Statsig,
|
||||
OtlpHttp {
|
||||
endpoint: String,
|
||||
/// Exporter request headers.
|
||||
headers: HashMap<String, String>,
|
||||
/// Header names are retained; values are redacted by the server.
|
||||
#[schemars(with = "HashMap<String, String>")]
|
||||
#[ts(as = "HashMap<String, String>")]
|
||||
headers: HashMap<String, SerdeRedactedString>,
|
||||
protocol: OtelHttpProtocol,
|
||||
tls: Option<OtelTlsConfig>,
|
||||
},
|
||||
OtlpGrpc {
|
||||
endpoint: String,
|
||||
/// Exporter request headers.
|
||||
headers: HashMap<String, String>,
|
||||
/// Header names are retained; values are redacted by the server.
|
||||
#[schemars(with = "HashMap<String, String>")]
|
||||
#[ts(as = "HashMap<String, String>")]
|
||||
headers: HashMap<String, SerdeRedactedString>,
|
||||
tls: Option<OtelTlsConfig>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -708,3 +708,6 @@ the app-server host and retry setup.
|
||||
`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.
|
||||
|
||||
Exporter header values are redacted in effective config, source layers, managed
|
||||
requirements, and write-override metadata. Runtime exporters retain the original values.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::config_layer::config_layer_metadata_to_api;
|
||||
use crate::config_layer::config_layer_to_api;
|
||||
use crate::config_manager::ConfigManager;
|
||||
use crate::otel_config::redact_otel_exporter_headers;
|
||||
use codex_app_server_protocol::Config as ApiConfig;
|
||||
use codex_app_server_protocol::ConfigBatchWriteParams;
|
||||
use codex_app_server_protocol::ConfigEdit as ApiConfigEdit;
|
||||
@@ -168,7 +169,7 @@ impl ConfigManager {
|
||||
!matches!(&metadata.name, ConfigLayerSource::PackagedDefaults { .. })
|
||||
});
|
||||
|
||||
Ok(ConfigReadResponse {
|
||||
let mut response = ConfigReadResponse {
|
||||
config,
|
||||
origins: origins
|
||||
.into_iter()
|
||||
@@ -183,7 +184,20 @@ impl ConfigManager {
|
||||
.map(|layer| config_layer_to_api(layer.as_layer()))
|
||||
.collect()
|
||||
}),
|
||||
})
|
||||
};
|
||||
|
||||
// Redact exporter credentials from both the effective config and any
|
||||
// included config layers before returning the public API response.
|
||||
if let Some(otel) = response.config.additional.get_mut("otel") {
|
||||
redact_otel_exporter_headers(otel);
|
||||
}
|
||||
for layer in response.layers.iter_mut().flatten() {
|
||||
if let Some(otel) = layer.config.get_mut("otel") {
|
||||
redact_otel_exporter_headers(otel);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub(crate) async fn read_requirements(
|
||||
@@ -952,12 +966,28 @@ fn compute_override_metadata(
|
||||
}
|
||||
let message = override_message(&overriding_layer.name);
|
||||
|
||||
// Redact the complete OTEL config before extracting the overridden path,
|
||||
// which may point to an entire exporter or an individual credential.
|
||||
let effective_value = if segments.first().is_some_and(|segment| segment == "otel") {
|
||||
effective
|
||||
.get("otel")
|
||||
.and_then(|otel| serde_json::to_value(otel).ok())
|
||||
.and_then(|mut otel| {
|
||||
redact_otel_exporter_headers(&mut otel);
|
||||
segments[1..]
|
||||
.iter()
|
||||
.try_fold(&otel, |value, segment| value.get(segment.as_str()))
|
||||
.cloned()
|
||||
})
|
||||
} else {
|
||||
effective_value.and_then(|value| serde_json::to_value(value).ok())
|
||||
}
|
||||
.unwrap_or(JsonValue::Null);
|
||||
|
||||
Some(OverriddenMetadata {
|
||||
message,
|
||||
overriding_layer: config_layer_metadata_to_api(overriding_layer),
|
||||
effective_value: effective_value
|
||||
.and_then(|value| serde_json::to_value(value).ok())
|
||||
.unwrap_or(JsonValue::Null),
|
||||
effective_value,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use codex_app_server_protocol::ConfigLayerSource as ApiConfigLayerSource;
|
||||
use codex_config::CloudConfigBundleLoader;
|
||||
use codex_config::LoaderOverrides;
|
||||
use codex_config::test_support::CloudConfigBundleFixture;
|
||||
use codex_config::types::OtelExporterKind;
|
||||
use codex_http_client::HttpClientFactory;
|
||||
use codex_http_client::OutboundProxyPolicy;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
@@ -2101,6 +2102,178 @@ async fn write_value_reports_managed_override() {
|
||||
assert_eq!(overridden.effective_value, serde_json::json!("never"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_overrides_redact_managed_otel_exporter_headers() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let user_path = tmp.path().join(CONFIG_TOML_FILE);
|
||||
std::fs::write(&user_path, "").expect("write user config");
|
||||
let managed_path = tmp.path().join("managed_config.toml");
|
||||
std::fs::write(
|
||||
&managed_path,
|
||||
r#"[otel]
|
||||
environment = "managed"
|
||||
exporter = { otlp-http = { endpoint = "https://collector.example", protocol = "json", headers = { Authorization = "admin-secret", "x.api.key" = "dotted-secret" } } }
|
||||
trace_exporter = { otlp-grpc = { endpoint = "https://trace.example", headers = { Authorization = "trace-secret" } } }
|
||||
metrics_exporter = { otlp-http = { endpoint = "https://metrics.example", protocol = "json", headers = { Authorization = "metrics-secret" } } }
|
||||
span_attributes = { "service.name" = "managed-service" }"#,
|
||||
)
|
||||
.expect("write managed config");
|
||||
let service = ConfigManager::new_for_tests(
|
||||
tmp.path().to_path_buf(),
|
||||
vec![],
|
||||
LoaderOverrides::with_managed_config_path_for_tests(managed_path),
|
||||
CloudConfigBundleLoader::default(),
|
||||
);
|
||||
let exporter = serde_json::json!({
|
||||
"otlp-http": {
|
||||
"endpoint": "https://collector.example",
|
||||
"headers": { "Authorization": "[redacted]", "x.api.key": "[redacted]" },
|
||||
"protocol": "json"
|
||||
}
|
||||
});
|
||||
let otel = serde_json::json!({
|
||||
"environment": "managed",
|
||||
"exporter": exporter.clone(),
|
||||
"trace_exporter": {
|
||||
"otlp-grpc": {
|
||||
"endpoint": "https://trace.example",
|
||||
"headers": { "Authorization": "[redacted]" }
|
||||
}
|
||||
},
|
||||
"metrics_exporter": {
|
||||
"otlp-http": {
|
||||
"endpoint": "https://metrics.example",
|
||||
"protocol": "json",
|
||||
"headers": { "Authorization": "[redacted]" }
|
||||
}
|
||||
},
|
||||
"span_attributes": { "service.name": "managed-service" }
|
||||
});
|
||||
|
||||
for (key_path, expected) in [
|
||||
("otel", otel.clone()),
|
||||
("otel.exporter", exporter.clone()),
|
||||
("otel.exporter.otlp-http", exporter["otlp-http"].clone()),
|
||||
(
|
||||
"otel.exporter.otlp-http.headers",
|
||||
exporter["otlp-http"]["headers"].clone(),
|
||||
),
|
||||
(
|
||||
"otel.exporter.otlp-http.headers.Authorization",
|
||||
serde_json::json!("[redacted]"),
|
||||
),
|
||||
(
|
||||
"otel.exporter.otlp-http.headers.\"x.api.key\"",
|
||||
serde_json::json!("[redacted]"),
|
||||
),
|
||||
(
|
||||
"otel.exporter.otlp-http.endpoint",
|
||||
serde_json::json!("https://collector.example"),
|
||||
),
|
||||
(
|
||||
"otel.exporter.otlp-http.protocol",
|
||||
serde_json::json!("json"),
|
||||
),
|
||||
("otel.trace_exporter", otel["trace_exporter"].clone()),
|
||||
("otel.metrics_exporter", otel["metrics_exporter"].clone()),
|
||||
(
|
||||
"otel.metrics_exporter.otlp-http.headers.Authorization",
|
||||
serde_json::json!("[redacted]"),
|
||||
),
|
||||
("otel.environment", serde_json::json!("managed")),
|
||||
(
|
||||
"otel.span_attributes.\"service.name\"",
|
||||
serde_json::json!("managed-service"),
|
||||
),
|
||||
] {
|
||||
let response = service
|
||||
.write_value(ConfigValueWriteParams {
|
||||
file_path: None,
|
||||
key_path: key_path.to_string(),
|
||||
value: serde_json::Value::Null,
|
||||
merge_strategy: MergeStrategy::Replace,
|
||||
expected_version: None,
|
||||
})
|
||||
.await
|
||||
.expect("no-op config clear should succeed");
|
||||
assert_eq!(response.status, WriteStatus::OkOverridden, "{key_path}");
|
||||
assert_eq!(
|
||||
response
|
||||
.overridden_metadata
|
||||
.expect("managed override")
|
||||
.effective_value,
|
||||
expected,
|
||||
"{key_path}"
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(&user_path).unwrap(), "");
|
||||
}
|
||||
|
||||
let response = service
|
||||
.batch_write(ConfigBatchWriteParams {
|
||||
edits: vec![codex_app_server_protocol::ConfigEdit {
|
||||
key_path: "otel.exporter.otlp-http.headers.Authorization".to_string(),
|
||||
value: serde_json::Value::Null,
|
||||
merge_strategy: MergeStrategy::Replace,
|
||||
}],
|
||||
file_path: None,
|
||||
expected_version: None,
|
||||
reload_user_config: false,
|
||||
})
|
||||
.await
|
||||
.expect("no-op batch clear should succeed");
|
||||
assert_eq!(response.status, WriteStatus::OkOverridden);
|
||||
assert_eq!(
|
||||
response
|
||||
.overridden_metadata
|
||||
.expect("managed batch override")
|
||||
.effective_value,
|
||||
serde_json::json!("[redacted]")
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(&user_path).unwrap(), "");
|
||||
|
||||
let read = service
|
||||
.read(ConfigReadParams {
|
||||
include_layers: true,
|
||||
cwd: None,
|
||||
})
|
||||
.await
|
||||
.expect("read public config");
|
||||
let mut effective_exporter = exporter.clone();
|
||||
effective_exporter["otlp-http"]["tls"] = serde_json::Value::Null;
|
||||
assert_eq!(
|
||||
read.config.additional["otel"]["exporter"],
|
||||
effective_exporter
|
||||
);
|
||||
let managed_layer = read
|
||||
.layers
|
||||
.expect("config layers")
|
||||
.into_iter()
|
||||
.find(|layer| {
|
||||
matches!(
|
||||
layer.name,
|
||||
ApiConfigLayerSource::LegacyManagedConfigTomlFromFile { .. }
|
||||
)
|
||||
})
|
||||
.expect("legacy managed layer");
|
||||
assert_eq!(managed_layer.config["otel"]["exporter"], exporter);
|
||||
|
||||
let runtime = service
|
||||
.load_latest_config(/*fallback_cwd*/ None)
|
||||
.await
|
||||
.expect("load runtime config");
|
||||
let OtelExporterKind::OtlpHttp { headers, .. } = runtime.otel.exporter else {
|
||||
panic!("unexpected runtime exporter");
|
||||
};
|
||||
assert_eq!(
|
||||
headers.get("Authorization").map(|value| value.as_str()),
|
||||
Some("admin-secret")
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("x.api.key").map(|value| value.as_str()),
|
||||
Some("dotted-secret")
|
||||
);
|
||||
}
|
||||
|
||||
/// Legacy managed feature toggles own their normalized enabled origin and override metadata.
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_boolean_layer_owns_enabled_origin_and_overrides() {
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
//! Maps OTEL requirements into app-server responses.
|
||||
//! Maps OTEL requirements and masks exporter credentials in 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_app_server_protocol::SerdeRedactedString;
|
||||
use codex_config::OtelRequirementsToml;
|
||||
use codex_config::types::OtelExporterKind;
|
||||
use codex_config::types::OtelHttpProtocol as CoreOtelHttpProtocol;
|
||||
use codex_config::types::OtelTlsConfig as CoreOtelTlsConfig;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub(crate) fn map_otel_requirements_to_api(otel: OtelRequirementsToml) -> OtelRequirements {
|
||||
OtelRequirements {
|
||||
@@ -26,6 +29,12 @@ pub(crate) fn map_otel_requirements_to_api(otel: OtelRequirementsToml) -> OtelRe
|
||||
}
|
||||
|
||||
fn map_otel_exporter_to_api(exporter: OtelExporterKind) -> OtelExporter {
|
||||
let response_headers = |headers: HashMap<String, _>| {
|
||||
headers
|
||||
.into_iter()
|
||||
.map(|(name, value)| (name, SerdeRedactedString::from(value)))
|
||||
.collect()
|
||||
};
|
||||
match exporter {
|
||||
OtelExporterKind::None => OtelExporter::None,
|
||||
OtelExporterKind::Statsig => OtelExporter::Statsig,
|
||||
@@ -36,7 +45,7 @@ fn map_otel_exporter_to_api(exporter: OtelExporterKind) -> OtelExporter {
|
||||
tls,
|
||||
} => OtelExporter::OtlpHttp {
|
||||
endpoint,
|
||||
headers,
|
||||
headers: response_headers(headers),
|
||||
protocol: match protocol {
|
||||
CoreOtelHttpProtocol::Binary => OtelHttpProtocol::Binary,
|
||||
CoreOtelHttpProtocol::Json => OtelHttpProtocol::Json,
|
||||
@@ -49,7 +58,7 @@ fn map_otel_exporter_to_api(exporter: OtelExporterKind) -> OtelExporter {
|
||||
tls,
|
||||
} => OtelExporter::OtlpGrpc {
|
||||
endpoint,
|
||||
headers,
|
||||
headers: response_headers(headers),
|
||||
tls: tls.map(map_otel_tls_to_api),
|
||||
},
|
||||
}
|
||||
@@ -62,3 +71,22 @@ fn map_otel_tls_to_api(tls: CoreOtelTlsConfig) -> OtelTlsConfig {
|
||||
client_private_key: tls.client_private_key.map(Into::into),
|
||||
}
|
||||
}
|
||||
|
||||
/// Masks exporter header values before OTEL configuration crosses the API boundary.
|
||||
pub(crate) fn redact_otel_exporter_headers(otel: &mut JsonValue) {
|
||||
for field in ["exporter", "trace_exporter", "metrics_exporter"] {
|
||||
let Some(exporter) = otel.get_mut(field).and_then(JsonValue::as_object_mut) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for config in exporter.values_mut() {
|
||||
let Some(headers) = config.get_mut("headers").and_then(JsonValue::as_object_mut) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for value in headers.values_mut() {
|
||||
*value = SerdeRedactedString::PLACEHOLDER.into();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -602,7 +602,7 @@ async fn config_read_disables_guardian_v2_when_managed_config_requires_guardian_
|
||||
#[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(
|
||||
async fn config_requirements_read_redacts_otel_exporter_headers(
|
||||
protocol: &str,
|
||||
metrics_exporter: &str,
|
||||
) -> Result<()> {
|
||||
@@ -641,7 +641,7 @@ metrics_exporter = "$METRICS_EXPORTER""#
|
||||
"exporter": {
|
||||
"otlp-http": {
|
||||
"endpoint": "http://127.0.0.1:1/v1/logs",
|
||||
"headers": { "Authorization": "Bearer managed-token" },
|
||||
"headers": { "Authorization": "[redacted]" },
|
||||
"protocol": protocol,
|
||||
"tls": {
|
||||
"ca-certificate": codex_home.path().join("ca.pem"),
|
||||
@@ -653,7 +653,7 @@ metrics_exporter = "$METRICS_EXPORTER""#
|
||||
"trace_exporter": {
|
||||
"otlp-grpc": {
|
||||
"endpoint": "http://127.0.0.1:4317",
|
||||
"headers": { "x-api-key": "managed-grpc-secret" },
|
||||
"headers": { "x-api-key": "[redacted]" },
|
||||
"tls": {
|
||||
"ca-certificate": codex_home.path().join("ca.pem"),
|
||||
"client-certificate": null,
|
||||
@@ -682,6 +682,73 @@ metrics_exporter = "$METRICS_EXPORTER""#
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn config_read_redacts_otel_exporter_headers_from_effective_config_and_layers() -> Result<()>
|
||||
{
|
||||
let codex_home = TempDir::new()?;
|
||||
write_config(
|
||||
&codex_home,
|
||||
r#"[otel]
|
||||
exporter = { otlp-http = { endpoint = "http://127.0.0.1:1/v1/logs", protocol = "json", headers = { Authorization = "logs-secret" } } }
|
||||
trace_exporter = { otlp-grpc = { endpoint = "http://127.0.0.1:1", headers = { Authorization = "traces-secret" } } }
|
||||
metrics_exporter = { otlp-http = { endpoint = "http://127.0.0.1:1/v1/metrics", protocol = "binary", headers = { Authorization = "metrics-secret" } } }"#,
|
||||
)?;
|
||||
let mut mcp = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.without_auto_env()
|
||||
.with_args(&[
|
||||
"-c",
|
||||
"otel.exporter.otlp-http.headers.Authorization='session-secret'",
|
||||
])
|
||||
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
|
||||
.await?;
|
||||
let request_id = mcp
|
||||
.send_config_read_request(ConfigReadParams {
|
||||
include_layers: true,
|
||||
cwd: None,
|
||||
})
|
||||
.await?;
|
||||
let response: ConfigReadResponse =
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
|
||||
let serialized = serde_json::to_string(&response)?;
|
||||
let effective_otel = response
|
||||
.config
|
||||
.additional
|
||||
.get("otel")
|
||||
.expect("effective OTEL config");
|
||||
let layers = response.layers.as_ref().expect("included config layers");
|
||||
let user_otel = layers
|
||||
.iter()
|
||||
.find(|layer| matches!(&layer.name, ConfigLayerSource::User { .. }))
|
||||
.and_then(|layer| layer.config.get("otel"))
|
||||
.expect("user OTEL config layer");
|
||||
|
||||
let flags = layers
|
||||
.iter()
|
||||
.find(|layer| matches!(layer.name, ConfigLayerSource::SessionFlags))
|
||||
.expect("session flags");
|
||||
assert_eq!(
|
||||
flags.config["otel"],
|
||||
json!({
|
||||
"exporter": { "otlp-http": { "headers": { "Authorization": "[redacted]" } } }
|
||||
})
|
||||
);
|
||||
assert!(!serialized.contains("session-secret"));
|
||||
|
||||
for (field, transport, secret) in [
|
||||
("exporter", "otlp-http", "logs-secret"),
|
||||
("trace_exporter", "otlp-grpc", "traces-secret"),
|
||||
("metrics_exporter", "otlp-http", "metrics-secret"),
|
||||
] {
|
||||
let expected = json!({ "Authorization": "[redacted]" });
|
||||
assert_eq!(effective_otel[field][transport]["headers"], expected);
|
||||
assert_eq!(user_otel[field][transport]["headers"], expected);
|
||||
assert!(!serialized.contains(secret), "response leaked {field}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn config_read_returns_effective_and_layers() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -130,6 +130,18 @@ async fn account_switch_reloads_telemetry_collectors_and_preserves_trace_context
|
||||
.received_requests()
|
||||
.await
|
||||
.context("collector did not record requests")?;
|
||||
for request in requests
|
||||
.iter()
|
||||
.filter(|request| request.url.path().ends_with("/logs"))
|
||||
{
|
||||
assert_eq!(
|
||||
request
|
||||
.headers
|
||||
.get("x-collector-key")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("test-collector-key")
|
||||
);
|
||||
}
|
||||
let exported = |path: &str| {
|
||||
requests
|
||||
.iter()
|
||||
@@ -189,7 +201,7 @@ enabled = false
|
||||
|
||||
[otel]
|
||||
environment = "test"
|
||||
exporter = {{ otlp-http = {{ endpoint = "{collector_endpoint}/logs", protocol = "json" }} }}
|
||||
exporter = {{ otlp-http = {{ endpoint = "{collector_endpoint}/logs", protocol = "json", headers = {{ x-collector-key = "test-collector-key" }} }} }}
|
||||
trace_exporter = {{ otlp-http = {{ endpoint = "{collector_endpoint}/traces", protocol = "json" }} }}
|
||||
metrics_exporter = {{ otlp-http = {{ endpoint = "{collector_endpoint}/metrics", protocol = "json" }} }}
|
||||
"#
|
||||
|
||||
@@ -30,6 +30,7 @@ use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
|
||||
use codex_utils_redacted_string::RedactedString;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
@@ -616,7 +617,8 @@ pub enum OtelExporterKind {
|
||||
OtlpHttp {
|
||||
endpoint: String,
|
||||
#[serde(default)]
|
||||
headers: HashMap<String, String>,
|
||||
#[schemars(with = "HashMap<String, String>")]
|
||||
headers: HashMap<String, RedactedString>,
|
||||
protocol: OtelHttpProtocol,
|
||||
#[serde(default)]
|
||||
tls: Option<OtelTlsConfig>,
|
||||
@@ -624,7 +626,8 @@ pub enum OtelExporterKind {
|
||||
OtlpGrpc {
|
||||
endpoint: String,
|
||||
#[serde(default)]
|
||||
headers: HashMap<String, String>,
|
||||
#[schemars(with = "HashMap<String, String>")]
|
||||
headers: HashMap<String, RedactedString>,
|
||||
#[serde(default)]
|
||||
tls: Option<OtelTlsConfig>,
|
||||
},
|
||||
|
||||
@@ -37,7 +37,7 @@ pub fn build_provider(
|
||||
endpoint: endpoint.clone(),
|
||||
headers: headers
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.map(|(name, value)| (name.clone(), value.as_str().to_owned()))
|
||||
.collect(),
|
||||
protocol,
|
||||
tls: tls.as_ref().map(|config| OtelTlsSettings {
|
||||
@@ -55,7 +55,7 @@ pub fn build_provider(
|
||||
endpoint: endpoint.clone(),
|
||||
headers: headers
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.map(|(name, value)| (name.clone(), value.as_str().to_owned()))
|
||||
.collect(),
|
||||
tls: tls.as_ref().map(|config| OtelTlsSettings {
|
||||
ca_certificate: config.ca_certificate.clone(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde::Serializer;
|
||||
use std::fmt;
|
||||
use std::ops::Deref;
|
||||
use std::ops::DerefMut;
|
||||
@@ -47,3 +48,24 @@ impl fmt::Debug for RedactedString {
|
||||
f.write_str("<redacted>")
|
||||
}
|
||||
}
|
||||
|
||||
/// An API response string whose serialized form always hides the value.
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
|
||||
#[serde(transparent)]
|
||||
pub struct SerdeRedactedString(RedactedString);
|
||||
|
||||
impl SerdeRedactedString {
|
||||
pub const PLACEHOLDER: &'static str = "[redacted]";
|
||||
}
|
||||
|
||||
impl From<RedactedString> for SerdeRedactedString {
|
||||
fn from(value: RedactedString) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for SerdeRedactedString {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(Self::PLACEHOLDER)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9157,7 +9157,10 @@ class OtlpHttp(BaseModel):
|
||||
populate_by_name=True,
|
||||
)
|
||||
endpoint: str
|
||||
headers: Annotated[dict[str, str], Field(description="Exporter request headers.")]
|
||||
headers: Annotated[
|
||||
dict[str, str],
|
||||
Field(description="Header names are retained; values are redacted by the server."),
|
||||
]
|
||||
protocol: OtelHttpProtocol
|
||||
tls: OtelTlsConfig | None = None
|
||||
|
||||
@@ -9175,7 +9178,10 @@ class OtlpGrpc(BaseModel):
|
||||
populate_by_name=True,
|
||||
)
|
||||
endpoint: str
|
||||
headers: Annotated[dict[str, str], Field(description="Exporter request headers.")]
|
||||
headers: Annotated[
|
||||
dict[str, str],
|
||||
Field(description="Header names are retained; values are redacted by the server."),
|
||||
]
|
||||
tls: OtelTlsConfig | None = None
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user