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 —
logger.Debug(…)is exported. - Only the
…Contextcalls can carrytrace_idandspan_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 to0.1, so omitting it is not "record everything".metrics.WithInterval(d)— the periodic reader's export interval. Defaults to 60 seconds.logstakes no options at all — there is no variadic parameter onlogs.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¶
- Diagnose telemetry that never arrives — when all of the above builds cleanly and the backend stays empty.
- Configuration keys — loading
sharedand the per-signal sections from atelemetry.*config tree.