Error mapping and structured logging
One place turns domain errors into HTTP statuses, and your logs become searchable.
- Build one error boundary that maps domain errors to HTTP statuses.
- Write structured logs with
log/slogthat 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
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 }}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.
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,)Request IDs tie a failure together
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)) })}Logging rules
- Never log passwords, tokens, whole
Authorizationheaders, 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.
Errormeans a human should look. If everything isError, nothing is. - A cancelled request is not a 500.
context.Canceledmeans the client left. Do not page anyone for it.
Health checks: two, not one
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"})})- 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/slogwith key–value pairs. JSON in production, text in development.- Liveness and readiness are different checks. Do not merge them.
- 1Force a database error and confirm the client sees a generic 500 while the log has the detail and a request id.
- 2Add the request id to the response header, then find that one request in your logs by grepping the id.
- 3Kill a request mid-flight with Ctrl-C in curl and confirm it does not log as a 500.
- 4Switch the handler from Text to JSON and diff how readable each is for a human and for
jq.