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_*.
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.
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.