Multi-backend fan-out
hermes-otel can send the same span tree to several backends in parallel. Every backend entry gets its own BatchSpanProcessor (independent worker thread, independent queue), so a slow or unreachable collector can't block the agent or starve the others.
Why fan out?
- Side-by-side evaluation — compare Phoenix and Langfuse on the same real traffic.
- Operational vs. LLM-specific UIs — ops team watches Jaeger / Grafana Tempo; product team watches Langfuse.
- Local + cloud — mirror everything to a local Phoenix for development and a Langfuse Cloud for retention.
- Tenant / project isolation — same traces, different ingest paths with different auth headers.
Configuration
Multi-backend is YAML-only. When backends: is set and non-empty, single-backend env-var detection is skipped entirely — the YAML takes full control.
# ~/.hermes/hermes_otel.yaml
backends:
- type: phoenix
endpoint: http://localhost:6006/v1/traces
- type: langfuse
public_key_env: LANGFUSE_PUBLIC_KEY
secret_key_env: LANGFUSE_SECRET_KEY
base_url: https://cloud.langfuse.com
- type: signoz
endpoint: http://localhost:4328/v1/traces
ingestion_key_env: OTEL_SIGNOZ_INGESTION_KEY
- type: jaeger
endpoint: http://localhost:4318/v1/traces
- type: tempo
endpoint: http://localhost:4358/v1/traces
- type: otlp
name: honeycomb
endpoint: https://api.honeycomb.io/v1/traces
headers:
x-honeycomb-team: ${HONEYCOMB_API_KEY}
The startup log prints one line per backend: its name (else the type's display name), the traces URL, and which signals it gets when metrics are off:
[hermes-otel] ✓ Phoenix at http://localhost:6006/v1/traces (traces only)
[hermes-otel] ✓ Langfuse at https://cloud.langfuse.com/api/public/otel/v1/traces (traces only)
[hermes-otel] ✓ SigNoz at http://localhost:4328/v1/traces
[hermes-otel] ✓ Jaeger at http://localhost:4318/v1/traces (traces only)
[hermes-otel] ✓ Tempo at http://localhost:4358/v1/traces (traces only)
[hermes-otel] ✓ honeycomb at https://api.honeycomb.io/v1/traces
An entry that cannot be resolved (a missing key, an unknown type) is logged and skipped; the others still start.
Isolation guarantees
Each backend runs in its own worker thread with its own bounded queue:
- A single slow collector backs up only its own queue — the others keep draining.
- If one backend is completely unreachable, the plugin logs the failed POST attempts and drops the oldest spans in that queue when the buffer fills. The others continue.
- At init the plugin opens one TCP connection to each backend endpoint on a background thread and logs
✓ <name> reachable at host:port (3 ms)or⚠ <name> unreachable at host:port (Connection refused), so a dead backend is said so at startup rather than 30 s later inside the export worker (#481). It runs once, sends no bytes and never gates an export; a proxy that accepts the connection passes even when the service behind it is down.hermes otel statusprobes on demand and prints the same verdict per backend.probe_backends: falseturns the startup probe off. - No backend's latency is on Hermes' hot path.
span.end()is still a queue push.
Per-backend signal overrides
By default, every backend receives trace exports. Metrics and logs follow
the type: a type whose backend takes no OTLP metrics (Phoenix, Langfuse,
Jaeger, Zipkin, Tempo and the other traces-only types) gets no metrics
exporter, and logs go only to types that store them. The
signal table lists every type's defaults.
Use traces: true|false, metrics: true|false, or logs: true|false to
override a signal per backend:
backends:
- type: otlp
name: collector
endpoint: http://collector:4318/v1/traces
metrics: true
logs: true
# Dashboard/query-only Tempo entry: visible to the OTel Traces tab, but it
# does not receive duplicate span exports because traces are disabled here.
- type: tempo
name: tempo-query
endpoint: http://tempo:3200
query_port: 3200
traces: false
metrics: false
logs: false
trace: false is accepted as a friendly alias for traces: false; use the
plural spelling in new configs to match metrics and logs.
The dashboard's Settings tab shows each entry as a card that opens the
backend's web UI. Add ui_url: https://grafana.example.com to an entry when
the UI is not on the OTLP endpoint's origin (Grafana for lgtm / tempo, a
Jaeger or SigNoz container on its own port); see
the dashboard docs.
Forcing a signal on or off:
backends:
- type: jaeger
endpoint: http://localhost:4318/v1/traces
metrics: true # Force-attempt metrics export (will probably 404; useful for testing collectors-that-happen-to-be-jaeger)
- type: otlp
name: traces-only-sink
endpoint: http://sink:4318/v1/traces
metrics: false # Explicit opt-out
Secrets
Prefer env-var references over inline values. Two forms:
-
*_env:keys — for first-class backend types:- type: langfusepublic_key_env: LANGFUSE_PUBLIC_KEYsecret_key_env: LANGFUSE_SECRET_KEY -
${VAR}interpolation — insideheaders::- type: otlpendpoint: https://api.honeycomb.io/v1/tracesheaders:x-honeycomb-team: ${HONEYCOMB_API_KEY}
The config file is plain text: even when it is not in git (the plugin repo ignores config.yaml), any editor plugin, pair-programming session or backup system can read it. Keep secrets in env vars. For a credential that expires while Hermes runs, use a headers_helper instead.
LangSmith and fan-out
LangSmith is an ordinary type: langsmith entry (its OTLP ingest at /otel/v1/traces), so it fans out beside every other backend. Before 1.26 it was a separate run-tree client that LANGSMITH_TRACING=true selected instead of the list; see LangSmith for the migration.
Debugging fan-out
Enable debug logging:
export HERMES_OTEL_DEBUG=true
Each backend's batches show up in ~/.hermes/plugins/hermes_otel/debug.log as export <backend>: N span(s) -> SUCCESS|FAILURE, with the SDK's reason for any failure on an [sdk] … line. See Debug logging.