Response Writer Interception for Usage Metering

Observability & Operations intermediate Building with AI ~25 min read

Overview

In Go’s net/http package, the http.ResponseWriter interface is the only channel through which handlers communicate back to the client. It provides three methods — Header(), WriteHeader(), and Write() — and by default, the server discards metadata about the response after it’s sent. The handler knows what it wrote, but middleware further up the chain has no way to inspect the status code, response size, or response content after the handler returns. This is a problem for observability and usage metering: if you want to log every response’s status code, count bytes transferred for billing, or extract result counts from JSON responses, you need to intercept the response writer.

Response writer interception wraps the standard http.ResponseWriter in a custom struct that records metadata as the handler writes its response. The wrapper captures the HTTP status code (set via WriteHeader), accumulates the byte count (from each Write call), and optionally extracts domain-specific metrics from the response body. After the handler returns, the middleware reads these captured values and feeds them into logging, metrics, or metering systems. In a search platform, this pattern enables transparent per-request usage tracking for billing — every search API response automatically records the result count, response size, and status code without any handler modifications.

This topic covers the decorator pattern for http.ResponseWriter, the subtleties of Go’s response writer interface (including http.Flusher, http.Hijacker, and http.Pusher), how to extract domain-specific metrics, and how to feed intercepted data into usage metering pipelines.

Core Concepts

The Decorator Pattern for http.ResponseWriter

Go’s http.ResponseWriter is an interface, not a concrete type — any struct that implements Header(), WriteHeader(int), and Write([]byte) qualifies. The decorator pattern wraps the original writer inside a new struct that delegates all calls to the original while recording metadata. The handler sees a standard http.ResponseWriter and behaves normally; the middleware sees the captured metadata after the handler returns. This is transparent interception — the handler doesn’t know (or care) that it’s being observed.

WriteHeader Capture & Default Status

In Go’s HTTP server, WriteHeader is called at most once per response. If the handler never calls WriteHeader explicitly, the first call to Write implicitly sends a 200 OK. The interceptor must handle both cases: capture the explicit status code if WriteHeader is called, and default to 200 if it’s never called. A common bug is checking the status code before Write has been called — it will be zero (the struct’s zero value), not 200. Use a flag to track whether WriteHeader was called.

Optional Interface Propagation

Go’s HTTP server checks whether the ResponseWriter implements optional interfaces: http.Flusher (for streaming responses), http.Hijacker (for WebSocket upgrades), and http.Pusher (for HTTP/2 server push). A naive wrapper that only implements the three core methods silently breaks streaming, WebSocket, and push functionality. The interceptor must conditionally propagate these optional interfaces by checking whether the underlying writer supports them and delegating accordingly.

Per-Request Usage Extraction

Beyond status codes and byte counts, usage metering often needs domain-specific metrics from the response body — the number of search results returned, the number of records in a paginated list, or the model name used for an AI-generated answer. These values are embedded in the JSON response body. The interceptor can buffer the response body (or a prefix of it), parse the relevant fields, and record them as usage dimensions — all without modifying the handler that produced the response.

Middleware Composition

Response writer interception is typically implemented as HTTP middleware — a function that wraps the next handler, injects the intercepting writer, calls the handler, then reads the captured metadata. This middleware composes with other middleware (authentication, rate limiting, CORS) in the standard chain. The interception middleware should be placed early in the chain (close to the outer handler) so it captures the final response, including any modifications made by inner middleware.

How It Works

The implementation has three layers: the response writer wrapper that captures metadata, the middleware that installs the wrapper and reads results, and the metering integration that feeds captured data into the billing pipeline.

Response Writer Wrapper

// internal/middleware/responsewriter.go
package middleware

import (
    "bytes"
    "net/http"
)

// ResponseRecorder wraps http.ResponseWriter to capture response metadata.
type ResponseRecorder struct {
    http.ResponseWriter
    statusCode  int
    bytesWritten int
    bodyBuffer  *bytes.Buffer // optional: buffer first N bytes for parsing
    bufferLimit int
    wroteHeader bool
}

func NewResponseRecorder(w http.ResponseWriter, bufferLimit int) *ResponseRecorder {
    var buf *bytes.Buffer
    if bufferLimit > 0 {
        buf = &bytes.Buffer{}
    }
    return &ResponseRecorder{
        ResponseWriter: w,
        statusCode:     200, // default if WriteHeader is never called
        bodyBuffer:     buf,
        bufferLimit:    bufferLimit,
    }
}

func (r *ResponseRecorder) WriteHeader(code int) {
    if r.wroteHeader {
        return // WriteHeader must be called at most once
    }
    r.wroteHeader = true
    r.statusCode = code
    r.ResponseWriter.WriteHeader(code)
}

func (r *ResponseRecorder) Write(b []byte) (int, error) {
    // Capture body prefix for metric extraction.
    if r.bodyBuffer != nil && r.bodyBuffer.Len() < r.bufferLimit {
        remaining := r.bufferLimit - r.bodyBuffer.Len()
        if len(b) <= remaining {
            r.bodyBuffer.Write(b)
        } else {
            r.bodyBuffer.Write(b[:remaining])
        }
    }

    n, err := r.ResponseWriter.Write(b)
    r.bytesWritten += n
    return n, err
}

// StatusCode returns the captured HTTP status code.
func (r *ResponseRecorder) StatusCode() int {
    return r.statusCode
}

// BytesWritten returns the total response body size.
func (r *ResponseRecorder) BytesWritten() int {
    return r.bytesWritten
}

// BodyPrefix returns the buffered prefix of the response body.
func (r *ResponseRecorder) BodyPrefix() []byte {
    if r.bodyBuffer == nil {
        return nil
    }
    return r.bodyBuffer.Bytes()
}

// Flush delegates to the underlying writer if it implements http.Flusher.
func (r *ResponseRecorder) Flush() {
    if f, ok := r.ResponseWriter.(http.Flusher); ok {
        f.Flush()
    }
}

// Unwrap returns the original ResponseWriter (required for http.ResponseController).
func (r *ResponseRecorder) Unwrap() http.ResponseWriter {
    return r.ResponseWriter
}

Usage Metering Middleware

// internal/middleware/metering.go
package middleware

import (
    "encoding/json"
    "net/http"
    "time"

    "example.com/search/internal/auth"
    "example.com/search/internal/metering"
)

func UsageMetering(recorder *metering.Recorder) 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()

            // Wrap the response writer to capture metadata.
            // Buffer first 4KB of response body for result count extraction.
            rec := NewResponseRecorder(w, 4096)

            // Call the next handler with the wrapped writer.
            next.ServeHTTP(rec, r)

            // After handler returns — extract usage metrics.
            duration := time.Since(start)
            actor := auth.ActorFromContext(r.Context())
            if actor == nil {
                return // unauthenticated requests are not metered
            }

            // Record basic request metrics.
            recorder.Record(metering.UsageEvent{
                TenantID:  actor.TenantID,
                EventType: "api_request",
                Metric:    "request_count",
                Value:     1,
                Dimensions: map[string]string{
                    "method":      r.Method,
                    "path":        r.URL.Path,
                    "status_code": http.StatusText(rec.StatusCode()),
                },
                EventID: RequestIDFromContext(r.Context()),
            })

            // Record bytes transferred (egress metering).
            recorder.Record(metering.UsageEvent{
                TenantID:  actor.TenantID,
                EventType: "api_request",
                Metric:    "bytes_egress",
                Value:     int64(rec.BytesWritten()),
                EventID:   RequestIDFromContext(r.Context()) + ":bytes",
            })

            // Extract result count from JSON response body.
            if resultCount := extractResultCount(rec.BodyPrefix()); resultCount > 0 {
                recorder.Record(metering.UsageEvent{
                    TenantID:  actor.TenantID,
                    EventType: "search",
                    Metric:    "result_count",
                    Value:     int64(resultCount),
                    EventID:   RequestIDFromContext(r.Context()) + ":results",
                })
            }

            _ = duration // available for latency histograms
        })
    }
}

// extractResultCount parses the response body prefix to find the result count.
func extractResultCount(body []byte) int {
    if len(body) == 0 {
        return 0
    }
    var envelope struct {
        Results []json.RawMessage `json:"results"`
        Total   int               `json:"total"`
    }
    if err := json.Unmarshal(body, &envelope); err != nil {
        return 0
    }
    if envelope.Total > 0 {
        return envelope.Total
    }
    return len(envelope.Results)
}

Wiring the Middleware into the Router

// cmd/server/main.go
func setupRouter(deps *Dependencies) http.Handler {
    mux := http.NewServeMux()

    // Register API handlers.
    mux.Handle("/api/v1/search", deps.searchHandler)
    mux.Handle("/api/v1/analytics", deps.analyticsHandler)
    mux.Handle("/api/v1/admin", deps.adminHandler)

    // Compose middleware chain (outermost first).
    var handler http.Handler = mux
    handler = middleware.UsageMetering(deps.meteringRecorder)(handler)
    handler = middleware.Authentication(deps.authService)(handler)
    handler = middleware.RequestID()(handler)
    handler = middleware.Recovery(deps.logger)(handler)

    return handler
}

Practical Applications

In a search platform, response writer interception powers the entire usage-based billing system. Every API response passes through the metering middleware, which captures the status code, response size, and result count without any handler-level instrumentation. When a new API endpoint is added, it automatically gets usage tracking — developers don’t need to remember to add metering calls, and there’s no risk of metering code being forgotten or inconsistent across endpoints.

The captured byte counts feed into egress-based billing, where customers pay per gigabyte of data transferred. The result counts feed into query-based billing tiers — a search returning 1,000 results is priced differently than one returning 10. The status codes enable filtering: failed requests (4xx, 5xx) are excluded from billing so customers aren’t charged for errors.

Beyond billing, the intercepted metadata feeds into Prometheus histograms for response size distribution and latency percentiles, structured access logs for debugging, and real-time dashboards showing request throughput and error rates per tenant. All of this comes from a single middleware that wraps the response writer — no per-handler instrumentation needed.

Implementation Patterns

Conditional Body Buffering

Buffering the entire response body for metric extraction is wasteful for large responses (multi-megabyte file downloads, streaming responses). Buffer only the first N bytes — enough to parse the JSON envelope containing result counts and metadata. For the search platform, 4KB is sufficient: the response envelope with result count and pagination metadata fits within the first few hundred bytes, well within the buffer limit.

Interface-Aware Wrapping

Use type assertions at construction time to build the right wrapper type. If the underlying writer supports http.Flusher and http.Hijacker, return a wrapper that also implements both. This avoids runtime reflection on every Flush() call and ensures that streaming and WebSocket functionality is preserved.

// Build the right wrapper type based on underlying writer capabilities.
func wrapResponseWriter(w http.ResponseWriter) *ResponseRecorder {
    rec := NewResponseRecorder(w, 4096)

    // Go 1.20+ http.ResponseController uses Unwrap() to find
    // the underlying writer's optional interfaces, so implementing
    // Unwrap() is sufficient for most cases. For older patterns,
    // explicitly check and delegate.
    return rec
}

Deferred Metric Emission

Emit usage metrics after the handler returns, not during response writing. Metric emission might involve network calls (sending to a metrics collector) or contention (writing to a shared channel). Doing this inside Write() adds latency to every response chunk. Instead, accumulate metadata during the request and emit all metrics in a single batch after ServeHTTP returns.

Common Pitfalls

Losing Optional Interface Support

The most common bug: wrapping http.ResponseWriter breaks http.Flusher support, which silently disables streaming responses and Server-Sent Events. The HTTP server checks if flusher, ok := w.(http.Flusher) — if the wrapper doesn’t implement Flusher, the type assertion fails and streaming stops working. Always implement Unwrap() (for http.ResponseController) and delegate Flush() to the underlying writer.

Double WriteHeader Calls

If the handler calls WriteHeader(200) and then an error-handling middleware also calls WriteHeader(500), the second call is silently ignored by Go’s HTTP server — but the interceptor might overwrite its recorded status code. Guard against this with a wroteHeader flag that prevents recording a second status code. The first WriteHeader call is the one that matters.

Buffering Large Responses in Memory

If the buffer limit is set too high or removed entirely, a large response (streaming file download, multi-megabyte JSON payload) gets copied into memory. For a service handling thousands of concurrent requests, this causes memory pressure and potential OOM. Always cap the buffer size, and for endpoints known to produce large responses, disable body buffering entirely by setting the buffer limit to zero.

Incomplete JSON in Buffer

Buffering only the first N bytes of a response means the captured JSON is likely truncated. json.Unmarshal will fail on truncated JSON. The metric extraction function must handle parse failures gracefully — return zero/empty rather than logging errors for every large response. Alternatively, use a streaming JSON parser that can extract top-level fields without reading the entire document.

Metering Internal Health Checks

Health check endpoints (/healthz, /readyz) are hit by load balancers every few seconds. If the metering middleware doesn’t exclude these paths, the usage database fills with thousands of meaningless records per hour. Filter out health check paths before recording usage events — either by path prefix matching in the middleware or by mounting the health check handlers outside the metered middleware chain.

Further Reading

Further Reading