Add request and trace context to Go logs with slog
Compare explicit loggers, context-carried loggers, and context-aware handlers, then add active OpenTelemetry trace IDs and validate correlation in Guance.
A log saying “Order processed” tells you little when hundreds of requests are running at once. Add a request ID, service name, and order ID, and the same event becomes part of an investigation:
level=INFO msg="Order processed"
level=INFO msg="Order processed" request_id=7f3a9c user_id=user-42 order_id=A-1024 service=orders
Go's log/slog, available since Go 1.21, provides structured attributes and context-aware logging methods. It does not automatically turn every value in context.Context into a log field. The application chooses how to carry request attributes, and a handler decides which context values to emit.
This guide compares three ways to make that choice explicit, then adds OpenTelemetry trace and span IDs without freezing them at the start of a request.
Bind stable attributes with With
Use With for values that apply to every event from a logger:
base := slog.New(slog.NewJSONHandler(os.Stdout, nil)).With(
slog.String("service", "orders"),
)
requestLogger := base.With(slog.String("request_id", requestID))
requestLogger.Info("Order processed", slog.String("order_id", "A-1024"))
requestLogger carries the service and request ID; base is unchanged. A downstream function gets those fields only if it uses that derived logger. Binding attributes in middleware does not change every other logger in the process.
WithGroup groups subsequent attributes. With a JSON handler, this produces an http object:
requestLogger.WithGroup("http").Info(
"Response prepared",
slog.String("method", "GET"),
slog.Int("status_code", 200),
)
Decide whether your collection schema expects nested groups or flat fields before standardizing the output. slog reference
Option 1: pass the logger as a dependency
Explicit parameters make the dependency visible:
func processOrder(ctx context.Context, logger *slog.Logger, orderID string) {
logger.InfoContext(ctx, "Processing order", slog.String("order_id", orderID))
// Perform the application operation here.
}
func orderHandler(base *slog.Logger) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestLogger := base.With(slog.String("request_id", newRequestID()))
processOrder(r.Context(), requestLogger, "A-1024")
w.WriteHeader(http.StatusNoContent)
})
}
newRequestID is the application's ID generator; the next example supplies one. Passing a logger works well where explicit dependencies are already the convention. It can require changes to function signatures, especially deep in an existing call tree. A logger stored on a service struct is another explicit dependency pattern, but request-specific fields still need to be bound or supplied per call.
Option 2: carry a request logger in context
If functions already accept context.Context, a request-scoped logger can travel with it. Use a private key type and a safe fallback rather than an unchecked type assertion:
package requestlog
import (
"context"
"crypto/rand"
"encoding/hex"
"log/slog"
"net/http"
)
type loggerContextKey struct{}
func newRequestID() string {
var id [16]byte
if _, err := rand.Read(id[:]); err != nil {
panic(err)
}
return hex.EncodeToString(id[:])
}
func FromContext(ctx context.Context) *slog.Logger {
if logger, ok := ctx.Value(loggerContextKey{}).(*slog.Logger); ok {
return logger
}
return slog.Default()
}
func LoggingMiddleware(next http.Handler, base *slog.Logger) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := newRequestID()
logger := base.With(slog.String("request_id", requestID))
ctx := context.WithValue(r.Context(), loggerContextKey{}, logger)
w.Header().Set("X-Request-ID", requestID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func Handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
FromContext(ctx).InfoContext(ctx, "Request received")
w.WriteHeader(http.StatusNoContent)
}
This example generates its own ID. If you accept an upstream X-Request-ID, validate its length and character set and treat it as correlation data, not proof of identity. Decide whether the trusted gateway or the application owns that ID.
The advantage is fewer signature changes. The trade-off is an implicit dependency: readers must know the context convention to see where the logger comes from. Keep that convention small and documented. Context values are intended for request-scoped data; they are not a general dependency-injection container.
Option 3: let a handler extract context attributes
A shared logger can also inspect request context at each logging call. The built-in JSON and text handlers do not extract arbitrary context values, so this requires a wrapper or a library designed for it.
For example, the github.com/veqryn/slog-context package provides a handler and functions for appending context attributes:
import (
"context"
"log/slog"
"os"
slogctx "github.com/veqryn/slog-context"
)
func configureLogger() {
handler := slogctx.NewHandler(slog.NewJSONHandler(os.Stdout, nil), nil)
slog.SetDefault(slog.New(handler))
}
func processRequest(ctx context.Context, requestID string) {
ctx = slogctx.Append(ctx, "request_id", requestID)
slog.InfoContext(ctx, "Request received")
}
Call configureLogger() once at startup and pin a tested package version. processRequest illustrates an existing request context; downstream code must receive that derived context for the field to follow it. slog-context API
This pattern avoids passing a logger separately, but still requires context-aware logging calls. slog.Info("Request received") does not magically recover the request's context. It also makes the handler configuration part of the application's logging contract.
Extract trace IDs from the active span
OpenTelemetry instrumentation creates or extracts trace context and carries it through context.Context. Logging can read the current span context and emit trace_id and span_id as fields.
Read them at the logging call, rather than binding the initial span ID to a logger once in middleware. Otherwise logs written inside a child span can incorrectly retain the request's original span ID.
A handler wrapper can do that:
package requestlog
import (
"context"
"log/slog"
"go.opentelemetry.io/otel/trace"
)
type TraceHandler struct {
next slog.Handler
}
func NewTraceHandler(next slog.Handler) *TraceHandler {
return &TraceHandler{next: next}
}
func (h *TraceHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.next.Enabled(ctx, level)
}
func (h *TraceHandler) Handle(ctx context.Context, record slog.Record) error {
sc := trace.SpanContextFromContext(ctx)
if sc.IsValid() {
record = record.Clone()
record.AddAttrs(
slog.String("trace_id", sc.TraceID().String()),
slog.String("span_id", sc.SpanID().String()),
)
}
return h.next.Handle(ctx, record)
}
func (h *TraceHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &TraceHandler{next: h.next.WithAttrs(attrs)}
}
func (h *TraceHandler) WithGroup(name string) slog.Handler {
return &TraceHandler{next: h.next.WithGroup(name)}
}
Place this in a separate file in the same package as the earlier middleware. Wire it into the base logger during startup:
base := slog.New(NewTraceHandler(slog.NewJSONHandler(os.Stdout, nil)))
The wrapper delegates level checks and preserves itself when a logger calls With or WithGroup. It does not create spans or install an exporter. Your HTTP instrumentation must already establish a valid span context, and application code must pass the context of the active child span to InfoContext or ErrorContext when appropriate. OpenTelemetry Go trace API
Groups also matter: this wrapper forwards to the grouped handler, so dynamically appended trace fields can appear inside the active group. If Guance expects top-level correlation fields, keep tracing calls on the ungrouped logger or implement and test a wrapper that explicitly preserves them at the top level. Avoid adding competing trace_id fields manually.
An ungrouped error event can then contain:
{"time":"2026-08-26T08:00:00Z","level":"ERROR","msg":"Login failed","user_id":"user-42","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","span_id":"00f067aa0ba902b7"}
A valid span context does not prove that the trace was recorded or retained. Sampling and export failures can leave a log with an ID but no stored trace.
Verify the correlation in Guance
Send a known request and collect its log through DataKit. Check that the emitted trace_id and span_id match the request's trace context, that they remain strings, and that Pipeline maps them to the expected fields.
Use the Guance APM FAQ to check the relevant association settings, including service, environment, version, and log-index mappings. Then open the trace details and confirm that the collected event appears.
A request_id remains useful for filtering logs even without distributed tracing. It should not be relabeled trace_id: the formats and relationships belong to different correlation schemes.
Frequently asked questions
Should context carry a logger or just fields?
A request logger is simple to retrieve and use, but hides its bound fields from a caller. Fields plus an extracting handler separate the event context from the logging object. Choose a convention that suits the existing code, then apply it consistently. Explicit logger dependencies remain a good baseline where signature changes are manageable.
Should every attribute be bound with With?
No. Bind values that are useful on most events in that scope, such as service and request ID. Add occasional fields such as an order ID at the particular call. Every repeated attribute adds payload, so choose user identifiers and other personal data deliberately.
Is a request ID useful without OpenTelemetry?
Yes. It can group events from one request immediately. Later, add trace and span IDs alongside it when tracing is installed; there is no need to reinterpret the request ID as a trace ID.
Does InfoContext automatically add fields or suppress logs after cancellation?
No. It passes the context to the handler. The built-in handlers do not extract application values, and cancellation is not a reason for a handler to discard a record. You often need the final diagnostic log precisely because a request was canceled. Custom context processing must be explicit.
Related reading
Contact us
Join the community
to join the community
Try Guance
Start online and pay only for what you use.
Get startedChoose a Guance plan