Week 13: Error Handling at Scale, Logging & Observability

Week 2 covered Go's basic error-handling idiom; Week 12 leaned on errors.Is without fully explaining it. This week closes the loop — what idiomatic error handling looks like once a service has real operational stakes, plus the logging and observability hooks that let you actually find out what went wrong in production.

Module 13 of 16 Week 13 of 16 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Wrap an error with additional context using %w, preserving the original
  • Unwrap and inspect a wrapped error chain with errors.Is and errors.As
  • Replace ad hoc log.Println calls with structured logging via slog
  • Add basic metrics and tracing hooks to a service

1. Wrapping Errors with %w

fmt.Errorf with the %w verb wraps an existing error inside a new one — adding context ("what was happening when this failed") while preserving the original error so it can still be inspected later.

wrapping as an error travels up the call stack
func GetTask(db *sql.DB, id int) (*Task, error) {
    // ... query, then:
    if errors.Is(err, sql.ErrNoRows) {
        return nil, fmt.Errorf("task %d: %w", id, ErrNotFound)
    }
    // ...
}

func handleGetTask(w http.ResponseWriter, r *http.Request) {
    task, err := GetTask(db, id)
    if err != nil {
        if errors.Is(err, ErrNotFound) {
            writeError(w, http.StatusNotFound, "task not found")
            return
        }
        writeError(w, http.StatusInternalServerError, "internal error")
    }
}

Each layer adds its own context ("task %d: %w") without destroying the original ErrNotFound underneath — a plain fmt.Errorf("task %d: %v", id, err) (with %v instead of %w) would format the same message but lose the ability to programmatically check what kind of error it originally was.

2. errors.Is & errors.As

errors.Is — "is this error (or one it wraps) exactly this sentinel?"
var ErrNotFound = errors.New("not found")

if errors.Is(err, ErrNotFound) {
    // true even if err is a wrapped chain several layers deep,
    // as long as ErrNotFound is somewhere in that chain
}
errors.As — "is this error (or one it wraps) of this type, and give me it"
type ValidationError struct {
    Field string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("invalid field: %s", e.Field)
}

var valErr *ValidationError
if errors.As(err, &valErr) {
    fmt.Println("failed field:", valErr.Field) // unwrapped the concrete type back out
}

Use errors.Is when checking against a known sentinel value (like ErrNotFound); use errors.As when you need the concrete error type back to read fields off it. Both walk the entire wrapped chain automatically — created by every %w along the way — so the check works no matter how many layers of context were added since the original error.

3. Structured Logging with slog

log.Println("user", id, "not found") produces text a human can read but a machine has to parse with regex. slog (standard library since Go 1.21) logs structured key-value data instead, in a format tools can actually query.

structured logging
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

logger.Info("task created", "task_id", task.ID, "user_id", userID)
logger.Error("failed to save task", "error", err, "task_id", task.ID)
output — one JSON object per line
{"time":"2026-01-15T10:30:00Z","level":"INFO","msg":"task created","task_id":42,"user_id":7}

That JSON-lines format is directly queryable by any log aggregation tool — "show me every error where task_id is 42" is a real, fast query against structured logs, and effectively unanswerable against a pile of free-text log lines at any real production scale.

4. Basic Metrics & Tracing Hooks

Logs answer "what happened." Metrics answer "how often, how fast, in aggregate" — and a tiny bit of instrumentation goes a long way.

a request-timing middleware, extending Week 11's logging middleware
var requestDuration = prometheus.NewHistogramVec(
    prometheus.HistogramOpts{Name: "http_request_duration_seconds"},
    []string{"path", "status"},
)

func metricsMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        rec := &statusRecorder{ResponseWriter: w, status: 200}
        next.ServeHTTP(rec, r)
        requestDuration.
            WithLabelValues(r.URL.Path, strconv.Itoa(rec.status)).
            Observe(time.Since(start).Seconds())
    })
}

This is deliberately the same middleware pattern from Week 11 — metrics collection is just another cross-cutting concern, composed the same way logging and recovery were. A full tracing setup (correlating a request across multiple services) is a deeper topic than this course covers, but the entry point is the same: context (Week 7) carrying a trace ID through the same call chain that already carries cancellation.

5. Hands-on Exercise

Hands-on

Instrument the task API for production

Replace ad hoc error and log handling in last week's API with this week's patterns.

Requirements:

  1. Wrap every error crossing a function boundary in your Store implementation with %w and meaningful context, rather than returning it bare.
  2. Replace every log.Println/fmt.Println in the API with structured slog calls, including relevant fields (request path, task ID, error) on each.
  3. Use errors.Is in your HTTP handlers to distinguish a not-found error from a generic internal error, confirmed still working correctly through the wrapped chain from the store layer.
  4. Add a metrics middleware (Prometheus client library or even a simple in-memory counter is fine) recording request count and duration per route.
  5. Deliberately trigger a wrapped not-found error and confirm, by reading the log output, that both the original sentinel and the added context are visible.
Hint

If errors.Is(err, ErrNotFound) returns false even though you're sure ErrNotFound is somewhere in the chain, check every wrap along the path used %w and not %v — a single %v anywhere in the chain breaks the link, since it formats the error into a plain string and discards the ability to unwrap past that point entirely.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

What is the practical difference between wrapping an error with %w versus formatting it with %v?

%w preserves a reference to the original error inside the new one, so errors.Is/errors.As can still find and inspect it later, potentially through several more layers of wrapping. %v only formats the error's message into a new string — the result looks identical when printed, but the original error value itself is gone, and any later errors.Is check against it will fail.

Q2

When should you reach for errors.As instead of errors.Is?

errors.Is answers a yes/no question against a known sentinel value ("is this a not-found error"). errors.As is for when you need the actual concrete error value back — to read a field off a custom error type like ValidationError.Field — not just confirm its presence.

Q3

Why is a structured log line (JSON with named fields) more useful in production than an equivalent free-text log message?

A structured field like task_id is directly and reliably queryable by log aggregation tooling — "every error for this task_id" is a precise query. A free-text message requires the same information to be extracted with regex or string parsing, which is fragile against even small changes in message wording and much slower to query at real production log volumes.

Q4

Why does the metrics middleware in this week's example follow the exact same shape as Week 11's logging and recovery middleware?

Metrics collection is another cross-cutting concern that needs to run around every request regardless of which specific handler serves it — exactly the same requirement logging and panic recovery had. Reusing the middleware pattern means all three concerns compose cleanly in the same chain, rather than each needing its own bespoke wiring.