Structured Logging with slog
Part 3 built request-logging middleware that wraps every handler and records
what happened on the way through. That lesson focused on the middleware
shape — wrap, call next, act around it. This lesson focuses on what
actually goes into the log line, because "what goes into the log line"
turns out to matter as much as whether one gets written at all.
Why formatted strings don't scale
The obvious way to log a request is something like
log.Printf("%s %s %d", method, path, status). That's fine for a single
developer tailing output on a laptop. It falls apart the moment logs need to
be searched, filtered, or alerted on at scale. "Find every request to
/tasks that returned 5xx in the last hour" means writing a regex against a
free-form string, hoping nobody logs a path or error message containing a
space that shifts the columns. Formatted strings are for humans reading top
to bottom; production log pipelines need fields they can query directly.
slog.Logger and the key/value form
log/slog, part of the standard library since Go 1.21, solves this by
making every log call structured from the start. Instead of building a
string, you call a level method with a message and a flat list of
alternating keys and values:
logger.Info("request", "method", method, "path", path, "status", status)
The first argument is the message — a short, constant description of the
event, not a place to interpolate data. Everything after it is pairs:
"method", method, "path", path, "status", status. Each pair
becomes its own field in the output record. The message stays constant
across every request; the fields carry what varies. That constancy is what
makes the message itself useful as a grouping key — "show me all request
events" is a query you can actually write.
Handlers: where the formatting lives
A slog.Logger doesn't decide how records are rendered — a slog.Handler
does. slog.NewJSONHandler writes each record as a JSON object, one line
per event, which is what most log aggregation tools expect in production.
slog.NewTextHandler writes a more human-readable key=value line, handy
for local development where you're reading logs directly in a terminal. The
call to logger.Info doesn't change between them — only the handler you
constructed the logger with changes the output format. That's the whole
point of the separation: application code emits structured data, and the
handler decides how that data looks on the wire. Tests can take advantage of
the same separation by attaching a JSONHandler over a buffer and parsing
the result as JSON, rather than pattern-matching on formatted text.
Levels
slog has four levels — Debug, Info, Warn, Error — each a method on
*slog.Logger. A completed request is typically Info; something recoverable
but worth flagging (a slow query, a retried call) is Warn; something that
prevented the request from completing is Error. Choosing consistently
matters more than the exact boundary: alerting rules key off level, so a
handler that logs routine 200s at Error will drown out the errors that
actually need attention.
Wiring it in
slog.Default() returns the package-level logger used when you call
slog.Info directly instead of through a *slog.Logger value — convenient
for quick scripts, less useful once a service needs a consistently
configured handler everywhere. The natural next step for this lesson's
logRequest function is exactly where Part 3's logging middleware already
sits: instead of building an ad hoc string per request, that middleware
would hold a *slog.Logger and call a function shaped like this one after
next.ServeHTTP returns, so every request produces one structured record
with the fields a real log pipeline can act on.
Practice
1. Structured Logging with slog
IntermediateFormatted-string logs (
log.Printf("%s %s %d", ...)) are a pain to search and parse.log/slogwrites structured records — each field is its own key/value — so a log pipeline can filter onstatus>=500without regexes.Implement
logRequest(logger *slog.Logger, method, path string, status int): log at Info level with the message"request"and three attributes —method,path,status— using slog's key, value, key, value form:logger.Info("request", "method", method, "path", path, "status", status)The hidden test attaches a JSON handler and parses your output, checking each field landed as real structured data (not a formatted blob).
package main import "log/slog" func logRequest(logger *slog.Logger, method, path string, status int) { // TODO: call logger.Info with message "request" and key/value attrs // method, path, status. } func main() {}Sign in to write and run your solution in the browser.
Sign in for hints and the solution.