Skip to content

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 plus Enabled, 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.

settings := otelcore.ResolveSettings(shared, signal, overrides)

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:

  1. The signal's explicitly-set endpoint (via the override mask), else
  2. the shared config's endpoint, else
  3. 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 http and https. An http scheme (or an explicit Insecure flag) 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 in Headers, 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 empty BasePath and 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