Bound the /metrics method label (closes #261)
Some checks failed
check / check (push) Superseded by a newer commit; never tested
Some checks failed
check / check (push) Superseded by a newer commit; never tested
This commit was merged in pull request #264.
This commit is contained in:
@@ -26,6 +26,15 @@ import (
|
||||
// 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.
|
||||
//
|
||||
@@ -50,9 +59,45 @@ func routePatternID(ctx context.Context) string {
|
||||
return unmatchedRoute
|
||||
}
|
||||
|
||||
// routePatternRecorder wraps a go-http-metrics recorder and replaces
|
||||
// the handler id on every observation with the request's route
|
||||
// pattern.
|
||||
// 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
|
||||
@@ -71,29 +116,31 @@ func routePatternID(ctx context.Context) string {
|
||||
// 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 routePatternRecorder struct {
|
||||
type boundedLabelRecorder struct {
|
||||
inner httpmetrics.Recorder
|
||||
}
|
||||
|
||||
func (r routePatternRecorder) ObserveHTTPRequestDuration(
|
||||
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 routePatternRecorder) ObserveHTTPResponseSize(
|
||||
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 routePatternRecorder) AddInflightRequests(
|
||||
func (r boundedLabelRecorder) AddInflightRequests(
|
||||
ctx context.Context,
|
||||
props httpmetrics.HTTPProperties,
|
||||
quantity int,
|
||||
@@ -102,7 +149,7 @@ func (r routePatternRecorder) AddInflightRequests(
|
||||
r.inner.AddInflightRequests(ctx, props, quantity)
|
||||
}
|
||||
|
||||
var _ httpmetrics.Recorder = routePatternRecorder{}
|
||||
var _ httpmetrics.Recorder = boundedLabelRecorder{}
|
||||
|
||||
// Metrics returns middleware that records Prometheus HTTP metrics on
|
||||
// the default registry, which is the one the /metrics route gathers.
|
||||
@@ -119,14 +166,14 @@ func metricsMiddleware(
|
||||
rec httpmetrics.Recorder,
|
||||
) func(http.Handler) http.Handler {
|
||||
mdlw := ghmm.New(ghmm.Config{
|
||||
Recorder: routePatternRecorder{inner: rec},
|
||||
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. routePatternRecorder overwrites this value
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user