check / check (push) Successful in 4m2s
The HTTP metrics recorder, the delivery collectors and the Go and process collectors now register on one prometheus.Registry that fx provides, instead of Prometheus's global default registry, and /metrics serves that registry. A second metrics-enabled router in one process, or the server tests run with -count=2, no longer panics on a duplicate registration. The middleware builds its recorder once, in New, so installing Metrics() on more than one router over the same graph is also safe. The scrape keeps the same series and labels, including go_*, process_* and promhttp_metric_handler_*. Model: opus-5-5
180 lines
6.7 KiB
Go
180 lines
6.7 KiB
Go
package middleware
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi"
|
|
httpmetrics "github.com/slok/go-http-metrics/metrics"
|
|
ghmm "github.com/slok/go-http-metrics/middleware"
|
|
"github.com/slok/go-http-metrics/middleware/std"
|
|
)
|
|
|
|
// inflightHandler is the fixed `handler` label on
|
|
// http_requests_inflight, the one HTTP metric here that cannot carry
|
|
// a route pattern.
|
|
//
|
|
// The gauge is incremented before the wrapped handler runs and
|
|
// decremented after it returns, and the pattern only exists between
|
|
// those two moments. Deriving the label from the route would
|
|
// therefore increment one series and decrement another, leaving every
|
|
// pattern permanently off by the number of requests it served — a
|
|
// broken gauge, on top of the per-path cardinality this file exists
|
|
// to remove. So the gauge is deliberately aggregate: one series,
|
|
// counting the requests in flight across the whole service.
|
|
const inflightHandler = "(all)"
|
|
|
|
// unmatchedMethod is the `method` label for a request whose method
|
|
// the router can never route.
|
|
//
|
|
// It is deliberately the same sentinel as unmatchedRoute rather than
|
|
// a spelling of its own: both stand for a client-chosen token that
|
|
// matched nothing this service registers, and giving one idea two
|
|
// spellings would read in a scrape as two different unmatched states.
|
|
const unmatchedMethod = unmatchedRoute
|
|
|
|
// routePatternID is the `handler` label for a request: the chi route
|
|
// pattern, never the concrete path.
|
|
//
|
|
// The pattern is what bounds the label's domain to the routes the
|
|
// service registers. The path does not bound it at all — every byte
|
|
// after /webhook/ is client-chosen, so labelling by path lets any
|
|
// unauthenticated client mint permanent series at will, and publishes
|
|
// the entrypoint UUID (the receiver's only credential) in the scrape
|
|
// while doing it.
|
|
//
|
|
// chi populates the route context during routeHTTP, so this is only
|
|
// valid once routing has run. Every caller below is on the recording
|
|
// side of the middleware, which go-http-metrics defers until after
|
|
// the wrapped handler returns.
|
|
func routePatternID(ctx context.Context) string {
|
|
if rc := chi.RouteContext(ctx); rc != nil {
|
|
if pattern := rc.RoutePattern(); pattern != "" {
|
|
return pattern
|
|
}
|
|
}
|
|
|
|
return unmatchedRoute
|
|
}
|
|
|
|
// methodID is the `method` label for a request: the request method
|
|
// when the router can route it, and the unmatched sentinel otherwise.
|
|
//
|
|
// net/http accepts any RFC 9110 token as a method and hands it
|
|
// through verbatim, so the raw method is client-chosen bytes and
|
|
// bounds the label at nothing — the same unauthenticated
|
|
// series-minting the handler label carried, reached through a second
|
|
// dimension. What bounds it is the set chi's router will match a
|
|
// route for: its methodMap, which is unexported, so it is restated
|
|
// here against the net/http constants it is built from. A token
|
|
// outside that set can only ever produce chi's 405, so folding every
|
|
// one of them onto a single series loses no information a scrape
|
|
// could have used, while the nine methods that can reach a handler
|
|
// stay distinguishable.
|
|
//
|
|
// chi.RegisterMethod would extend the router's set at runtime; this
|
|
// service never calls it, and a caller that started to would have to
|
|
// extend this switch with it.
|
|
func methodID(method string) string {
|
|
switch method {
|
|
case http.MethodConnect,
|
|
http.MethodDelete,
|
|
http.MethodGet,
|
|
http.MethodHead,
|
|
http.MethodOptions,
|
|
http.MethodPatch,
|
|
http.MethodPost,
|
|
http.MethodPut,
|
|
http.MethodTrace:
|
|
return method
|
|
default:
|
|
return unmatchedMethod
|
|
}
|
|
}
|
|
|
|
// boundedLabelRecorder wraps a go-http-metrics recorder and replaces
|
|
// the request-controlled labels on every observation with bounded
|
|
// ones: the handler id becomes the request's route pattern, and the
|
|
// method becomes one the router can route.
|
|
//
|
|
// This is the seam that makes the pattern usable at all. The metrics
|
|
// middleware is global (see Server.setupGlobalMiddleware), so it is
|
|
// entered before chi has matched anything, and go-http-metrics fixes
|
|
// its handler id up front — passing the pattern in as that id is not
|
|
// possible, and leaving the id empty makes the library substitute the
|
|
// raw URL path, which is the defect. What the library does hand over
|
|
// is the request context, unchanged, on each recorder call; that
|
|
// context carries the same *chi.Context pointer routing mutates in
|
|
// place, and the duration and size calls happen after the wrapped
|
|
// handler has returned. Reading the pattern there is what the access
|
|
// log already does in accessLogURL.
|
|
//
|
|
// Recording after the whole chain returns is also what makes this
|
|
// hold for requests the route-level receiver rate limiter rejects.
|
|
// Those never reach a handler, but chi has already matched the route
|
|
// by the time the limiter runs, so their 429s land on the pattern
|
|
// like any other response.
|
|
type boundedLabelRecorder struct {
|
|
inner httpmetrics.Recorder
|
|
}
|
|
|
|
func (r boundedLabelRecorder) ObserveHTTPRequestDuration(
|
|
ctx context.Context,
|
|
props httpmetrics.HTTPReqProperties,
|
|
duration time.Duration,
|
|
) {
|
|
props.ID = routePatternID(ctx)
|
|
props.Method = methodID(props.Method)
|
|
r.inner.ObserveHTTPRequestDuration(ctx, props, duration)
|
|
}
|
|
|
|
func (r boundedLabelRecorder) ObserveHTTPResponseSize(
|
|
ctx context.Context,
|
|
props httpmetrics.HTTPReqProperties,
|
|
sizeBytes int64,
|
|
) {
|
|
props.ID = routePatternID(ctx)
|
|
props.Method = methodID(props.Method)
|
|
r.inner.ObserveHTTPResponseSize(ctx, props, sizeBytes)
|
|
}
|
|
|
|
func (r boundedLabelRecorder) AddInflightRequests(
|
|
ctx context.Context,
|
|
props httpmetrics.HTTPProperties,
|
|
quantity int,
|
|
) {
|
|
props.ID = inflightHandler
|
|
r.inner.AddInflightRequests(ctx, props, quantity)
|
|
}
|
|
|
|
var _ httpmetrics.Recorder = boundedLabelRecorder{}
|
|
|
|
// Metrics returns middleware that records Prometheus HTTP metrics on
|
|
// the registry the /metrics route serves. Every call shares the one
|
|
// recorder New built, so any number of routers can install it.
|
|
func (s *Middleware) Metrics() func(http.Handler) http.Handler {
|
|
return metricsMiddleware(s.metricsRecorder)
|
|
}
|
|
|
|
// metricsMiddleware builds the recording middleware against a given
|
|
// recorder, so tests can gather from a registry of their own.
|
|
func metricsMiddleware(
|
|
rec httpmetrics.Recorder,
|
|
) func(http.Handler) http.Handler {
|
|
mdlw := ghmm.New(ghmm.Config{
|
|
Recorder: boundedLabelRecorder{inner: rec},
|
|
})
|
|
|
|
return func(next http.Handler) http.Handler {
|
|
// The handler id is unmatchedRoute rather than "" so that
|
|
// the client-chosen URL path never enters the metrics
|
|
// pipeline at all: an empty id is the library's signal to
|
|
// substitute it. boundedLabelRecorder overwrites this value
|
|
// on every observation, so it is reachable only if that
|
|
// decorator is removed — in which case the metrics collapse
|
|
// to one series instead of leaking again.
|
|
return std.Handler(unmatchedRoute, mdlw, next)
|
|
}
|
|
}
|