Skip to main content

Short-lived credentials (headers_helper)

Most of the plugin's headers are static: headers: on the entry, the *_env credential fields, the top-level headers: block. They are read once, when the exporters are built, so a token that expires cannot live there. A Google Cloud access token is good for an hour, an OIDC token from a vault for minutes, and a password someone rotates is wrong the moment they do; until now the answers were a restart or an OpenTelemetry Collector in front of the plugin doing the auth.

headers_helper: on a backend entry names a command instead (#483). The plugin runs it when the entry's exporters are built, parses its stdout as headers, and sends them on every signal of that backend. When the backend answers 401 or 403, the plugin runs the helper once more, puts the new headers on the exporter, and retries the same batch once. A second refusal is logged and the batch is dropped, like any other failed export; the next batch tries again with the headers the helper printed last.

backends:
- type: otlp
name: cloud-trace
endpoint: https://telemetry.googleapis.com/v1/traces
headers_helper: /home/me/.hermes/mint-gcp-headers.sh
headers_helper_timeout_ms: 5000 # default

The contract​

CommandAn argv string, split with POSIX shlex rules ("a b" is one argument), run with no shell. Pipes, $(...) and redirections belong inside the script you name. ${VAR} in the string is expanded when the config loads, like every other entry field.
EnvironmentThe plugin's own: whatever Hermes's process has, so gcloud, aws, vault and friends find their config. The helper runs as the user running Hermes.
Timeoutheaders_helper_timeout_ms, default 5000. A run that takes longer counts as failed.
stdoutEither one JSON object ({"Authorization": "Bearer …", "x-goog-user-project": "p"}) or one Name=value per line. Blank lines and # comments are skipped. Values may contain =.
Exit codeNon-zero is a failure: the message carries the code and the first lines of stderr (redacted).
PrecedenceMinted headers sit on top of the entry's static headers: and of what the type's resolver built (an OpenObserve entry's Authorization: Basic …, Honeycomb's x-honeycomb-team). The helper is the live credential, so on the same name it wins. Print the full set every run: a header it stops printing is removed from the requests.
Static credentialOptional when a helper is set. A type that otherwise refuses an entry without its credential (OpenObserve user/password, Langfuse public_key/secret_key, Uptrace dsn, Honeycomb, LangSmith, Laminar, LangWatch, Latitude, Weave, Agenta, Parseable and ClickStack api_key) accepts a helper in its place and leaves that header for the helper to mint (#583). Fields that are not the credential (Latitude's project) are still required. With neither, the entry is still skipped at startup.
TransportOTLP/HTTP only. An entry with protocol: grpc and a headers_helper is refused at startup (headers_helper needs protocol: http/protobuf; gRPC cannot refresh it): the refresh works through the HTTP exporter's requests, and a gRPC channel has no such hook.
SignalsOne helper process serves the entry's traces, metrics and logs exporters; each gets the same minted set. A refresh is shared: when one exporter's 401/403 re-runs the helper, the others send their next batch with the new set, and exporters refused at the same moment wait for that one run instead of starting their own (#584). A rotation costs one helper run and one refused batch per entry, not one per signal.

A helper that fails at startup skips that backend entry with the error in the startup log (✗ cloud-trace traces init failed: headers_helper … exited 1), the way a missing credential does; the other entries proceed. A helper that fails during a refresh leaves the old headers in place, the refused batch is dropped, and the next batch triggers the next attempt. Nothing in this path ever blocks the agent: exports run on the batch processors' threads.

The Settings tab's backend card shows the command and the timeout as the entry's source for its headers, never what the helper printed. debug.log (HERMES_OTEL_DEBUG=true) records each run with the header names and redacted values: headers_helper cloud-trace: run 1 (412 ms) -> Authorization=••••••, x-goog-user-project=my-project, and 401 from the backend; re-ran the helper and retrying the batch once with … (the new header names and masked values) when a refresh happens (another exporter already re-ran the helper when a concurrent refusal took the set that run minted). See Privacy for what the redactor masks.

Example: Google Cloud access token​

Cloud Trace, Cloud Monitoring and Cloud Logging take OTLP/HTTP at telemetry.googleapis.com with an OAuth access token and the quota project in x-goog-user-project. gcloud auth print-access-token prints a token good for about an hour. The helper, saved outside the plugin directory so an upgrade keeps it:

#!/bin/sh
# ~/.hermes/mint-gcp-headers.sh — chmod +x
set -eu
printf 'Authorization=Bearer %s\n' "$(gcloud auth print-access-token)"
printf 'x-goog-user-project=%s\n' "${GOOGLE_CLOUD_PROJECT:?set GOOGLE_CLOUD_PROJECT}"
backends:
- type: otlp
name: google-cloud
endpoint: https://telemetry.googleapis.com/v1/traces # /v1/metrics and /v1/logs are derived
headers_helper: /home/me/.hermes/mint-gcp-headers.sh
metrics: true
logs: true
capture_logs: true

When the token expires, the next export gets a 401, the helper mints a new one and that batch lands on the retry. This example is documented, not verified against a Google Cloud project (the same standing as the Collector's googlecloud target).

Any issuer with a command-line works the same way: vault read -field=token …, aws-signed presigned headers from a small script, op read for a rotated key, or a script that reads a file another process keeps fresh.

Example: a rotated password (OpenObserve)​

The explicit types build their auth header from the entry's fields; a helper replaces it with a fresh value, or mints it alone. For OpenObserve, which takes HTTP Basic auth, a helper that reads the password from a file:

#!/bin/sh
# ~/.hermes/mint-oo-headers.sh
set -eu
auth=$(printf '%s:%s' "$OPENOBSERVE_USER" "$(cat "$HOME/.hermes/oo-password")" | base64 | tr -d '\n')
printf 'Authorization=Basic %s\n' "$auth"
backends:
- type: openobserve
endpoint: http://localhost:5080/api/default/v1/traces
headers_helper: /home/me/.hermes/mint-oo-headers.sh
capture_logs: true

The entry needs no user / password for exporting: the helper's Authorization is the only one sent. The dashboard's OpenObserve tab queries with the entry's own user / password (or *_env), so set them as well if you read the backend from the dashboard; the helper's header still wins on export.

Change the user's password in the OpenObserve UI, write the new one to ~/.hermes/oo-password, and the next batch goes: a 401, one helper run, one retry that lands. debug.log shows the sequence; in OpenObserve the stream keeps filling with no gap beyond that one batch's retry.

When to use the Collector instead​

The helper mints headers; it cannot sign a request. Backends that want a signature over the body (AWS SigV4 for X-Ray and CloudWatch) still need the Collector and its exporter. The Collector is also the place for processing the plugin does not do (temporality conversion for Azure Monitor, sampling, extra redaction). For a plain bearer token or API key that expires, the helper is the smaller setup: no second process, the plugin's debug.log still records the real backend's answer per batch, and the dashboard keeps querying the backend directly.

Reference​

FieldTypeDefaultDescription
headers_helperstring(unset)The command, as an argv string (no shell). Its stdout, a JSON object or Name=value lines, is merged over the entry's static headers on every signal; re-run once and the batch retried once on a 401/403
headers_helper_timeout_msint5000How long one run may take

Both are per backend entry; there is no top-level form. The fields are listed with the other shared entry fields in the config schema.