Not able to use env var for otlp headers in config

Open 💬 2 comments Opened Mar 12, 2026 by flexdinesh

What version of Codex CLI is running?

0.114.0

What subscription do you have?

Pro

Which model were you using?

GPT-5.4

What platform is your computer?

Arch Linux

What terminal emulator and version are you using (if applicable)?

Ghostty

What issue are you seeing?

I am able to set up and use OTEL config. But I want to pass shell environment variables to the header value. This example in the doc does not work now.

[otel]
exporter = { otlp-http = {
endpoint = "https://otel.example.com/v1/logs",
protocol = "binary",
headers = { "x-otlp-api-key" = "${OTLP_TOKEN}" }
}}

It works if I hardcode the token value.

What steps can reproduce the bug?

Try to use environment variable in OTEL config headers

What is the expected behavior?

OTEL config headers should be able to interpolate the value from shell environment variables

Additional information

_No response_

View original on GitHub ↗

2 Comments

edenfunf · 5 months ago

Root Cause Analysis

I traced through the config and OTEL initialisation pipeline and found the exact location of the problem.

Where header values enter the pipeline:

codex-rs/core/src/config/types.rs defines OtelExporterKind with a plain headers: HashMap<String, String> field. Header values are stored verbatim from the TOML file — no interpolation step exists.

Where headers are consumed:

codex-rs/core/src/otel_init.rs converts the config.otel settings into an OtelExporter value via the to_otel_exporter closure. Both the OtlpHttp and OtlpGrpc branches simply clone header values straight through:

headers: headers
    .iter()
    .map(|(k, v)| (k.clone(), v.clone()))   // ← literal copy, no expansion
    .collect(),

What happens to ${OTLP_TOKEN}:

The string "${OTLP_TOKEN}" is passed unchanged all the way into the HTTP client header map and eventually sent as a literal header value, which is not a valid auth token.

Proposed fix:

Add a small expand_env_vars(value: &str) -> String helper in otel_init.rs that replaces ${VAR_NAME} with the corresponding process environment variable. If the variable is not set the placeholder is left unchanged (rather than silently becoming an empty string) so misconfiguration is visible.

fn expand_env_vars(value: &str) -> String {
    let mut result = String::with_capacity(value.len());
    let mut rest = value;
    while let Some(start) = rest.find("${") {
        result.push_str(&rest[..start]);
        rest = &rest[start + 2..];
        if let Some(end) = rest.find('}') {
            let var_name = &rest[..end];
            match std::env::var(var_name) {
                Ok(val) => result.push_str(&val),
                Err(_) => {
                    result.push_str("${");
                    result.push_str(var_name);
                    result.push('}');
                }
            }
            rest = &rest[end + 1..];
        } else {
            result.push_str("${");
        }
    }
    result.push_str(rest);
    result
}

Then change both map calls in to_otel_exporter from .map(|(k, v)| (k.clone(), v.clone())) to .map(|(k, v)| (k.clone(), expand_env_vars(v))).

This fix is entirely contained to otel_init.rs. No config schema changes are needed — users continue using headers = { ... } exactly as in the issue example. The change also covers OtlpGrpc headers and the trace_exporter/metrics_exporter variants since they all go through the same closure.

I've verified this compiles and added unit tests covering: substitution of a set variable, no-op for values with no placeholder, multiple placeholders in one value, an unset variable left unchanged, and an unclosed ${ left unchanged.

Happy to open a PR if that would be helpful.

CharlieDigital · 2 months ago

Also observing the same; several rounds of trying to figure this out and it would appear that the docs are wrong and this does not work as expected.