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 floor, so logger.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 (0.1 is 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