Endpoint resolution & the config model¶
This module deliberately separates deciding what the OTLP targets are from building
the exporters. otelcore owns the first half as a small set of typed values and a
pure merge; the signal packages own the second. Understanding that split explains why
the API looks the way it does — and why an empty endpoint is a feature, not a bug.
Typed values, not a config framework¶
otelcore takes no dependency on any configuration container. It works from
plain structs:
Config— the shared OTLP settings (Endpoint,Headers,Insecure).SignalConfig— the same fields plusEnabled, for one signal (traces / metrics / logs).Settings— the resolved target for one signal, which the provider factories consume.
You decide where these come from — flags, environment, a YAML file, your own config
library. The module never reaches for a Viper or a context to find them. This is
what keeps it framework-free and trivially testable.
Shared-plus-override resolution¶
Most services point every signal at one collector, occasionally overriding one signal.
ResolveSettings
expresses exactly that: start from the shared Config, then overlay the fields a
signal explicitly set.
The catch every hand-rolled merge gets wrong is telling "explicitly set to empty" apart
from "not set." A zero-valued SignalConfig.Endpoint could mean use no endpoint or
inherit the shared one. otelcore resolves this with an explicit
SignalOverrides
mask: a field is overridden only when its mask bit is true. An unset field can
never clobber the shared value with a zero. You own the policy for what "set" means —
a config key being present, a non-empty flag, an environment variable — and encode it
as the mask.
Enabled is per-signal only (there is no shared "enable everything"), so each signal
opts in independently.
The empty endpoint is intentional¶
A resolved Settings with Enabled: true and an empty Endpoint is valid, not an
error. The signal exporters treat it as "I have no explicit endpoint" and fall back to
the standard OpenTelemetry environment variables — OTEL_EXPORTER_OTLP_ENDPOINT and
friends.
This matters operationally: a service can ship with no telemetry endpoint in its own config, and an operator points it at a collector purely through the ecosystem's env vars — the same knobs every other OTel-instrumented process already understands. The module gets out of the way rather than forcing its config to duplicate the OTel environment contract.
Resolution order for a signal's endpoint is therefore:
- The signal's explicitly-set endpoint (via the override mask), else
- the shared config's endpoint, else
- empty → the SDK reads
OTEL_EXPORTER_OTLP_*.
The fallback is endpoint-only, not all-or-nothing. A configured Headers map and
the Insecure flag are still passed to the exporter on the empty-endpoint path.
So an operator can point the target at a collector through
OTEL_EXPORTER_OTLP_ENDPOINT while keeping an auth token in the service's own config
— the token is honoured, and the export never silently falls back to unauthenticated.
Explicit values replace environment values; they do not merge with them¶
"Still passed" is precise, and "merged" would not be. The SDK applies environment
configuration first and explicit options second, and its WithHeaders assigns the
map rather than merging into it. A single entry in a configured Headers map therefore
discards everything in OTEL_EXPORTER_OTLP_HEADERS, rather than being added to it.
The same ordering has a sharper consequence for Insecure, and it runs against the
grain of the endpoint validation below: because this module only ever adds
WithInsecure() and never the SDK's WithSecure(), an OTEL_EXPORTER_OTLP_INSECURE=true
in the environment survives into a configured https:// endpoint and the traffic goes out
in plaintext. Endpoint validation is configuration hygiene, not a transport guarantee —
the environment-variable reference
has the truth table.
An empty endpoint is not "off"¶
One thing the fallback does not do is disable export. With Endpoint empty and no
OTEL_EXPORTER_OTLP_ENDPOINT set either, the exporter uses the SDK's default target of
localhost:4318 and fails against it on a background goroutine. "Nowhere configured" is
indistinguishable from "configured wrongly", and neither fails at construction. If a
signal should be off, act on Settings.Enabled and skip the constructor.
Endpoint parsing fails fast and closed¶
When an endpoint is given, it is validated up front by
ParseEndpoint
rather than deferred to the first failed export. The checks mirror the toolkit's
chat base-URL validator:
- Scheme allowlist — only
httpandhttps. Anhttpscheme (or an explicitInsecureflag) marks the endpoint plaintext; use that only for a local collector. - No credentials in the URL — a URL carrying userinfo (
http://user:pass@host) is rejected. Auth belongs inHeaders, never the URL, where it would leak into logs. - Shape checks — empty, schemeless, hostless, unparseable, control-character-bearing,
or over-long URLs (
MaxEndpointLength, 2 KiB) are rejected. - Trailing-slash normalisation — a trailing slash on the path is trimmed, so
https://collector:4318/yields an emptyBasePathand each signal's suffix joins cleanly to/v1/logs(etc.) rather than//v1/logs, which strict collectors 404.
All failures wrap the
ErrInvalidEndpoint
sentinel, so callers can distinguish a bad endpoint from an exporter/transport error
with errors.Is. A misconfigured endpoint stops you at startup, not silently at export
time.
Why the signal packages are separate¶
otelcore imports no signal exporter. tracing, metrics, and logs each build
their own exporter (otlptracehttp / otlpmetrichttp / otlploghttp) from a resolved
Settings. A service that only needs traces imports tracing and never links the
metric or log exporter code. The shared core stays free of any one signal's
dependencies, which is what lets all three sit on it without a heavyweight common
import.
Where to look next¶
- What this module does not do — the constraints this model implies, stated as constraints.
- Configuration keys — the
telemetry.*tree, every field, and what a wrong value does. - Environment variables — which
OTEL_*variables reach the exporters, and which this module overrides.