Structured Logging with log/slog

September 10, 2024

Structured Logging with log/slog
Structured Logging with log/slog

Go 1.21 added log/slog to the standard library. Structured logging without a dependency.

Estimated Reading Time : 8m

What’s wrong with log.Println

log.Printf("user %s logged in from %s", username, ip)

This produces a human-readable string. Parsing it programmatically (in a log aggregator, alerting system, or grep pipeline) means writing regex against an unstructured format that changes every time you edit the message.

Structured logging emits key-value pairs:

time=2024-09-10T14:30:00Z level=INFO msg="user logged in" user=alice ip=10.0.0.1

Every field is named and extractable. slog brings this to the standard library.

Basic usage

package main

import "log/slog"

func main() {
    slog.Info("user logged in",
        "user", "alice",
        "ip", "10.0.0.1",
    )
}

Output:

2024/09/10 14:30:00 INFO user logged in user=alice ip=10.0.0.1

Key-value pairs are passed as alternating arguments after the message. slog.Info, slog.Debug, slog.Warn, and slog.Error are the four log levels.

Handlers

A handler controls the output format. slog ships with two:

TextHandler

Human-readable key-value format:

logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
logger.Info("request", "method", "GET", "path", "/users", "status", 200)
time=2024-09-10T14:30:00.000Z level=INFO msg=request method=GET path=/users status=200

JSONHandler

Machine-readable JSON, which is what most log aggregators (Datadog, Loki, CloudWatch) expect:

logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("request", "method", "GET", "path", "/users", "status", 200)
{"time":"2024-09-10T14:30:00.000Z","level":"INFO","msg":"request","method":"GET","path":"/users","status":200}

Setting the default logger

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: slog.LevelDebug,
    }))
    slog.SetDefault(logger)

    // Now slog.Info(), slog.Debug(), etc. use the JSON handler
    slog.Info("server starting", "port", 8080)
}

slog.SetDefault also redirects the old log package’s output through the new handler, so third-party libraries using log.Println get structured output too.

Log levels

slog.Debug("cache miss", "key", cacheKey)     // -4
slog.Info("request handled", "status", 200)    //  0
slog.Warn("rate limit approaching", "pct", 85) //  4
slog.Error("query failed", "err", err)         //  8

Set the minimum level in HandlerOptions:

slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelWarn, // only Warn and Error
})

Typed attributes

Alternating key, value arguments work but aren’t type-checked. Use slog.Attr for explicit typing:

slog.LogAttrs(ctx, slog.LevelInfo, "request",
    slog.String("method", "GET"),
    slog.Int("status", 200),
    slog.Duration("latency", elapsed),
    slog.String("path", "/users"),
)

slog.String, slog.Int, slog.Duration, slog.Bool, slog.Time, slog.Any: each creates a typed attribute. This avoids the overhead of interface boxing and catches type mismatches at compile time.

Groups

Groups namespace related attributes:

slog.Info("request",
    slog.Group("req",
        slog.String("method", "GET"),
        slog.String("path", "/users"),
    ),
    slog.Group("resp",
        slog.Int("status", 200),
        slog.Duration("latency", 12*time.Millisecond),
    ),
)

JSON output:

{"time":"...","level":"INFO","msg":"request","req":{"method":"GET","path":"/users"},"resp":{"status":200,"latency":"12ms"}}

Logger with context

Attach common fields to a logger so you don’t repeat them on every call:

logger := slog.Default().With(
    "service", "api",
    "version", "1.4.2",
)

logger.Info("started")  // includes service=api version=1.4.2 automatically
logger.Info("request", "path", "/users")  // also includes service and version

With returns a new logger; the original is unchanged. This is useful for per-request loggers:

func handleRequest(w http.ResponseWriter, r *http.Request) {
    logger := slog.Default().With(
        "request_id", r.Header.Get("X-Request-ID"),
        "method", r.Method,
        "path", r.URL.Path,
    )

    logger.Info("request started")
    // ... handle request ...
    logger.Info("request completed", "status", statusCode)
}

Passing loggers through context

ctx := context.Background()

// Store logger in context
logger := slog.Default().With("request_id", "abc-123")
ctx = context.WithValue(ctx, loggerKey, logger)

// Retrieve later
slog.InfoContext(ctx, "processing", "step", "validation")

For a cleaner API, use a helper:

func LoggerFromContext(ctx context.Context) *slog.Logger {
    if logger, ok := ctx.Value(loggerKey).(*slog.Logger); ok {
        return logger
    }
    return slog.Default()
}

Migrating from other loggers

If you’re using logrus or zap, slog covers the same ground with a smaller API surface:

Feature logrus zap slog
Structured fields .WithField() .With() .With()
JSON output built-in built-in JSONHandler
Log levels 7 levels 5 levels 4 levels
Performance slowest fastest fast
Dependency external external standard library

slog is faster than logrus and slightly slower than zap in benchmarks. For most applications, the difference is negligible, and having no dependency is worth a lot.

The bottom line

Use slog.NewJSONHandler in production, slog.NewTextHandler in development. Attach context with With. Use slog.SetDefault once at startup and the rest of your application just calls slog.Info().