Go cookbook · Intermediate

Write HTTP middleware for logging, recovery and request IDs

Every handler repeats logging and panics crash the request without a useful log.

Create a free account to save this page, take notes and track your progress. Sign in

Approach

Middleware is func(http.Handler) http.Handler. Wrap the response writer to capture the status, put a request ID in the context, recover panics into 500s, and log one structured line per request.

Solution

package middleware

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"log/slog"
	"net/http"
	"runtime/debug"
	"time"
)

type ctxKey struct{}

func RequestID(ctx context.Context) string {
	id, _ := ctx.Value(ctxKey{}).(string)
	return id
}

type statusRecorder struct {
	http.ResponseWriter
	status int
}

func (r *statusRecorder) WriteHeader(code int) {
	r.status = code
	r.ResponseWriter.WriteHeader(code)
}

func Chain(h http.Handler, mws ...func(http.Handler) http.Handler) http.Handler {
	for i := len(mws) - 1; i >= 0; i-- {
		h = mws[i](h)
	}
	return h
}

func WithRequestID(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id := r.Header.Get("X-Request-Id")
		if id == "" {
			b := make([]byte, 8)
			rand.Read(b)
			id = hex.EncodeToString(b)
		}
		w.Header().Set("X-Request-Id", id)
		next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, id)))
	})
}

func Logging(logger *slog.Logger) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			start := time.Now()
			rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
			next.ServeHTTP(rec, r)
			logger.Info("request", "method", r.Method, "path", r.URL.Path, "status", rec.status,
				"duration_ms", time.Since(start).Milliseconds(), "request_id", RequestID(r.Context()))
		})
	}
}

func Recover(logger *slog.Logger) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			defer func() {
				if v := recover(); v != nil {
					logger.Error("panic", "value", v, "stack", string(debug.Stack()), "request_id", RequestID(r.Context()))
					http.Error(w, "internal server error", http.StatusInternalServerError)
				}
			}()
			next.ServeHTTP(w, r)
		})
	}
}

// Usage: handler := Chain(mux, WithRequestID, Recover(logger), Logging(logger))

Pitfalls

  • Using string keys for context values (collisions); use an unexported key type.
  • Wrapping the ResponseWriter and losing optional interfaces (http.Flusher); use http.ResponseController in handlers or implement Unwrap().
  • Recovering panics but not logging the stack.

Variations

  • Put the request ID into a logger stored in the context so all logs for a request share it.
  • Add OpenTelemetry's otelhttp.NewHandler for traces and metrics.

Further reading: http.Handler

Reference: https://pkg.go.dev/net/http#Handler

More Go recipes

Was this page helpful?