Skip to content
intermediate Phase 15 · CLI Tools, Logging, and Profiling

Structured Logging with log/slog

Emit key-value log records as text or JSON with leveled filtering.

40m
0 problems
Topic Progress 0%

The Incident Where Grep Found Nothing

The alert fires at 02:14. You grep the service logs for the failing order id and find exactly one line: 2026/03/11 02:14:07 something went wrong with order. The message was written with log.Printf by a developer who knew the shape of the problem at the time. Six months and four rewordings later, the string nobody can predict is the one thing grep needs. Meanwhile the JSON pipeline the platform team shipped parses level, timestamp, and service automatically — and ignores the free-text blob where your only context lives.

log/slog, in the standard library since Go 1.21, fixes the mismatch by making every log record structured: a message plus an ordered list of key-value attributes, rendered as text for humans or JSON for machines.

package main

import (
	"log/slog"
	"net/http"
	"os"
	"time"
)

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

	slog.Info("server listening", "addr", ":8080")
	http.ListenAndServe(":8080", mux())
}

func logRequest(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		next.ServeHTTP(w, r)
		slog.Info("request",
			"method", r.Method,
			"path", r.URL.Path,
			"duration", time.Since(start),
		)
	})
}

Output from the JSON handler, one line per record:

{"time":"2026-03-11T02:14:07.113Z","level":"INFO","msg":"request","method":"GET","path":"/orders/8821","duration":1.842}

Contrast the queries each form supports. The log version answers "does the text contain 8821?" — yes if the developer interpolated it, no if the id was added at a different layer. The slog version answers path="/orders/8821" as a first-class field with a stable key, which is what a log backend indexes and what a dashboard filters on.

The API surface is small on purpose. slog.Info/Warn/Error/Debug take a message and variadic any pairs; odd trailing pairs are rendered with a !BADKEY marker rather than panicking, so a mismatched pair is visible in the output instead of corrupting it. Durations, errors, and times have dedicated helpers (slog.Duration, slog.Any) when you want type control. Levels filter in the handler, not the call site: swap Level: slog.LevelInfo for a slog.LevelVar you can flip at runtime, and every Debug call costs nothing but a level check.

The discipline that matters more than the API: one record describes one event with everything an operator needs attached as fields. The failure modes — logging and returning the same error, unbounded values in attributes — are covered in The error Interface and Error Messages and the last chapter here.

Attributes That Stay Stable: With, Groups, and Level Discipline

A handler logs the request id, tenant, and route on every line. The first implementation repeats them:

slog.Info("cache miss", "req", rid, "tenant", tid, "route", route, "key", key)
slog.Warn("slow query", "req", rid, "tenant", tid, "route", route, "ms", ms)

Every call site carries the same three pairs, and they drift: one branch writes tenant, another writes tenant_id, and the log query that filters on tenant misses half the data. Key stability is the entire value of structured logging — a key that changes meaning or spelling is worse than prose, because prose at least reads naturally.

slog.Logger.With copies the logger and appends attributes once. The middleware pattern makes it structural:

func withRequestFields(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		l := slog.With(
			"request_id", r.Header.Get("X-Request-Id"),
			"route", routeTemplate(r.URL.Path),
		)
		r = r.WithContext(context.WithValue(r.Context(), loggerKey{}, l))
		next.ServeHTTP(w, r)
	})
}

// deeper in the call stack
func (s *Service) load(ctx context.Context, id int64) (Order, error) {
	l := loggerFrom(ctx)
	l.Info("loading order", "order_id", id)
	o, err := s.repo.Get(ctx, id)
	if err != nil {
		return Order{}, fmt.Errorf("load order %d: %w", id, err) // returned, not logged
	}
	return o, nil
}

A logger travels through the context and every line it emits carries the correlation fields without the business code naming them. Two rules keep this from rotting: attributes describe context (who, which request, which route) while the message describes the event, and attribute values must be bounded — order_id is a finite id the backend can index, but a raw SQL string, a full request body, or an unbounded user input turns every distinct value into a new index entry and leaks data into the bargain.

When several keys belong to one concept, WithGroup nests them:

logger := slog.WithGroup("db").With("host", dbHost)
logger.Info("connected", "latency", d) // {"db":{"host":"...","latency":...}}

Groups give the query layer a namespace — db.latency rather than a flat key that some other subsystem might later collide with. Use them for multi-field domains (db, http, build), not as decoration.

Levels need a policy, because a logger without one produces two uniform failures. Everything at Info becomes noise that trains operators to ignore the stream; everything at Debug means production cannot answer basic questions. A workable default: Info for state changes and request summaries, Warn for recoverable anomalies (retry succeeded, cache degraded), Error for failures that reach a boundary and affect a user, Debug for developer detail behind a flag. And keep the standard log package on the same stream: slog.SetDefault also routes log.Print and friends through the default handler, so library calls using the standard logger land in your JSON output — and for dependencies that accept a *log.Logger, slog.NewLogLogger(slog.Default().Handler(), slog.LevelInfo) builds one that writes through your handler instead of stdout.

Log Once, at the Boundary: Where Records Should Be Born

Three layers each contain this snippet:

if err != nil {
	log.Printf("failed to load order %d: %v", id, err)
	return err
}

The incident timeline now shows the same failure five times: repo: connection refused, then service: load order: connection refused, then handler: load order: connection refused, then the top-level http: 500 .... An operator grepping for the order id finds a wall of overlapping lines and no single authoritative record with the full stack of context. Double reporting is the most common structural bug in Go logging, and slog makes the right shape easy.

The rule: a layer either handles the error or passes it up — logging happens exactly once, where the chain ends. Return errors with wrapped context (the %w discipline from The error Interface and Error Messages) and log at the process boundary.

// deep layer: no logging, just context
func (r *OrderRepo) Get(ctx context.Context, id int64) (Order, error) {
	var o Order
	if err := r.db.GetContext(ctx, &o, q, id); err != nil {
		return Order{}, fmt.Errorf("repo get order %d: %w", id, err)
	}
	return o, nil
}

// boundary: one record, full context, then the response
func (s *Server) handleOrder(w http.ResponseWriter, r *http.Request) {
	id := pathID(r)
	o, err := s.svc.Load(r.Context(), id)
	if err != nil {
		slog.Error("request failed",
			"path", r.URL.Path,
			"order_id", id,
			slog.Any("err", err),
		)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	writeJSON(w, o)
}

One record, every correlating field, the error chain intact. If the boundary is a background job rather than an HTTP handler, the job's completion log plays the same role — the place where the error stops moving is the place it gets recorded.

Two more boundary decisions complete the picture. Sampling at the edge: a path logging one line per request at 50k rps writes 4 GB a day of mostly identical records; the request summary belongs in a metric (see Metrics, Logs, and Traces) and the per-request log should be reserved for failures or sampled. Redaction before emission: tokens, authorization headers, and body fields never enter the attribute list. slog gives you no automatic scrubbing — the call site decides — so treat the attribute list as data you are publishing, because after ingestion that is exactly what it is.

The final test of a logging design is an incident you have never seen. Can an operator, holding only the log stream, reconstruct which request failed, which upstream it called, and what the system decided? If each record carries stable keys for context and the chain logs once at the boundary, the answer is yes — and none of it depends on anyone remembering a magic grep string.

Quiz

1. An HTTP middleware logs request context, and deeper code logs errors as they occur. What is the structural problem the guidelines warn about?

Question 1 options

2. Which attribute set is safe to put on every request log line?

Question 2 options

3. You want per-request Debug detail in staging but only Info in production, without touching call sites. Where do you change it?

Question 3 options

4. A service writes one Info line per request at 50k rps. What is the recommended first change?

Question 4 options

Flashcards

Question

What is the shape of an slog record, and how does that change what you can query later?

Answer

A timestamp, level, message, and an ordered list of key-value attributes. Because keys are stable and typed, backends index them: filter all errors for a tenant with `tenant="acme"` instead of grepping prose whose wording changes with every rewording.

Question

When do you use logger.With and logger.WithGroup instead of passing fields at each call?

Answer

With attaches context that applies to every subsequent record from that logger — request id, tenant, route — so business code never repeats them and they cannot drift between call sites. WithGroup namespaces related keys (db.*, http.*) to prevent collisions and make queries read naturally.

Question

Where does level filtering happen in slog, and why does that matter for Debug calls in production?

Answer

In the handler, via HandlerOptions.Level (or a LevelVar you mutate at runtime), before any formatting. Filtered calls are nearly free, so leaving slog.Debug statements in production code costs one level check rather than formatting and I/O — verbosity becomes a configuration decision.

Question

What is the correct division of labor between an error and a log record in a three-layer service?

Answer

Each deep layer wraps the error with %w and returns it without logging; the boundary — HTTP handler, job runner, or main — logs exactly one record with full context and decides the response. This gives the operator a single authoritative line instead of the same failure repeated with weaker context.

Question

Why might a request-summary log line be the wrong home for request volume and latency?

Answer

At high throughput, one line per request costs gigabytes per day while carrying only count, rate, and distribution — data metrics store and query far more cheaply. Keep logs for failures and sampled events; answer 'how fast and how many' with histograms and counters.

Revision Notes

Key Takeaways

  • 1. Structured records are queryable because keys are stable — prose that changes with each rewording cannot be indexed
  • 2. Attach per-request context once with With; repeat it nowhere, or the keys drift and queries split
  • 3. Level filtering lives in the handler, making Debug calls cheap enough to leave in production code
  • 4. Log exactly once, at the boundary where the error chain stops; deep layers only wrap and return
  • 5. Attributes are published data: bounded values, no secrets, and high-volume summaries belong in metrics

Interview Tips

  • • "How do you log in a Go service?" — name slog, JSON handler in production, With for request context, and the log-once-at-the-boundary rule as the design principle
  • • "A service logs the same error five times. What's wrong and what's the fix?" — double reporting; wrap with %w while returning, emit one record at the handler with correlated fields
  • • "How would you add request tracing to logs?" — generate or propagate a request id, put a With-bound logger in the context, and reference the trace id field that ties lines together
  • • "What makes a log attribute a bad idea?" — unbounded cardinality (raw URLs, user input) and secrets; give the route-template-over-raw-path example unprompted

Cheat Sheet

Structured Logging with log/slog

Handler choice:

Audience Handler
Local dev, human reading stdout slog.NewTextHandler(os.Stderr, nil)
Production pipeline slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: ...})

Attribute rules: context fields (request_id, route, tenant) attached once with With; the message states the event; values bounded and non-sensitive — no bodies, tokens, or raw SQL.

Level policy: Info = state changes and request summaries; Warn = recoverable anomaly; Error = failure reaching a boundary; Debug = dev detail behind a LevelVar.

Log-once discipline: deep layers wrap with %w and return silently; the boundary (handler / job / main) emits the single record.

Code template:

func handle(w http.ResponseWriter, r *http.Request) {
	start := time.Now()
	id := pathID(r)
	o, err := svc.Load(r.Context(), id)
	if err != nil {
		slog.Error("request failed", "path", r.URL.Path, "order_id", id, slog.Any("err", err))
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	writeJSON(w, o)
	slog.Info("request ok", "path", r.URL.Path, "order_id", id, "dur", time.Since(start))
}

Correlation: carry slog.With(...) through context.WithValue so every line from one request shares keys.

Continue Learning

Before You Start

  • No prerequisites listed.

Related Reading