Day 47 — log/slog
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/day47Copy 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/healthzPaste 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.