Skip to content

Instrument HTTP requests

Metrics.HTTPMiddleware records the RED signals — Rate, Errors, Duration — for every HTTP request, on the same registry that serves /metrics. Labels use the route template, never the raw URL, so cardinality stays bounded.

m, err := metrics.New(metrics.WithMiddleware(authMiddleware))
if err != nil { return err }

httpMetrics, err := m.HTTPMiddleware()
if err != nil { return err }

mux := http.NewServeMux()
mux.Handle("GET /users/{id}", httpMetrics(usersHandler)) // instrument a route
m.MountOn(mux)                                           // and expose /metrics

MountOn returns nothing — it panics only if the mux rejects the pattern, which WithMetricsPath can cause. See Failure modes.

Or wire it once across the whole server through go/transport's middleware chain:

srv, err := transporthttp.Register(ctx, "api", controller, logger, mux, settings,
    transporthttp.WithMiddleware(transithttp.NewChain(httpMetrics)))

WithMiddleware returns a RegisterOption, not a ServerOption, so it has to go through Register. NewServer accepts ServerOption values only and will not compile with it.

The metrics produced

Metric Type Labels
http_requests_total counter method, route, status
http_request_duration_seconds histogram method, route, status
http_requests_in_flight gauge

route is the matched template (GET /users/{id}), read from http.Request.Pattern (Go 1.22+ ServeMux). /users/42 and /users/43 share one series.

Options

Option Effect
WithHTTPNamespace("myapp") prefix the metric names (myapp_http_requests_total)
WithDurationBuckets(...) override the histogram buckets (default prometheus.DefBuckets)
WithStatusClass() record status as its class (2xx) instead of the exact code
WithRouteLabelFunc(f) derive the route template yourself (non-stdlib routers)
WithUnmatchedRouteLabel("<other>") the bucket for requests that matched no route
WithHTTPConstLabels{...} stamp constant labels (e.g. version) on the HTTP metrics

Non-stdlib routers

If you don't use http.ServeMux, r.Pattern is empty and everything would fall into the <other> bucket. Supply WithRouteLabelFunc to read your router's matched route template — chi's chi.RouteContext(r.Context()).RoutePattern(), gin's c.FullPath(), etc. Return the template, never the raw path — see Cardinality safety.

Instrument the whole server once, not each route twice

HTTPMiddleware registers its three instruments on the registry the first time it is called. Calling it again on the same Metrics returns:

register HTTP instrument: duplicate metrics collector registration attempted

Call it once and apply the returned middleware wherever you need it — to a whole chain, or to individual routes. If you genuinely want a second, separate set (an admin listener, say), give it its own namespace: m.HTTPMiddleware(metrics.WithHTTPNamespace("admin")) succeeds because the metric names differ.

What it does not measure

Server-side requests only. There is no instrumented http.RoundTripper and no http_client_* series, so calls your service makes to other services are not covered — client_golang's own promhttp.InstrumentRoundTripper* helpers register on m.Registry() if you want them. Request and response sizes are not recorded either. See Limitations.

Errors and duration

The status label captures the response code (via a lightweight ResponseWriter wrapper that preserves flushing/hijacking through http.ResponseController), so error rate is sum(rate(http_requests_total{status=~"5.."}[5m])). The histogram gives you latency quantiles per route. Add exemplars to jump from a latency spike straight to the trace.