Files
telemetry/docs/adr/ADR-003-otel-api-vs-sdk-separation.md
Rene Nochebuena ed4e9ef161 feat(telemetry): initial stable release v0.9.0
Single-call OTel SDK bootstrap setting all three global providers (traces → Tempo, metrics → Mimir, logs → Loki) over OTLP gRPC.

What's included:
- New(ctx, Config): bootstraps TracerProvider, MeterProvider, and LoggerProvider with OTLP gRPC exporters; sets OTel globals
- W3C TraceContext + Baggage propagation set globally
- Resource tagging: service.name, service.version, deployment.environment merged with SDK defaults
- OTLPInsecure bool for development environments without TLS
- Sequential rollback on partial initialization failure — no dangling exporters on error
- Returns shutdown func(context.Context) error; caller defers in main or wires into launcher BeforeStop
- Tier 5 module: must be imported only by application main packages; zero micro-lib dependencies

Tested-via: todo-api POC integration
Reviewed-against: docs/adr/
2026-03-18 14:13:29 -06:00

3.2 KiB

ADR-003: OTel API vs SDK Separation — Global Provider Strategy

Status: Accepted Date: 2026-03-18

Context

OpenTelemetry Go has a two-package model:

  • API packages (go.opentelemetry.io/otel, .../otel/metric, .../otel/log) — stable, backward-compatible interfaces. When called with no SDK registered, all operations are no-ops with zero allocation.
  • SDK packages (go.opentelemetry.io/otel/sdk/...) — concrete implementations with exporters, processors, samplers. These have real runtime cost and external dependencies.

Micro-libs (httpserver, httpmw, logz, etc.) need to emit spans, metrics, or log records. They must not carry SDK dependencies. The question is how to connect API calls in libraries to the real SDK without importing SDK packages from libraries.

The two main strategies are:

  1. Explicit injection — each library accepts a TracerProvider, MeterProvider, or LoggerProvider as a constructor argument, and the application injects the real SDK provider.
  2. Global provider — libraries call otel.Tracer(...) / otel.Meter(...) / global.Logger(...) which consult the process-wide global provider. The application sets that global once at startup.

Decision

Use the OTel global provider strategy. Micro-libs obtain tracers, meters, and loggers from the OTel global API. telemetry.New(...) sets all three globals:

otel.SetTracerProvider(tp)       // traces
otel.SetMeterProvider(mp)        // metrics
global.SetLoggerProvider(lp)     // logs (go.opentelemetry.io/otel/log/global)

This means:

  • Libraries have zero SDK dependency. They only import go.opentelemetry.io/otel (and sub-packages for metric/log API).
  • Before telemetry.New is called, all OTel calls in libraries are no-ops — correct behavior in unit tests and in applications that don't use telemetry.
  • After telemetry.New is called, all OTel calls in libraries automatically route to the real OTLP exporters with no code change required in the libraries.

Explicit injection was considered but rejected because:

  • It forces every library constructor to accept provider arguments even when the application doesn't use telemetry.
  • It makes the calling code more verbose (every New(logger, cfg, tracerProvider, meterProvider, ...)) without clear benefit in a single-process application.
  • The global approach is the design intent of the OTel Go project for application-level bootstrap.

Consequences

  • The global providers are process-global mutable state. Tests that call telemetry.New will affect other tests running in the same process if tests run in parallel. The test suite uses a fake collector and short shutdown timeouts to mitigate this.
  • If a library is used in a context where the global provider has not been set (e.g., a library test), all OTel calls are no-ops. This is correct and expected.
  • Applications that use multiple telemetry.New calls (e.g., a misconfigured init) will overwrite the globals. Only one call to telemetry.New should occur per process.
  • The go.opentelemetry.io/otel/log/global package is a separate import from go.opentelemetry.io/otel because the log signal API was stabilized later. Libraries using the log API must import the log/global sub-package for global.SetLoggerProvider.