70. Structured logging with `log/slog`
The last lesson logged with fmt.Printf. That's fine for a lesson box and bad
for a service, because a line like
GET /items -> 404 in 31ms for user ada
can only be searched with a regex. When you have four million of them and need
"every 5xx for user ada in the last hour", you want fields, not prose.
Go 1.21 added log/slog to the standard library for exactly this.
Levels and key–value pairs
slog has a package-level logger you can call immediately:
package main
import "log/slog"
func main() {
slog.Info("server starting", "port", 8080, "env", "dev")
slog.Warn("slow request", "path", "/items", "ms", 412)
}
2026/08/12 02:21:19 INFO server starting port=8080 env=dev
2026/08/12 02:21:19 WARN slow request path=/items ms=412
(Your timestamp will differ — which is exactly why that box isn't runnable here. Every box below builds a logger with the timestamp switched off so the output is stable enough to print in a lesson.)
The call shape is one message plus alternating key, value, key, value. The
message stays constant and the varying parts become fields, which is the whole
idea: msg="slow request" path=/items groups every slow request together no
matter which path was slow.
Four levels, in order: Debug, Info, Warn, Error.
Choosing a handler
A Logger is a thin front end; a handler decides the format and
destination. Two ship with the standard library:
package main
import (
"log/slog"
"os"
)
// dropTime removes the timestamp so these lesson boxes print the same thing
// every run. Real services keep it.
func dropTime(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
}
func main() {
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ReplaceAttr: dropTime}))
log.Info("server starting", "port", 8080, "env", "dev")
log.Warn("slow request", "path", "/items", "ms", 412)
log.Error("upstream failed", "service", "billing", "attempt", 3)
}
level=INFO msg="server starting" port=8080 env=dev
level=WARN msg="slow request" path=/items ms=412
level=ERROR msg="upstream failed" service=billing attempt=3
TextHandler writes key=value pairs — readable in a terminal, still
machine-parseable. Values needing quotes get them automatically.
package main
import (
"log/slog"
"os"
)
func dropTime(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
}
func main() {
log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ReplaceAttr: dropTime}))
log.Info("request served", "method", "GET", "path", "/items", "status", 200)
log.Error("query failed", "table", "items", "err", "connection refused")
}
{"level":"INFO","msg":"request served","method":"GET","path":"/items","status":200}
{"level":"ERROR","msg":"query failed","table":"items","err":"connection refused"}
JSONHandler is what you deploy. Every log aggregator ingests JSON lines
without configuration, and status arrives as the number 200, not the string
"200" — so you can query for status >= 500 and get arithmetic rather than
string matching.
The usual arrangement: TextHandler locally, JSONHandler in production, and
one if at startup to choose. slog.SetDefault(log) makes your configured
logger the one the package-level slog.Info uses, so libraries logging through
slog land in the same stream.
ReplaceAttr is the general escape hatch, and it isn't only for lesson
boxes — it's how you redact. Return slog.Attr{} to drop an attribute
entirely, or rewrite the value in place to mask a password or an access token
before it ever reaches disk.
Levels filter
package main
import (
"log/slog"
"os"
)
func dropTime(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
}
func main() {
opts := &slog.HandlerOptions{Level: slog.LevelInfo, ReplaceAttr: dropTime}
log := slog.New(slog.NewTextHandler(os.Stdout, opts))
log.Debug("this is filtered out")
log.Info("this gets through")
verbose := slog.New(slog.NewTextHandler(os.Stdout,
&slog.HandlerOptions{Level: slog.LevelDebug, ReplaceAttr: dropTime}))
verbose.Debug("now debug shows", "detail", "everything")
}
level=INFO msg="this gets through"
level=DEBUG msg="now debug shows" detail=everything
HandlerOptions.Level sets the floor; anything below it is discarded. The
default floor is Info, which is why the first Debug produced nothing.
Filtering happens before the arguments are formatted, so a Debug call in a
hot path costs almost nothing when debug is off. Leave the debug logging in.
Request-scoped loggers
With returns a logger that carries fields on every subsequent call:
package main
import (
"log/slog"
"os"
)
func dropTime(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
}
func main() {
base := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ReplaceAttr: dropTime}))
reqLog := base.With("request_id", "abc123", "user", "ada")
reqLog.Info("handling request")
reqLog.Info("finished", slog.Group("timing", "db_ms", 12, "total_ms", 31))
}
level=INFO msg="handling request" request_id=abc123 user=ada
level=INFO msg=finished request_id=abc123 user=ada timing.db_ms=12 timing.total_ms=31
This is the single most useful thing in the package. Build a logger once per request with the request's ID, hand it down, and every line from that request is findable with one query — no threading an ID through six function signatures and forgetting it in the seventh.
slog.Group nests related fields; they come out dotted in text and as a nested
object in JSON.
Logging middleware, properly
Now the last lesson's middleware with a real logger:
package main
import (
"log/slog"
"net/http"
"net/http/httptest"
"os"
)
func dropTime(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
}
type statusWriter struct {
http.ResponseWriter
status int
}
func (s *statusWriter) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
func requestLogger(log *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(sw, r)
log.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", sw.status,
)
})
}
func main() {
log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ReplaceAttr: dropTime}))
mux := http.NewServeMux()
mux.HandleFunc("GET /items", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("[]"))
})
mux.HandleFunc("GET /boom", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "nope", http.StatusInternalServerError)
})
h := requestLogger(log, mux)
for _, p := range []string{"/items", "/boom", "/missing"} {
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", p, nil))
}
}
{"level":"INFO","msg":"request","method":"GET","path":"/items","status":200}
{"level":"INFO","msg":"request","method":"GET","path":"/boom","status":500}
{"level":"INFO","msg":"request","method":"GET","path":"/missing","status":404}
Note the third line: the mux's built-in 404 was logged too, because the
middleware wraps the mux rather than any individual handler.
requestLogger takes the logger as a parameter instead of reaching for a
global. That's the dependency-injection habit from module 5 — it means a test
can pass a logger writing to a bytes.Buffer and assert on what was logged.
A real one would add the duration (time.Since(start)), a request ID, and
r.RemoteAddr. Everything else stays exactly this shape.
What not to log
- Never log secrets. Passwords, tokens, keys, card numbers. Logs get copied
to places the database never goes.
ReplaceAttrcan enforce this centrally. - Log the error, not a paraphrase.
"err", errkeeps the wrapped chain from module 7;"err", "something failed"throws it away. - One line per event. Multi-line log entries defeat every line-oriented tool between you and the answer.
Your turn
Build a JSON logger, derive a request-scoped logger carrying order_id 1001,
then log an Info "order received" with items 3, and an Error "payment
declined" with reason insufficient_funds.
dropTime is given — pass it so the output is stable.
package main
import (
"log/slog"
"os"
)
func dropTime(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
}
func main() {
// build a JSON logger writing to os.Stdout, using dropTime
// derive a logger carrying order_id=1001
// Info "order received" with items=3
// Error "payment declined" with reason=insufficient_funds
}
package main
import (
"log/slog"
"os"
)
func dropTime(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
}
func main() {
base := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ReplaceAttr: dropTime}))
orderLog := base.With("order_id", 1001)
orderLog.Info("order received", "items", 3)
orderLog.Error("payment declined", "reason", "insufficient_funds")
}
Next: the server itself — and how to stop one without dropping requests.