How to Implement Custom Middleware for Observability in Go

Implement Go observability middleware by wrapping your HTTP handler to log request methods, paths, and execution duration.

The blind spot in your server

You deployed your Go server. It handles requests. It returns JSON. Then a user reports a timeout on the /checkout endpoint. You check the logs and see nothing useful. You restart the server and the problem vanishes. You need a way to watch traffic as it flows through your application, capturing timing, errors, and context without touching every single handler.

Middleware solves this by wrapping your handlers. It lets you run code before a request reaches your logic and after it finishes, all in one place. Observability middleware uses this pattern to measure duration, log status codes, inject tracing IDs, and catch panics before they crash the server.

How middleware works

Middleware is a function that takes an http.Handler and returns a new http.Handler. The returned handler wraps the original one. When a request arrives, the middleware runs its pre-handling code, calls the wrapped handler, and then runs its post-handling code.

You can chain middleware. The request passes through the chain from outside to inside, gets processed by the route handler, and the response bubbles back up from inside to outside. This composition model keeps your route handlers focused on business logic while cross-cutting concerns like logging and metrics live in reusable wrappers.

Minimal logging middleware

Here's the simplest middleware: capture time, call next, log duration.

package main

import (
    "log"
    "net/http"
    "time"
)

// LoggingMiddleware wraps a handler to log method, path, and duration.
func LoggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now() // Capture start time before delegating.
        next.ServeHTTP(w, r) // Execute the next handler in the chain.
        // Log after the handler returns to capture full duration.
        log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
    })
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("Hello"))
    })

    // Wrap the mux with middleware before passing to ListenAndServe.
    http.ListenAndServe(":8080", LoggingMiddleware(mux))
}

The http.HandlerFunc adapter converts a function with the handler signature into an http.Handler. Without it, the compiler rejects the code with cannot use func literal (type func(http.ResponseWriter, *http.Request)) as type http.Handler in argument. The adapter implements the ServeHTTP method so the function satisfies the interface.

Walkthrough: what happens at runtime

When the server starts, LoggingMiddleware(mux) runs once. It returns a new handler that holds a reference to mux. Every incoming request hits the middleware first. The middleware records the start time, calls mux.ServeHTTP, and waits. The mux routes to the handler, the handler writes the response, and control returns to the middleware. The middleware calculates the elapsed time and prints the log line. The response is already on its way to the client.

If you chain multiple middleware, the outermost one runs first. It calls the next middleware, which calls the next, until the route handler executes. On the way back, each middleware runs its post-handling code in reverse order. This onion model lets you layer concerns: authentication runs before logging, logging runs before metrics, and metrics run before the route.

Capturing status codes

Real middleware needs the status code. The standard library hides it, so wrap the writer.

package main

import (
    "log"
    "net/http"
    "time"
)

// statusRecorder wraps http.ResponseWriter to capture the status code.
type statusRecorder struct {
    http.ResponseWriter
    statusCode int
}

// WriteHeader captures the status code before delegating to the underlying writer.
func (r *statusRecorder) WriteHeader(code int) {
    r.statusCode = code
    r.ResponseWriter.WriteHeader(code)
}

// Write captures the implicit 200 status if WriteHeader was never called.
func (r *statusRecorder) Write(b []byte) (int, error) {
    if r.statusCode == 0 {
        r.statusCode = http.StatusOK // Default to 200 if no header was set.
    }
    return r.ResponseWriter.Write(b)
}

// ObservabilityMiddleware logs method, path, status, and duration.
func ObservabilityMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        // Wrap the writer to intercept WriteHeader and Write calls.
        rec := &statusRecorder{ResponseWriter: w, statusCode: http.StatusOK}
        next.ServeHTTP(rec, r)
        // Log details after the handler completes.
        log.Printf("%s %s %d %v", r.Method, r.URL.Path, rec.statusCode, time.Since(start))
    })
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/error", func(w http.ResponseWriter, r *http.Request) {
        http.Error(w, "bad request", http.StatusBadRequest)
    })

    http.ListenAndServe(":8080", ObservabilityMiddleware(mux))
}

The statusRecorder embeds http.ResponseWriter. This promotes all methods automatically, so you only need to override WriteHeader and Write. The Write method handles a subtle quirk: if a handler calls Write without calling WriteHeader, the standard library implicitly sends a 200 status. The recorder checks statusCode == 0 to catch this case. The receiver name r matches the type statusRecorder, following Go convention for short, meaningful names.

Pitfalls and runtime behavior

Middleware runs on every request. Heavy allocation or blocking calls here degrade performance for the entire application. Avoid creating large buffers or starting goroutines that outlive the request. If you need background work, detach it with go and ensure it doesn't leak.

Panics in handlers propagate up through the middleware chain. If a handler panics, the middleware's post-handling code never runs. Your log line is skipped, and the server's default recovery handler kicks in. Add defer recover in middleware to catch panics, log them, and return a 500 response.

// PanicRecoveryMiddleware catches panics and returns a 500 response.
func PanicRecoveryMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                log.Printf("panic: %v", err)
                http.Error(w, "internal server error", http.StatusInternalServerError)
            }
        }() // Defer recovery to run after the handler returns or panics.
        next.ServeHTTP(w, r)
    })
}

Context cancellation is another trap. If a client disconnects, the request context is cancelled. Middleware should respect this. If you're logging asynchronously, check r.Context().Done() before sending logs to avoid blocking on a dead channel. Functions that take a context should always put it as the first parameter, conventionally named ctx. Middleware often injects context values like request IDs or structured loggers. Use context.WithValue sparingly and define custom key types to avoid collisions.

The compiler enforces strict typing. If you pass a handler with the wrong signature, you get cannot use x as type http.Handler in argument. If you forget to import a package, you get undefined: pkg. If you import a package but don't use it, you get imported and not used. Go's error messages are plain text and descriptive. Read them carefully; they usually point directly to the issue.

Decision matrix

Use a custom middleware function when you need to intercept requests for logging, metrics, or tracing across multiple endpoints. Use http.HandlerFunc to convert a function literal into a handler when you are writing quick route handlers or wrapping logic inline. Use a wrapper struct for http.ResponseWriter when you need to capture the status code or buffer the response body for validation. Use a third-party router like chi or gin when you need built-in middleware chaining, context management, and structured logging without writing boilerplate. Use plain sequential code when you only need to log a single endpoint: adding middleware adds indirection that isn't worth the cost for one-off cases.

Where to go next

Middleware is a chain. Break the chain and the request dies. Capture the status code or you're flying blind. Trust gofmt to format your code; argue about logic, not indentation.