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:
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.
OTEL_EXPORTER — applies to all three. Unchanged from today, no longer the only way.
- 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
- 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:
OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_PROTOCOL
OTEL_EXPORTER_OTLP_PROTOCOL
OTEL_PROTOCOL
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.
OTel signals cannot be configured per signal via environment variables
Summary
OpenTelemetryConfighas separateTraces,MetricsandLogssections, and eachprovider constructor honors its own
Exporter— including adefaultbranch thatreturns 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_EXPORTERandOTEL_PROTOCOLconfigure all three signals at once. Only a YAMLconfig 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_NAMEto collect one signal enables all three.The two you didn't want have no endpoint, so the SDK falls back to
localhost:4317/4318and 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 allthree positions:
Config loads via
env.Parse(c)(internal/config/config.go:288). caarlos0/env walksnested structs and applies each field's tag verbatim, with no per-struct prefix — so one
OTEL_EXPORTERvalue populatesTraces.Exporter,Metrics.ExporterandLogs.Exporter. Three fields, one value between them.The off switch works, it is just unreachable:
internal/otel/exporter.go:47,:79,:111each return a provider with no exporter attached for an unrecognizedExportervalue.
The default compounds it —
case "", "otlp"means unset is on, so enabling OTelenables everything.
The field's
descalso advertises behavior that does not exist ("Typically used withenvironment variables like
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"), which propagates intothe 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, thenOTEL_EXPORTER_OTLP_PROTOCOL, then a gRPCdefault — and has no callers.
ToConfigreadsc.<Signal>.Protocoldirectly anddefaults 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 (
otlptracegrpcvsotlptracehttp), and noOTEL_EXPORTER_OTLP_PROTOCOLhandling exists anywhere under
exporters/otlpat v1.44.0. The protocol can only bechanged via
OTEL_PROTOCOL, which is Outpost-specific and, per the above, applies toall three signals at once.
getProtocolis also the package's only use of viper, and the config package neverconstructs a viper instance — so it could not have worked as written even if it were
called.
Reproduce
Metrics arrive. Traces and logs are enabled anyway and retry against the default
endpoint indefinitely:
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) becauseNewTracerProvidernever passesWithSampler.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:
OTEL_TRACES_EXPORTER/OTEL_METRICS_EXPORTER/OTEL_LOGS_EXPORTER— spec names,spec values.
nonedisables a signal and maps onto the existingdefaultbranch ineach provider constructor, so it needs no new code path.
OTEL_EXPORTER— applies to all three. Unchanged from today, no longer the only way.OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_ENDPOINTset → those signals on,the rest off
OTEL_EXPORTER_OTLP_ENDPOINTset → all three onlocalhostendpoint. Today'sbehavior, 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=noneis for.Which protocol — per signal, first match wins:
OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_PROTOCOLOTEL_EXPORTER_OTLP_PROTOCOLOTEL_PROTOCOLgrpcSteps 1 and 2 are what
getProtocolalready 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.
httpmust keep validating as an alias for the spec'shttp/protobuf— it is the valuevalidateOTelProtocolaccepts 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_PROTOCOLand never touches per-signal anything. An operator who configuresendpoints 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, oronly
OTEL_SERVICE_NAMEplus a generic endpoint.Also worth fixing alongside: the
desctags, so the generated config docs describe thevariables that actually exist.