Day 47 — log/slog

Updated

July 30, 2026

Day 47 — log/slog

Stage V · ~3h
Goal: Replace ad-hoc fmt/log logging with log/slog—structured attributes, JSON/text handlers, levels, and request-scoped logging in your HTTP API.

Why this day exists

Unstructured logs (fmt.Printf("user %s failed %v", ...)) fight you in production:

  • Hard to query in log systems
  • Inconsistent field names
  • No levels / no sampling hooks
  • Request correlation becomes archaeology

log/slog (stdlib since 1.21) is the modern default: fast enough, structured, replaceable handlers. Day 47 makes it habit for every later HTTP day.


Theory 1 — Logger and records

import "log/slog"

slog.Info("server starting", "addr", addr)
slog.Error("db ping failed", "err", err)

Each call becomes a record: time, level, message, attributes. Handlers format and write records.

Default logger is convenient; prefer explicit loggers for libraries and tests:

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

Libraries should accept *slog.Logger (or an interface) rather than calling slog.Default() so tests can inject a buffer handler.

Levels

Level Typical use
Debug High-volume diagnostics
Info Normal ops events
Warn Recoverable issues
Error Failures needing attention
opts := &slog.HandlerOptions{Level: slog.LevelDebug}

Dynamic levels: custom LevelVar (hot reload of verbosity without restart).

var level slog.LevelVar
level.Set(slog.LevelInfo)
opts := &slog.HandlerOptions{Level: &level}
// later from admin/env: level.Set(slog.LevelDebug)

Theory 2 — Attributes and grouping

Prefer typed attributes when building shared helpers; keyvals are fine at call sites:

slog.Info("request",
    slog.String("method", r.Method),
    slog.String("path", r.URL.Path),
    slog.Int("status", status),
    slog.Duration("dur", time.Since(start)),
)

Or loosely typed keyvals: "method", r.Method (pairs must alternate; a missing value is a footgun).

With / groups

log := logger.With("service", "items", "env", "dev")
reqLog := log.With(slog.String("request_id", id))
reqLog.Info("handled")

logger.WithGroup("http").Info("done", "status", 200)
// JSON: {"http":{"status":200}, ...}

With creates a child logger that always emits the attached attrs—ideal for request-scoped fields.


Theory 3 — Handlers

Handler Output
NewTextHandler Human key=value
NewJSONHandler Machine JSON lines
Custom Handler Redaction, sampling, OpenTelemetry bridge
func newLogger(jsonOut bool, w io.Writer, level slog.Level) *slog.Logger {
    opts := &slog.HandlerOptions{
        Level:     level,
        AddSource: false, // true in dev if you want file:line
    }
    var h slog.Handler
    if jsonOut {
        h = slog.NewJSONHandler(w, opts)
    } else {
        h = slog.NewTextHandler(w, opts)
    }
    return slog.New(h)
}

ReplaceDefault only at process edge (main). Mid-request SetDefault races and confuses tests.

ReplaceAttr (redaction sketch)

opts := &slog.HandlerOptions{
    Level: slog.LevelInfo,
    ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
        if a.Key == "authorization" || a.Key == "password" {
            return slog.String(a.Key, "[REDACTED]")
        }
        return a
    },
}

Theory 4 — Errors and context

if err != nil {
    logger.Error("create item", "err", err)
}

slog will format error values usefully. For wrapped errors, %+v stack dumps are not slog’s job—log err plus stable codes separately. Keep client-facing codes out of the “mystery string only” trap (Day 55).

Context carriers (pattern)

stdlib does not force context logging, but you can:

type ctxKey struct{}

func WithLogger(ctx context.Context, l *slog.Logger) context.Context {
    return context.WithValue(ctx, ctxKey{}, l)
}

func FromContext(ctx context.Context) *slog.Logger {
    if l, ok := ctx.Value(ctxKey{}).(*slog.Logger); ok && l != nil {
        return l
    }
    return slog.Default()
}

Middleware injects a request-scoped logger with request_id. Handlers call FromContext(r.Context())—never invent a second correlation scheme mid-handler.


Theory 5 — What not to log

  • Passwords, tokens, session IDs (or truncate/hash)
  • Full request bodies with PII
  • High-cardinality unbounded strings as field keys (keys should be stable)
  • Entire config dumps containing secrets

Cardinality belongs in values carefully; metrics explode on unbounded label keys—same intuition for some log backends. Prefer stable keys: user_id, not dynamic key names.


Theory 6 — slog vs log vs fmt

API Role in 2026 services
fmt User-facing CLI output, one-off scripts
log Legacy; avoid new code
log/slog Default for services and libraries

Bridge old code with slog.NewLogLogger(handler, level) only when a dependency demands *log.Logger.


Worked example — HTTP access middleware

type statusWriter struct {
    http.ResponseWriter
    code int
}

func (w *statusWriter) WriteHeader(statusCode int) {
    w.code = statusCode
    w.ResponseWriter.WriteHeader(statusCode)
}

func SlogAccess(log *slog.Logger) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            start := time.Now()
            id := r.Header.Get("X-Request-ID")
            if id == "" {
                id = fmt.Sprintf("%d", start.UnixNano())
            }
            ww := &statusWriter{ResponseWriter: w, code: http.StatusOK}
            reqLog := log.With(
                "request_id", id,
                "method", r.Method,
                "path", r.URL.Path,
            )
            ctx := WithLogger(r.Context(), reqLog)
            w.Header().Set("X-Request-ID", id)
            next.ServeHTTP(ww, r.WithContext(ctx))
            reqLog.Info("request completed",
                "status", ww.code,
                "dur_ms", time.Since(start).Milliseconds(),
            )
        })
    }
}

Handlers:

func (s *Server) createItem(w http.ResponseWriter, r *http.Request) {
    log := FromContext(r.Context())
    // ... create ...
    log.Info("item created", "id", item.ID)
}

Worked example — main wiring (Go 1.26)

func main() {
    jsonLogs := flag.Bool("json-log", false, "emit JSON logs")
    levelStr := flag.String("log-level", "info", "debug|info|warn|error")
    flag.Parse()

    var level slog.Level
    if err := level.UnmarshalText([]byte(*levelStr)); err != nil {
        fmt.Fprintf(os.Stderr, "bad log-level: %v\n", err)
        os.Exit(2)
    }
    log := newLogger(*jsonLogs, os.Stdout, level)
    slog.SetDefault(log)

    mux := http.NewServeMux()
    // register routes...
    handler := SlogAccess(log)(mux)
    log.Info("listening", "addr", ":8080")
    log.Error("server exit", "err", http.ListenAndServe(":8080", handler))
}

Lab 1 — Wire slog into Day 45 shape

mkdir -p ~/lab/90daysofx/01-go/day47
cd ~/lab/90daysofx/01-go/day47
go mod init example.com/day47

Copy or reimplement a minimal API (health + one CRUD path). Flags:

-json-log
-log-level=debug|info|warn|error

Prove both text and JSON modes start cleanly.


Lab 2 — Prove structure

Run server with JSON logs; curl a few endpoints; show one log line is valid JSON with method, path, status, request_id.

go run ./cmd/itemsd -json-log=true 2>&1 | head
# other terminal
curl -s -H 'X-Request-ID: lab-1' localhost:8080/healthz

Paste one pretty-printed line into your journal.


Lab 3 — Test handler with slog

Capture logs in tests:

func TestAccessLog(t *testing.T) {
    var buf bytes.Buffer
    log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
    h := SlogAccess(log)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusNoContent)
    }))
    req := httptest.NewRequest(http.MethodGet, "/x", nil)
    rec := httptest.NewRecorder()
    h.ServeHTTP(rec, req)
    if !strings.Contains(buf.String(), `"status":204`) {
        t.Fatalf("logs=%s", buf.String())
    }
}

Lab 4 — Level filtering

With level=Info, log.Debug("x") must not appear. Toggle LevelVar in a test:

var lv slog.LevelVar
lv.Set(slog.LevelInfo)
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: &lv}))
log.Debug("hidden")
log.Info("visible")
// assert buf has visible, not hidden
lv.Set(slog.LevelDebug)
log.Debug("now visible")

Lab 5 — Error attribute consistency

Always use key "err" for errors (team convention). Document in README. Grep handlers for fmt.Println / log.Println and remove them. Add one intentional Error log when create fails validation or store error.


Common gotchas

Gotcha Fix
Logging huge structs Select fields
Unstable keys Fixed vocabulary
Default logger in libraries Accept *slog.Logger param
Secrets in logs Redact / careful fields
Using log and slog mixed Standardize on slog
Forgetting request association With + context
Odd-length keyvals Use typed attrs or lint carefully
SetDefault in tests without restore Inject logger; avoid global when testing

Checkpoint

  • JSON and text handlers switchable
  • Access log middleware emits structured fields
  • Request-scoped logger via context
  • Level filtering demonstrated
  • No secrets in log lines
  • Tests capture and assert log output

Commit

git add .
git commit -m "day47: slog structured logging in HTTP API"

Write three personal gotchas before continuing.


Tomorrow

Day 48 — templates & regexp: text/template vs html/template, compile-once regexp, and one real text-processing task.