Dev Skills

Error mapping and structured logging

One place turns domain errors into HTTP statuses, and your logs become searchable.

Topic 82 of 98 · 5 min to read · 50 min to build
After this topic you can
  • Build one error boundary that maps domain errors to HTTP statuses.
  • Write structured logs with log/slog that include a request ID.
  • Explain the difference between liveness and readiness health checks.

Two jobs that are easy to confuse

When something goes wrong, two different audiences need two different things. The client needs a status code and a short, safe message. You need the full detail, so you can find out what happened.

Mixing them up hurts both ways. Returning the internal error to the client leaks your table names, your file paths, and sometimes your data. Logging nothing means you cannot debug it at all.

Structured logging is the second half. A log line written as a sentence is hard to filter. A log line written as key-and-value pairs can be filtered by status=500, counted and alerted on.

One error boundary for the whole API

internal/httpx/errors.go
func FromError(w http.ResponseWriter, r *http.Request, err error) {
var ve *ValidationError
switch {
case errors.Is(err, context.Canceled):
return // client went away; nothing to send
case errors.Is(err, ErrNotFound):
Error(w, 404, "not found")
case errors.Is(err, ErrForbidden):
Error(w, 404, "not found") // hide existence
case errors.Is(err, ErrEmailTaken):
Error(w, 409, "email already registered")
case errors.Is(err, ErrInvalidCredentials):
Error(w, 401, "invalid credentials")
case errors.Is(err, ErrValidation):
Error(w, 422, err.Error())
case errors.As(err, &ve):
JSON(w, 422, map[string]string{ve.Field: ve.Msg})
default:
// the detail goes to the log, with the request id
slog.ErrorContext(r.Context(), "unhandled error", "err", err)
Error(w, 500, "internal server error") // the client learns nothing
}
}
Watch out: Never return an internal error message to a client. A raw SQL error leaks your table names and column names, and a stack trace leaks your source paths. Log the detail with a request id, return a generic message and a status.

Structured logging with log/slog

log/slog has been in the standard library since Go 1.21. For most services it removes the need for a logging dependency.

internal/logging/logging.go
func Setup(env string) {
var h slog.Handler
if env == "production" {
h = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})
} else {
h = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})
}
slog.SetDefault(slog.New(h))
}
slog.Info("request completed",
"method", r.Method,
"path", r.URL.Path,
"status", status,
"duration_ms", ms,
"request_id", rid,
)
JSON in production because machines read it; text in development because you do.

Request IDs tie a failure together

internal/middleware/requestid.go
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" {
var b [8]byte
rand.Read(b[:])
id = hex.EncodeToString(b[:])
}
w.Header().Set("X-Request-ID", id)
// a logger carrying the id, put into the context
ctx := context.WithValue(r.Context(), loggerKey{}, slog.Default().With("request_id", id))
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Now every line from one request shares an id, and the client got it in the response header. "It failed at 2pm" becomes one grep.

Logging rules

  • Never log passwords, tokens, whole Authorization headers, card numbers, or full request bodies. Anyone who can read the logs can then read those too.
  • Handle an error once. Log it or return it, not both, or one failure appears five times at five layers.
  • Log at the boundary, where you decide the HTTP status. That is where you have the request id and the wrapped error chain.
  • Levels mean something. Error means a human should look. If everything is Error, nothing is.
  • A cancelled request is not a 500. context.Canceled means the client left. Do not page anyone for it.

Health checks: two, not one

health.go
mux.HandleFunc("GET /livez", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200) // is the process wedged? restart me if so
})
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
httpx.Error(w, 503, "database unavailable") // stop sending me traffic
return
}
httpx.JSON(w, 200, map[string]string{"status": "ok"})
})
Keep them separate. Wiring a DB ping into liveness means a brief database blip restarts every instance at once.
The short version
  • One function maps domain errors to statuses. Handlers never choose a status themselves.
  • Internal detail goes to the log with a request id; the client gets a generic message.
  • log/slog with key–value pairs. JSON in production, text in development.
  • Liveness and readiness are different checks. Do not merge them.
Remember: The client gets a status and a safe message. You get the detail, the request id, and enough to find it in the logs.
Build this before moving on
Add the error boundary, slog setup, request-id middleware, and both health endpoints. Force a database error and confirm the client sees a generic 500 while the log has the full detail and the id.
Then try these in your own editor
  1. 1Force a database error and confirm the client sees a generic 500 while the log has the detail and a request id.
  2. 2Add the request id to the response header, then find that one request in your logs by grepping the id.
  3. 3Kill a request mid-flight with Ctrl-C in curl and confirm it does not log as a 500.
  4. 4Switch the handler from Text to JSON and diff how readable each is for a human and for jq.
Official documentation
Check yourselfTake the Production test