Golang Logging With slog: JSON, Levels, and Trace IDs
Last verified on Nov 8, 2026.
Go's standard library has had structured logging since Go 1.21: the log/slog package. A call is a message plus key-value attributes, and a handler decides the output format. The built-in JSON handler gives you one object per line with time, level, msg, and your fields. This guide covers setup, runtime level changes, request and trace IDs through context.Context, redaction, and OpenTelemetry export.
Set Up a JSON Logger
package main
import (
"log/slog"
"os"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger)
slog.Info("order placed", "order_id", 4812, "amount_cents", 2599)
}The output is structured logging in the form the package documents:
{
"time": "2026-11-08T06:49:58.818855801Z",
"level": "INFO",
"msg": "order placed",
"order_id": 4812,
"amount_cents": 2599
}slog.SetDefault makes the top-level functions such as slog.Info use your logger. It also redirects the older log package, so existing log.Printf calls in your code and in dependencies flow through the same handler. The Go team wrote in the slog announcement that the goal was a common framework other logging packages can share, so many third-party loggers can plug in as handlers rather than compete.
Attributes can be alternating keys and values, as above, or typed slog.Attr values such as slog.Int("count", 3). If you pass a key with no value, slog logs it under the key !BADKEY, which is a sign to grep for in code review.
Levels and Changing Them at Runtime
The levels are numbers: LevelDebug is -4, LevelInfo is 0, LevelWarn is 4, and LevelError is 8. The built-in handlers default to INFO. See log levels for when each is appropriate.
To change the level while the process runs, give the handler a LevelVar. The package documents it as safe to read and write from multiple goroutines:
var level = new(slog.LevelVar) // Info by default
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level})
slog.SetDefault(slog.New(handler))
// Later, from an admin endpoint or signal handler:
level.Set(slog.LevelDebug)Wire level.Set to an authenticated admin route or a signal so you can turn on debug output for a live incident without a redeploy, then turn it back off.
Add Fixed Fields With With and Group
Logger.With returns a new logger that includes attributes on every call. Use it for values that never change per process:
logger := slog.New(handler).With("service", "checkout", "version", "1.14.2")slog.Group nests related fields. The JSON handler writes each group as an object keyed by the group name:
slog.Info("order placed",
"order_id", 4812,
slog.Group("user", slog.Int("id", 42), slog.String("plan", "pro")),
){ "time": "...", "level": "INFO", "msg": "order placed", "order_id": 4812, "user": { "id": 42, "plan": "pro" } }Set AddSource: true in HandlerOptions when you want the file and line of each call, at the cost of a slightly larger line.
Request and Trace IDs From Context
The built-in handlers do not read the context for you. The package documents that some handlers may want information from the context.Context at the call site, with the current span identifier as the example, and it recommends passing a context to the output method when one is available. That means two things: call InfoContext, ErrorContext, or LogAttrs with ctx, and wrap the handler to pull IDs out of it.
type ctxKey struct{}
type ContextHandler struct{ slog.Handler }
func (h ContextHandler) Handle(ctx context.Context, r slog.Record) error {
if id, ok := ctx.Value(ctxKey{}).(string); ok {
r.AddAttrs(slog.String("request_id", id))
}
if sc := trace.SpanContextFromContext(ctx); sc.IsValid() {
r.AddAttrs(
slog.String("trace_id", sc.TraceID().String()),
slog.String("span_id", sc.SpanID().String()),
)
}
return h.Handler.Handle(ctx, r)
}
func (h ContextHandler) WithAttrs(a []slog.Attr) slog.Handler {
return ContextHandler{h.Handler.WithAttrs(a)}
}
func (h ContextHandler) WithGroup(n string) slog.Handler {
return ContextHandler{h.Handler.WithGroup(n)}
}Here trace is go.opentelemetry.io/otel/trace. WithAttrs and WithGroup must return your wrapper, not the inner handler, or Logger.With silently drops the behavior. Middleware stores the ID in the context:
func requestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" {
id = uuid.NewString() // github.com/google/uuid
}
ctx := context.WithValue(r.Context(), ctxKey{}, id)
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}With a request ID set and a sampled span in context, the line looks like this:
{
"time": "2026-11-08T06:50:06.860530458Z",
"level": "INFO",
"msg": "inside span",
"request_id": "req-8f3a",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span_id": "00f067aa0ba902b7"
}The request ID is your correlation ID, and the trace ID is the trace ID that ties the line to a distributed trace. A call without a context, such as slog.Info, gets neither.
Redact Secrets
HandlerOptions.ReplaceAttr runs on every attribute before it is written, so it is the one place to mask sensitive keys:
func redact(groups []string, a slog.Attr) slog.Attr {
switch a.Key {
case "password", "token", "authorization":
return slog.String(a.Key, "***")
}
return a
}
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ReplaceAttr: redact})A token inside a user group is masked too, since the function also sees grouped attributes. For a type that should never print its contents, implement slog.LogValuer instead.
Common Mistakes
- Calling
slog.Infoinside a request handler. The context-less methods cannot see the request or trace ID. Use theContextvariants. - Unpaired arguments.
slog.Info("x", "orphan")logs"!BADKEY":"orphan". Prefer typedslog.Attrvalues in hot or important paths. - Building a new handler per request. Create one at startup and derive loggers with
With. - Logging errors as strings. Pass
"err", errso the field is consistent, and add the operation as its own field. - Setting the level with a plain
Leveland expecting to change it. A fixedLevelis fixed for the handler's lifetime. Use aLevelVar.
Production Tips
- Write JSON to stdout and let your platform collect it. See the JSON logging guide for field naming.
- Choose field names once, such as
request_id,trace_id, anduser_id, and reuse them in every service. The logging best practices guide has a checklist. - For the lowest allocation cost on hot paths, use
Logger.LogAttrs, which the docs describe as the most efficient form. - Do not log at INFO inside tight loops. Debug-level lines in loops are cheap to filter but still cost arguments.
Exporting slog Records With OpenTelemetry
The otelslog bridge is an slog.Handler that turns slog records into OpenTelemetry log records. The OTLP HTTP exporter picks up OTEL_EXPORTER_OTLP_ENDPOINT, so the code needs no endpoint:
exp, err := otlploghttp.New(ctx) // reads OTEL_EXPORTER_OTLP_ENDPOINT
if err != nil {
panic(err)
}
provider := sdklog.NewLoggerProvider(sdklog.WithProcessor(sdklog.NewBatchProcessor(exp)))
defer provider.Shutdown(ctx)
slog.SetDefault(otelslog.NewLogger("checkout", otelslog.WithLoggerProvider(provider)))Records logged with a context that holds an active span carry that span's trace context through the bridge. The packages are go.opentelemetry.io/contrib/bridges/otelslog, go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp, and go.opentelemetry.io/otel/sdk/log.
Sending Go Logs to Sazabi
Sazabi, an AI-native observability and security platform, accepts OTLP/HTTP logs and traces from any OpenTelemetry SDK. Per its OpenTelemetry source docs, you set OTEL_EXPORTER_OTLP_ENDPOINT to your intake URL and restart. The otlploghttp exporter in the previous section sends OTLP over HTTP and reads that variable, so no endpoint appears in the code. Logs and traces arrive as separate streams, so trace spans from the same service use the traces integration. Read the OpenTelemetry integration for the full list of options, and see crash loops for a signal that Go services produce when a panic restarts them.
Frequently Asked Questions
What is slog in Go?
log/slog is the standard library package for structured logging, added in Go 1.21. It logs a message plus key-value attributes and sends them to a handler that writes text or JSON.
How do I log JSON in Go?
Create a logger with slog.New(slog.NewJSONHandler(os.Stdout, nil)). Each call writes one JSON object with time, level, msg, and your attributes.
How do I change the log level at runtime in Go?
Set HandlerOptions.Level to a *slog.LevelVar and call its Set method. A LevelVar is safe to read and write from multiple goroutines.
How do I add a trace ID to slog output?
Wrap the handler, read the span context from the context.Context passed to Handle, and add trace_id and span_id attributes. Call the Context variants such as InfoContext so the handler receives it.
Sources
- NewJSONHandler output, HandlerOptions (Level, AddSource, ReplaceAttr), LevelVar, Logger.With, Group, context-taking methods such as InfoContext, SetDefault also redirects the log package, level values, and the !BADKEY key for unpaired arguments (accessed Nov 8, 2026)
- slog was added to the standard library in Go 1.21 to give structured logging a common framework that other logging packages can share (accessed Nov 8, 2026)
- SpanContextFromContext, SpanContext.IsValid, and TraceID and SpanID String methods (accessed Nov 8, 2026)
- otelslog provides an slog.Handler that converts slog records into OpenTelemetry log records (accessed Nov 8, 2026)
- The OTLP HTTP log exporter reads OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_LOGS_ENDPOINT (accessed Nov 8, 2026)
- Sazabi accepts OTLP/HTTP logs and traces from any OpenTelemetry SDK through OTEL_EXPORTER_OTLP_ENDPOINT (accessed Nov 8, 2026)