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

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.

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.