Skip to content

OTel signals cannot be configured per signal via environment variables #1015

Description

@alexluong

OTel signals cannot be configured per signal via environment variables

Summary

OpenTelemetryConfig has separate Traces, Metrics and Logs sections, and each
provider constructor honors its own Exporter — including a default branch that
returns a provider which never exports. Per-signal control is fully implemented
internally.

It is unreachable from the environment. All three sections share one pair of env vars,
so OTEL_EXPORTER and OTEL_PROTOCOL configure all three signals at once. Only a YAML
config file can express per-signal config; a container that mounts no config file has no
way to get there.

Separately, none of the standard OTel variables an operator would reach for first
(OTEL_EXPORTER_OTLP_PROTOCOL, OTEL_EXPORTER_OTLP_{SIGNAL}_PROTOCOL) have any effect,
despite a helper being present that was written to read them.

Practical effect: setting OTEL_SERVICE_NAME to collect one signal enables all three.
The two you didn't want have no endpoint, so the SDK falls back to
localhost:4317/4318 and they export into a dead socket for the life of the process.

Cause

Per-signal exporter/protocol

internal/config/otel.go:11-21 — the embedded struct carries identical tags in all
three positions:

type OpenTelemetryTypeConfig struct {
	Exporter string `yaml:"exporter" env:"OTEL_EXPORTER" ...`
	Protocol string `yaml:"protocol" env:"OTEL_PROTOCOL" ...`
}

type OpenTelemetryConfig struct {
	ServiceName string                  `env:"OTEL_SERVICE_NAME" ...`
	Traces      OpenTelemetryTypeConfig `yaml:"traces"`
	Metrics     OpenTelemetryTypeConfig `yaml:"metrics"`
	Logs        OpenTelemetryTypeConfig `yaml:"logs"`
}

Config loads via env.Parse(c) (internal/config/config.go:288). caarlos0/env walks
nested structs and applies each field's tag verbatim, with no per-struct prefix — so one
OTEL_EXPORTER value populates Traces.Exporter, Metrics.Exporter and
Logs.Exporter. Three fields, one value between them.

The off switch works, it is just unreachable: internal/otel/exporter.go:47, :79,
:111 each return a provider with no exporter attached for an unrecognized Exporter
value.

The default compounds it — case "", "otlp" means unset is on, so enabling OTel
enables everything.

The field's desc also advertises behavior that does not exist ("Typically used with
environment variables like OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"), which propagates into
the generated config docs.

Standard protocol variables are ignored

getProtocol (internal/config/otel.go:45) implements the spec's precedence chain —
OTEL_EXPORTER_OTLP_<SIGNAL>_PROTOCOL, then OTEL_EXPORTER_OTLP_PROTOCOL, then a gRPC
default — and has no callers. ToConfig reads c.<Signal>.Protocol directly and
defaults it to gRPC through an unrelated local closure, getProtocolWithDefault.

So both standard variables are silently ignored and every deployment gets gRPC
regardless. The SDK does not compensate: transport is fixed by which exporter package is
imported (otlptracegrpc vs otlptracehttp), and no OTEL_EXPORTER_OTLP_PROTOCOL
handling exists anywhere under exporters/otlp at v1.44.0. The protocol can only be
changed via OTEL_PROTOCOL, which is Outpost-specific and, per the above, applies to
all three signals at once.

getProtocol is also the package's only use of viper, and the config package never
constructs a viper instance — so it could not have worked as written even if it were
called.

Reproduce

OTEL_SERVICE_NAME=outpost
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT=https://<collector>
# traces and logs deliberately left unconfigured

Metrics arrive. Traces and logs are enabled anyway and retry against the default
endpoint indefinitely:

error 2026/07/30 17:08:40 traces export: Post "https://localhost:4318/v1/traces":
      dial tcp [::1]:4318: connect: connection refused

There is no environment variable that turns them off.

Workaround

OTEL_TRACES_SAMPLER=always_off, which the SDK reads itself
(sdk/trace/sampler_env.go) because NewTracerProvider never passes WithSampler.
Spans become non-recording. Metrics are unaffected — separate provider. This routes
around Outpost's config rather than through it, and there is no equivalent for logs.

Proposed fix

Setting an endpoint should be how you opt a signal in, and the OTel specification's own
variable names should work. Outpost should need no proprietary variable to express any
of this, while keeping the ones it has.

Which signals are on — per signal, first match wins:

  1. OTEL_TRACES_EXPORTER / OTEL_METRICS_EXPORTER / OTEL_LOGS_EXPORTER — spec names,
    spec values. none disables a signal and maps onto the existing default branch in
    each provider constructor, so it needs no new code path.
  2. OTEL_EXPORTER — applies to all three. Unchanged from today, no longer the only way.
  3. Endpoint inference:
    • any OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_ENDPOINT set → those signals on,
      the rest off
    • else OTEL_EXPORTER_OTLP_ENDPOINT set → all three on
  4. Nothing set → all three on, against the SDK's default localhost endpoint. Today's
    behavior, preserved for local-collector setups.

Generic and per-signal endpoints together: the generic endpoint turns all three on, and
a per-signal endpoint refines where that signal sends — which is how the SDK already
resolves endpoints, so Outpost inherits it. Turning one signal off while a generic
endpoint is set is what OTEL_{SIGNAL}_EXPORTER=none is for.

Which protocol — per signal, first match wins:

  1. OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_PROTOCOL
  2. OTEL_EXPORTER_OTLP_PROTOCOL
  3. OTEL_PROTOCOL
  4. grpc

Steps 1 and 2 are what getProtocol already implements; this is where it gets wired up
(and loses its unused viper parameter). If both 2 and 3 are set to different values the
spec name wins.

http must keep validating as an alias for the spec's http/protobuf — it is the value
validateOTelProtocol accepts today and what existing deployments are set to.

Endpoint variables stay the SDK's job. Outpost would read them only to decide which
signals to enable, and continue to leave endpoint, headers, compression and timeout
entirely to the SDK.

The organizing principle throughout: explicit beats inferred, specific beats generic.
An operator who wants one variable to govern everything sets OTEL_EXPORTER /
OTEL_PROTOCOL and never touches per-signal anything. An operator who configures
endpoints per signal gets the matching signals without setting an exporter variable at
all. Both are supported; neither is a fallback for the other.

Nothing here changes behavior for a deployment that sets only OTEL_SERVICE_NAME, or
only OTEL_SERVICE_NAME plus a generic endpoint.

Also worth fixing alongside: the desc tags, so the generated config docs describe the
variables that actually exist.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions