otelHeadersHelper headers silently dropped for gRPC OTLP exporters (metrics/logs/traces) — falls back to no auth

Status Open
Reported on v2.1.223
Maintainer reply None cached
Activity 1 comment · opened Aug 15, 2026

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-JavaScript version bundled: 0.208.0 (from exporter User-Agent header)
  • grpc-node-js version 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):

  1. Claude Code's own config-building code (used for every OTLP signal identically) constructs exporter options by setting .headers to an async function that resolves the otelHeadersHelper script's output:

``js
r.headers = async () => { let i = await jQs(); return {...o, ...i} }
`
(
jQs() is a shared, debounced cache around invoking the configured otelHeadersHelper`.)

  1. This .headers option is passed into OTLPMetricExporter / OTLPLogExporter / OTLPTraceExporter constructors, all of which funnel through a shared convertLegacyOtlpGrpcOptions(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")
``

  1. The current bundled OTel-JS SDK version has moved gRPC exporters to a metadata/credentials-based API, and no longer honors the legacy headers option for gRPC transport at all — it only logs a diag.warn and silently discards it, falling back to createEmptyMetadata(). Claude Code's own header-injection code (step 1) never sets .metadata or .credentials — only .headers — so no Authorization header (or any custom header) is ever attached to gRPC OTLP exports, regardless of how correctly otelHeadersHelper is configured.
  1. 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's oidcauthextension logged "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=1 set and --debug enabled, 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 a diag.warn about 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=grpc combination effectively non-functional for any auth-gated collector, with no actionable error message pointing at the real cause.

Steps to reproduce

  1. Configure otelHeadersHelper in settings.json pointing to a script that outputs {"Authorization": "Bearer <token>"}.
  2. Set OTEL_EXPORTER_OTLP_PROTOCOL=grpc and point OTEL_EXPORTER_OTLP_ENDPOINT at a collector whose OTLP gRPC receiver enforces auth (e.g. an oidc/bearer-token-checking auth extension in an OpenTelemetry Collector).
  3. Run claude -p "hello" --debug with CLAUDE_CODE_OTEL_DIAG_STDERR=1 set.
  4. Observe the collector reject the export due to a missing/invalid Authorization header, even though the otelHeadersHelper script independently produces a valid token when run standalone.
  5. Confirm (independently, via a manual gRPC call using the exact same token/header) that the collector accepts a properly-attached Authorization header 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.

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗