Correlate logs with traces¶
A log record exported through logs.Handler carries trace_id and span_id when — and
only when — the call that produced it was given a context containing an active span. The
provider cannot supply what the call site did not pass.
Pass a context to every log call¶
Use slog's …Context variants. They are the only ones that reach the OTel bridge with
a context:
ctx, span := tp.Tracer("checkout").Start(ctx, "place-order")
defer span.End()
logger.InfoContext(ctx, "order placed", "order.id", orderID)
logger.Info("order placed") — no context — produces a record with an empty trace ID,
even lexically inside the span above. slog substitutes context.Background(), the OTel
SDK finds no span in it, and the record is exported uncorrelated.
The same applies to DebugContext, WarnContext and ErrorContext. There is no
setting that makes the non-context forms correlate.
Propagate context into the functions that log¶
Correlation only survives if the context does. A helper that logs needs the context passed to it:
func (s *Service) placeOrder(ctx context.Context, id string) error {
ctx, span := s.tracer.Start(ctx, "placeOrder")
defer span.End()
if err := s.repo.Insert(ctx, id); err != nil {
s.logger.ErrorContext(ctx, "insert failed", "err", err)
return err
}
return nil
}
A function that takes no context.Context, or that starts from context.Background(),
cannot produce a correlated record no matter how it logs.
Keep stderr output as well as OTLP¶
slog.New(logs.Handler(lp, "checkout")) sends records only to the collector. Local
output stops. This module ships no fan-out handler, so write the few lines yourself:
type fanout []slog.Handler
func (f fanout) Enabled(ctx context.Context, l slog.Level) bool {
for _, h := range f {
if h.Enabled(ctx, l) {
return true
}
}
return false
}
func (f fanout) Handle(ctx context.Context, r slog.Record) error {
for _, h := range f {
if h.Enabled(ctx, r.Level) {
if err := h.Handle(ctx, r.Clone()); err != nil {
return err
}
}
}
return nil
}
func (f fanout) WithAttrs(a []slog.Attr) slog.Handler {
out := make(fanout, len(f))
for i, h := range f {
out[i] = h.WithAttrs(a)
}
return out
}
func (f fanout) WithGroup(name string) slog.Handler {
out := make(fanout, len(f))
for i, h := range f {
out[i] = h.WithGroup(name)
}
return out
}
Then combine the two handlers:
logger := slog.New(fanout{
slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}),
logs.Handler(lp, "checkout"),
})
Record.Clone() matters: slog.Record shares its attribute backing array, and handing
the same record to two handlers that add attributes corrupts one of them.
Stop debug records reaching the collector¶
The OTel bridge applies no severity floor of its own, so logger.DebugContext(…) is
exported. slog's familiar "info and above" default comes from the text and JSON
handlers, and there is not one in the path — slog.Logger itself has no level to set.
Wrap the bridge in a handler that does have one:
type minLevel struct {
slog.Handler
level slog.Level
}
func (m minLevel) Enabled(ctx context.Context, l slog.Level) bool {
return l >= m.level && m.Handler.Enabled(ctx, l)
}
func (m minLevel) WithAttrs(a []slog.Attr) slog.Handler {
return minLevel{Handler: m.Handler.WithAttrs(a), level: m.level}
}
func (m minLevel) WithGroup(name string) slog.Handler {
return minLevel{Handler: m.Handler.WithGroup(name), level: m.level}
}
The WithAttrs and WithGroup overrides are not optional: without them, the first
logger.With(…) call returns the bare bridge handler and the floor silently disappears.
Check the correlation actually happened¶
Run a collector with the debug exporter and look at one record. A correlated record shows non-empty IDs matching the span:
LogRecord #0
Body: Str(order placed)
Trace ID: 3ed4f56bdf7729f52cf37e955c627cfe
Span ID: 025f4b95bbe12bc2
An uncorrelated one shows the fields present and empty:
The first-signals tutorial sets up that collector in a couple of commands.
What correlation does not need¶
Two things people reach for and do not need:
- A propagator.
otel.SetTextMapPropagatormatters for carrying trace context between services. Within one process, correlation works from the Go context alone. - A shared endpoint. Logs and traces can go to different collectors and still correlate, because the IDs travel on the records rather than being matched at export.