Skip to content

Wire OTLP logs, metrics & traces

All three signal packages share the same shape — NewProvider(ctx, res, settings, …opts) — so wiring the full stack is three calls against one resource. This guide also shows resolving one shared endpoint across the signals with per-signal overrides.

One resource, three providers

res := otelcore.Resource("my-service", "1.4.0")
s := otelcore.Settings{Enabled: true, Endpoint: "https://collector.internal:4318"}

tp, err := tracing.NewProvider(ctx, res, s, tracing.WithSampling(0.25))
mp, err := metrics.NewProvider(ctx, res, s, metrics.WithInterval(30*time.Second))
lp, err := logs.NewProvider(ctx, res, s)

Register them with the global API and remember to shut each down on exit (they flush buffered telemetry):

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

defer func() {
    _ = tp.Shutdown(ctx)
    _ = mp.Shutdown(ctx)
    _ = lp.Shutdown(ctx)
}()

Bridge logs into slog

For logs, logs.Handler turns a LoggerProvider into a ready slog.Handler, so your existing slog call sites export to OTel without change:

lp, _ := logs.NewProvider(ctx, res, s)
logger := slog.New(logs.Handler(lp, "my-service"))
logger.InfoContext(ctx, "service started") // ← exported via OTLP

Three things about that logger are easy to get wrong, and each has a fix in Correlate logs with traces:

  • It writes only to OTLP. Nothing reaches stderr any more.
  • It applies no level floorlogger.Debug(…) is exported.
  • Only the …Context calls can carry trace_id and span_id. logger.Info(…) inside a span produces an uncorrelated record.

Signal options

  • tracing.WithSampling(ratio) — parent-based ratio sampling (e.g. 0.1 = 10%). Defaults to 0.1, so omitting it is not "record everything".
  • metrics.WithInterval(d) — the periodic reader's export interval. Defaults to 60 seconds.
  • logs takes no options at all — there is no variadic parameter on logs.NewProvider.

Both options override the corresponding environment variable (OTEL_TRACES_SAMPLER, OTEL_METRIC_EXPORT_INTERVAL), and they do so even when you pass no option, because the package default is passed in your place. See Provider options and defaults.

One endpoint, per-signal overrides

When most signals share a collector but one differs (say traces go to a separate tail-sampling endpoint), resolve each signal's Settings from a shared config plus a per-signal override with otelcore.ResolveSettings:

shared := otelcore.Config{Endpoint: "https://collector.internal:4318"}

// Traces override only the endpoint; everything else inherits the shared config.
traceSignal := otelcore.SignalConfig{Enabled: true, Endpoint: "https://tail-sampler:4318"}

traceSettings := otelcore.ResolveSettings(shared, traceSignal, otelcore.SignalOverrides{
    Endpoint: true, // only Endpoint was explicitly set for traces
})
// traceSettings.Endpoint == "https://tail-sampler:4318"; Headers/Insecure inherit shared.

The SignalOverrides mask records which fields the per-signal section actually set, so an unset field never clobbers the shared value with a zero. You decide how a field counts as "set" (a config key present, a non-empty flag, an env var) and express it as the mask — the core takes no dependency on any configuration container.

Enabled and the empty endpoint

Settings.Enabled is yours to honour — skip building a provider when a signal is disabled. Nothing in this module reads the field, so building one anyway gives a disabled signal a live exporter and a background export goroutine.

An empty Endpoint on an enabled signal is not an error: the exporter falls back to OTEL_EXPORTER_OTLP_*, and to localhost:4318 if those are unset too. See the config model for the full resolution order.

Where to look next