Go

Write a logging middleware that records the response status code. What are the pitfalls of wrapping http.ResponseWriter?

Question 445HardGo 1.22 to 1.25

Middleware is just func(http.Handler) http.Handler. To capture the status you wrap the ResponseWriter. The pitfall: the concrete writer implements optional interfaces (http.Flusher, http.Hijacker, io.ReaderFrom), and a naive wrapper hides them, breaking SSE streaming or WebSockets. Since Go 1.20, provide Unwrap() so http.ResponseController can reach the underlying writer.

type statusRecorder struct {
	http.ResponseWriter
	status int
	bytes  int
}

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

func (s *statusRecorder) Write(b []byte) (int, error) {
	if s.status == 0 {
		s.status = http.StatusOK // implicit 200 on first Write
	}
	n, err := s.ResponseWriter.Write(b)
	s.bytes += n
	return n, err
}

func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter }

func Logging(log *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}
			next.ServeHTTP(rec, r)
			log.Info("request", "method", r.Method, "path", r.URL.Path,
				"status", rec.status, "bytes", rec.bytes, "dur", time.Since(start))
		})
	}
}

// Chain: handler := Logging(log)(Recover(Auth(mux)))

Order matters: the outermost middleware runs first. Put panic recovery near the outside, and remember a handler that writes nothing yields status 0 in the recorder (treat it as 200).

More on Standard Library, HTTP & Systems Design in Go

All 35 Standard Library, HTTP & Systems Design in Go questions