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.
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); usehttp.ResponseControllerin handlers or implementUnwrap(). - 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.NewHandlerfor traces and metrics.
Further reading: http.Handler
Reference: https://pkg.go.dev/net/http#Handler
More Go recipes
- Benchmark code with testing.B.Loop
- Build a small, secure container image for a Go service
- Decode and validate JSON request bodies safely
- Embed static files and templates in the binary
- Load and validate configuration from environment variables
- Log structured JSON with slog and redact secrets
- Process jobs concurrently with a bounded worker pool
- Profile a running service with pprof