otelHeadersHelper headers silently dropped for gRPC OTLP exporters (metrics/logs/traces) — falls back to no auth
Description
When otelHeadersHelper is configured and OTEL_EXPORTER_OTLP_PROTOCOL=grpc is set, Claude Code's OTLP metrics/logs/traces exporters silently fail to attach the Authorization header (or any custom header) to outgoing requests. No error is surfaced to the user in normal operation — the collector simply rejects every export with an authentication error, and there is no indication that the headers were never sent in the first place.
Environment
- Claude Code version:
2.1.223 OTEL-OTLP-Exporter-JavaScriptversion bundled:0.208.0(from exporterUser-Agentheader)grpc-node-jsversion bundled:1.14.4- Relevant settings.json:
``json``
"otelHeadersHelper": "/path/to/auth-script.sh",
"env": {
"OTEL_METRICS_EXPORTER": "otlp",
"OTEL_LOGS_EXPORTER": "otlp",
"OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
"OTEL_EXPORTER_OTLP_ENDPOINT": "https://<custom-otlp-collector>"
}
Root cause
Traced through the bundled CLI binary (/opt/homebrew/Caskroom/claude-code/2.1.223/claude, a bun-compiled single-file executable):
- Claude Code's own config-building code (used for every OTLP signal identically) constructs exporter options by setting
.headersto an async function that resolves theotelHeadersHelperscript's output:
``js`
r.headers = async () => { let i = await jQs(); return {...o, ...i} }
jQs()
( is a shared, debounced cache around invoking the configured otelHeadersHelper`.)
- This
.headersoption is passed intoOTLPMetricExporter/OTLPLogExporter/OTLPTraceExporterconstructors, all of which funnel through a sharedconvertLegacyOtlpGrpcOptions(e, signal)function for the gRPC transport. That function does this:
``js`
function convertLegacyOtlpGrpcOptions(e, t) {
if (e.headers) diag.warn("Headers cannot be set when using grpc");
let r = e.credentials;
return mergeOtlpGrpcConfigurationWithDefaults({
url: e.url,
metadata: () => e.metadata ?? createEmptyMetadata(),
credentials: r != null ? () => r : void 0,
...
});
}
`
Confirmed identical call sites for all three signals:
``
convertLegacyOtlpGrpcOptions(e, "LOGS")
convertLegacyOtlpGrpcOptions(e, "TRACES")
convertLegacyOtlpGrpcOptions(e ?? {}, "METRICS")
- The current bundled OTel-JS SDK version has moved gRPC exporters to a
metadata/credentials-based API, and no longer honors the legacyheadersoption for gRPC transport at all — it only logs adiag.warnand silently discards it, falling back tocreateEmptyMetadata(). Claude Code's own header-injection code (step 1) never sets.metadataor.credentials— only.headers— so no Authorization header (or any custom header) is ever attached to gRPC OTLP exports, regardless of how correctlyotelHeadersHelperis configured.
- By contrast, the HTTP/protobuf transport path (
convertLegacyHttpOptions) does correctly convert and use.headers:
``js``
function convertLegacyHttpOptions(e, ...) {
if (e.metadata) diag.warn("Metadata cannot be set when using http");
return mergeOtlpNodeHttpConfigurationWithDefaults({
headers: convertLegacyHeaders(e), // <- honored correctly
...
});
}
Impact
- Any custom OTLP collector that requires auth (via
otelHeadersHelper, e.g. an OIDC/bearer-token gateway) will reject every metrics/logs/traces export sent over gRPC, with an opaque auth error on the collector side (e.g. our collector'soidcauthextensionlogged"malformed jws: compact JWS format must have three parts"— consistent with an empty/absent Authorization value). - Claude Code itself surfaces essentially nothing about this by default. Even with
CLAUDE_CODE_OTEL_DIAG_STDERR=1set and--debugenabled, the resulting client-side error is a generic transport-level failure (16 UNAUTHENTICATED: authentication didn't succeed) with no indication that the root cause is a silently-dropped header, or that adiag.warnabout it exists at all (it's not surfaced to the user through the debug/diag path we found). - This makes
otelHeadersHelper+OTEL_EXPORTER_OTLP_PROTOCOL=grpccombination effectively non-functional for any auth-gated collector, with no actionable error message pointing at the real cause.
Steps to reproduce
- Configure
otelHeadersHelperinsettings.jsonpointing to a script that outputs{"Authorization": "Bearer <token>"}. - Set
OTEL_EXPORTER_OTLP_PROTOCOL=grpcand pointOTEL_EXPORTER_OTLP_ENDPOINTat a collector whose OTLP gRPC receiver enforces auth (e.g. anoidc/bearer-token-checking auth extension in an OpenTelemetry Collector). - Run
claude -p "hello" --debugwithCLAUDE_CODE_OTEL_DIAG_STDERR=1set. - Observe the collector reject the export due to a missing/invalid Authorization header, even though the
otelHeadersHelperscript independently produces a valid token when run standalone. - Confirm (independently, via a manual gRPC call using the exact same token/header) that the collector accepts a properly-attached
Authorizationheader fine — isolating the failure to Claude Code's client-side header injection, not the collector or the credential.
Workaround
Switch to HTTP transport, which correctly honors .headers:
"OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf"
(plus pointing OTEL_EXPORTER_OTLP_ENDPOINT/OTEL_EXPORTER_OTLP_METRICS_ENDPOINT at an HTTP-compatible route on the collector). This is not a real fix for users who need/prefer gRPC transport.
Suggested fix
Update the shared config-building code that wires otelHeadersHelper output into OTLP exporter options so that, when the resolved transport is gRPC, the resolved headers are set via .metadata (as a grpc.Metadata instance or equivalent function) and/or .credentials (via credentials.createFromMetadataGenerator), rather than .headers, which the current SDK version silently ignores for gRPC. Alternatively/additionally, surface the underlying diag.warn("Headers cannot be set when using grpc") message through the existing CLAUDE_CODE_OTEL_DIAG_STDERR diagnostic path so this is at least discoverable without binary reverse-engineering.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗