diff --git a/prometheus/promhttp/context.go b/prometheus/promhttp/context.go new file mode 100644 index 000000000..5b6679a14 --- /dev/null +++ b/prometheus/promhttp/context.go @@ -0,0 +1,31 @@ +// Copyright 2022 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package promhttp + +import "context" + +type _PathKey string + +var requestPathKey _PathKey + +func WithRequestPath(ctx context.Context, path string) context.Context { + return context.WithValue(ctx, requestPathKey, path) +} + +func pathFromContext(ctx context.Context) string { + if value, ok := ctx.Value(requestPathKey).(string); ok { + return value + } + return "*" +} diff --git a/prometheus/promhttp/instrument_client.go b/prometheus/promhttp/instrument_client.go index 210867816..d4db630ac 100644 --- a/prometheus/promhttp/instrument_client.go +++ b/prometheus/promhttp/instrument_client.go @@ -48,7 +48,7 @@ func InstrumentRoundTripperInFlight(gauge prometheus.Gauge, next http.RoundTripp // InstrumentRoundTripperCounter is a middleware that wraps the provided // http.RoundTripper to observe the request result with the provided CounterVec. // The CounterVec must have zero, one, or two non-const non-curried labels. For -// those, the only allowed label names are "code" and "method". The function +// those, the only allowed label names are "code", "method" and "path". The function // panics otherwise. For the "method" label a predefined default label value set // is used to filter given values. Values besides predefined values will count // as `unknown` method.`WithExtraMethods` can be used to add more @@ -60,6 +60,7 @@ func InstrumentRoundTripperInFlight(gauge prometheus.Gauge, next http.RoundTripp // is not incremented. // // Use with WithExemplarFromContext to instrument the exemplars on the counter of requests. +// Use with WithRequestPath to associate the request path with the context. // // See the example for ExampleInstrumentRoundTripperDuration for example usage. func InstrumentRoundTripperCounter(counter *prometheus.CounterVec, next http.RoundTripper, opts ...Option) RoundTripperFunc { @@ -68,13 +69,13 @@ func InstrumentRoundTripperCounter(counter *prometheus.CounterVec, next http.Rou o.apply(rtOpts) } - code, method := checkLabels(counter) + code, method, path := checkLabels(counter) return func(r *http.Request) (*http.Response, error) { resp, err := next.RoundTrip(r) if err == nil { addWithExemplar( - counter.With(labels(code, method, r.Method, resp.StatusCode, rtOpts.extraMethods...)), + counter.With(labels(code, method, path, r.Method, resp.StatusCode, rtOpts.getRequestPathFn(r), rtOpts.extraMethods...)), 1, rtOpts.getExemplarFn(r.Context()), ) @@ -86,8 +87,8 @@ func InstrumentRoundTripperCounter(counter *prometheus.CounterVec, next http.Rou // InstrumentRoundTripperDuration is a middleware that wraps the provided // http.RoundTripper to observe the request duration with the provided // ObserverVec. The ObserverVec must have zero, one, or two non-const -// non-curried labels. For those, the only allowed label names are "code" and -// "method". The function panics otherwise. For the "method" label a predefined +// non-curried labels. For those, the only allowed label names are "code", "method" +// and "path". The function panics otherwise. For the "method" label a predefined // default label value set is used to filter given values. Values besides // predefined values will count as `unknown` method. `WithExtraMethods` // can be used to add more methods to the set. The Observe method of the Observer @@ -101,6 +102,7 @@ func InstrumentRoundTripperCounter(counter *prometheus.CounterVec, next http.Rou // reported. // // Use with WithExemplarFromContext to instrument the exemplars on the duration histograms. +// Use with WithRequestPath to associate the request path with the context. // // Note that this method is only guaranteed to never observe negative durations // if used with Go1.9+. @@ -110,14 +112,14 @@ func InstrumentRoundTripperDuration(obs prometheus.ObserverVec, next http.RoundT o.apply(rtOpts) } - code, method := checkLabels(obs) + code, method, path := checkLabels(obs) return func(r *http.Request) (*http.Response, error) { start := time.Now() resp, err := next.RoundTrip(r) if err == nil { observeWithExemplar( - obs.With(labels(code, method, r.Method, resp.StatusCode, rtOpts.extraMethods...)), + obs.With(labels(code, method, path, r.Method, resp.StatusCode, rtOpts.getRequestPathFn(r), rtOpts.extraMethods...)), time.Since(start).Seconds(), rtOpts.getExemplarFn(r.Context()), ) diff --git a/prometheus/promhttp/instrument_client_test.go b/prometheus/promhttp/instrument_client_test.go index ce7c4da54..0c45295e1 100644 --- a/prometheus/promhttp/instrument_client_test.go +++ b/prometheus/promhttp/instrument_client_test.go @@ -47,7 +47,7 @@ func makeInstrumentedClient(opts ...Option) (*http.Client, *prometheus.Registry) Name: "client_api_requests_total", Help: "A counter for requests from the wrapped client.", }, - []string{"code", "method"}, + []string{"code", "method", "path"}, ) dnsLatencyVec := prometheus.NewHistogramVec( @@ -228,8 +228,9 @@ func TestClientMiddlewareAPI_WithRequestContext(t *testing.T) { t.Fatalf("%v", err) } + ctx := WithRequestPath(context.Background(), "/home") // Set a context with a long timeout. - ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + ctx, cancel := context.WithTimeout(ctx, 100*time.Millisecond) defer cancel() req = req.WithContext(ctx) @@ -256,7 +257,7 @@ func TestClientMiddlewareAPI_WithRequestContext(t *testing.T) { expected := ` # HELP client_api_requests_total A counter for requests from the wrapped client. # TYPE client_api_requests_total counter - client_api_requests_total{code="200",method="get"} 1 + client_api_requests_total{code="200",method="get",path="/home"} 1 ` if err := testutil.GatherAndCompare(reg, strings.NewReader(expected), @@ -310,7 +311,7 @@ func ExampleInstrumentRoundTripperDuration() { Name: "client_api_requests_total", Help: "A counter for requests from the wrapped client.", }, - []string{"code", "method"}, + []string{"code", "method", "path"}, ) // dnsLatencyVec uses custom buckets based on expected dns durations. @@ -381,7 +382,12 @@ func ExampleInstrumentRoundTripperDuration() { // Set the RoundTripper on our client. client.Transport = roundTripper - resp, err := client.Get("http://google.com") + ctx := WithRequestPath(context.Background(), "/") + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://google.com", nil) + if err != nil { + log.Printf("error: %v", err) + } + resp, err := client.Do(req) if err != nil { log.Printf("error: %v", err) } diff --git a/prometheus/promhttp/instrument_server.go b/prometheus/promhttp/instrument_server.go index cca67a78a..837d9ce4e 100644 --- a/prometheus/promhttp/instrument_server.go +++ b/prometheus/promhttp/instrument_server.go @@ -65,15 +65,15 @@ func InstrumentHandlerInFlight(g prometheus.Gauge, next http.Handler) http.Handl // http.Handler to observe the request duration with the provided ObserverVec. // The ObserverVec must have valid metric and label names and must have zero, // one, or two non-const non-curried labels. For those, the only allowed label -// names are "code" and "method". The function panics otherwise. For the "method" -// label a predefined default label value set is used to filter given values. +// names are "code", "method" and "path". The function panics otherwise. For the +// "method" label a predefined default label value set is used to filter given values. // Values besides predefined values will count as `unknown` method. // `WithExtraMethods` can be used to add more methods to the set. The Observe // method of the Observer in the ObserverVec is called with the request duration -// in seconds. Partitioning happens by HTTP status code and/or HTTP method if -// the respective instance label names are present in the ObserverVec. For -// unpartitioned observations, use an ObserverVec with zero labels. Note that -// partitioning of Histograms is expensive and should be used judiciously. +// in seconds. Partitioning happens by HTTP status code and/or HTTP method and/or +// path in the context if the respective instance label names are present in the +// ObserverVec. For unpartitioned observations, use an ObserverVec with zero labels. +// Note that partitioning of Histograms is expensive and should be used judiciously. // // If the wrapped Handler does not set a status code, a status code of 200 is assumed. // @@ -87,7 +87,7 @@ func InstrumentHandlerDuration(obs prometheus.ObserverVec, next http.Handler, op o.apply(hOpts) } - code, method := checkLabels(obs) + code, method, path := checkLabels(obs) if code { return func(w http.ResponseWriter, r *http.Request) { @@ -96,7 +96,7 @@ func InstrumentHandlerDuration(obs prometheus.ObserverVec, next http.Handler, op next.ServeHTTP(d, r) observeWithExemplar( - obs.With(labels(code, method, r.Method, d.Status(), hOpts.extraMethods...)), + obs.With(labels(code, method, path, r.Method, d.Status(), hOpts.getRequestPathFn(r), hOpts.extraMethods...)), time.Since(now).Seconds(), hOpts.getExemplarFn(r.Context()), ) @@ -108,7 +108,7 @@ func InstrumentHandlerDuration(obs prometheus.ObserverVec, next http.Handler, op next.ServeHTTP(w, r) observeWithExemplar( - obs.With(labels(code, method, r.Method, 0, hOpts.extraMethods...)), + obs.With(labels(code, method, path, r.Method, 0, hOpts.getRequestPathFn(r), hOpts.extraMethods...)), time.Since(now).Seconds(), hOpts.getExemplarFn(r.Context()), ) @@ -119,12 +119,12 @@ func InstrumentHandlerDuration(obs prometheus.ObserverVec, next http.Handler, op // to observe the request result with the provided CounterVec. The CounterVec // must have valid metric and label names and must have zero, one, or two // non-const non-curried labels. For those, the only allowed label names are -// "code" and "method". The function panics otherwise. For the "method" +// "code", "method" and "path". The function panics otherwise. For the "method" // label a predefined default label value set is used to filter given values. // Values besides predefined values will count as `unknown` method. // `WithExtraMethods` can be used to add more methods to the set. Partitioning of the -// CounterVec happens by HTTP status code and/or HTTP method if the respective -// instance label names are present in the CounterVec. For unpartitioned +// CounterVec happens by HTTP status code and/or HTTP method and/or path in the context +// if the respective instance label names are present in the CounterVec. For unpartitioned // counting, use a CounterVec with zero labels. // // If the wrapped Handler does not set a status code, a status code of 200 is assumed. @@ -138,7 +138,7 @@ func InstrumentHandlerCounter(counter *prometheus.CounterVec, next http.Handler, o.apply(hOpts) } - code, method := checkLabels(counter) + code, method, path := checkLabels(counter) if code { return func(w http.ResponseWriter, r *http.Request) { @@ -146,7 +146,7 @@ func InstrumentHandlerCounter(counter *prometheus.CounterVec, next http.Handler, next.ServeHTTP(d, r) addWithExemplar( - counter.With(labels(code, method, r.Method, d.Status(), hOpts.extraMethods...)), + counter.With(labels(code, method, path, r.Method, d.Status(), hOpts.getRequestPathFn(r), hOpts.extraMethods...)), 1, hOpts.getExemplarFn(r.Context()), ) @@ -156,7 +156,7 @@ func InstrumentHandlerCounter(counter *prometheus.CounterVec, next http.Handler, return func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(w, r) addWithExemplar( - counter.With(labels(code, method, r.Method, 0, hOpts.extraMethods...)), + counter.With(labels(code, method, path, r.Method, 0, hOpts.getRequestPathFn(r), hOpts.extraMethods...)), 1, hOpts.getExemplarFn(r.Context()), ) @@ -167,16 +167,16 @@ func InstrumentHandlerCounter(counter *prometheus.CounterVec, next http.Handler, // http.Handler to observe with the provided ObserverVec the request duration // until the response headers are written. The ObserverVec must have valid // metric and label names and must have zero, one, or two non-const non-curried -// labels. For those, the only allowed label names are "code" and "method". The -// function panics otherwise. For the "method" label a predefined default label +// labels. For those, the only allowed label names are "code", "method" and "path". +// The function panics otherwise. For the "method" label a predefined default label // value set is used to filter given values. Values besides predefined values // will count as `unknown` method.`WithExtraMethods` can be used to add more // methods to the set. The Observe method of the Observer in the // ObserverVec is called with the request duration in seconds. Partitioning -// happens by HTTP status code and/or HTTP method if the respective instance -// label names are present in the ObserverVec. For unpartitioned observations, -// use an ObserverVec with zero labels. Note that partitioning of Histograms is -// expensive and should be used judiciously. +// happens by HTTP status code and/or HTTP method and path in the context if the +// respective instance label names are present in the ObserverVec. For +// unpartitioned observations, use an ObserverVec with zero labels. +// Note that partitioning of Histograms is expensive and should be used judiciously. // // If the wrapped Handler panics before calling WriteHeader, no value is // reported. @@ -191,13 +191,13 @@ func InstrumentHandlerTimeToWriteHeader(obs prometheus.ObserverVec, next http.Ha o.apply(hOpts) } - code, method := checkLabels(obs) + code, method, path := checkLabels(obs) return func(w http.ResponseWriter, r *http.Request) { now := time.Now() d := newDelegator(w, func(status int) { observeWithExemplar( - obs.With(labels(code, method, r.Method, status, hOpts.extraMethods...)), + obs.With(labels(code, method, path, r.Method, status, hOpts.getRequestPathFn(r), hOpts.extraMethods...)), time.Since(now).Seconds(), hOpts.getExemplarFn(r.Context()), ) @@ -210,15 +210,15 @@ func InstrumentHandlerTimeToWriteHeader(obs prometheus.ObserverVec, next http.Ha // http.Handler to observe the request size with the provided ObserverVec. The // ObserverVec must have valid metric and label names and must have zero, one, // or two non-const non-curried labels. For those, the only allowed label names -// are "code" and "method". The function panics otherwise. For the "method" +// are "code", "method" and "path". The function panics otherwise. For the "method" // label a predefined default label value set is used to filter given values. // Values besides predefined values will count as `unknown` method. // `WithExtraMethods` can be used to add more methods to the set. The Observe // method of the Observer in the ObserverVec is called with the request size in -// bytes. Partitioning happens by HTTP status code and/or HTTP method if the -// respective instance label names are present in the ObserverVec. For -// unpartitioned observations, use an ObserverVec with zero labels. Note that -// partitioning of Histograms is expensive and should be used judiciously. +// bytes. Partitioning happens by HTTP status code and/or HTTP method and/or +// path in the context if the respective instance label names are present in the +// ObserverVec. For unpartitioned observations, use an ObserverVec with zero labels. +// Note that partitioning of Histograms is expensive and should be used judiciously. // // If the wrapped Handler does not set a status code, a status code of 200 is assumed. // @@ -231,14 +231,14 @@ func InstrumentHandlerRequestSize(obs prometheus.ObserverVec, next http.Handler, o.apply(hOpts) } - code, method := checkLabels(obs) + code, method, path := checkLabels(obs) if code { return func(w http.ResponseWriter, r *http.Request) { d := newDelegator(w, nil) next.ServeHTTP(d, r) size := computeApproximateRequestSize(r) observeWithExemplar( - obs.With(labels(code, method, r.Method, d.Status(), hOpts.extraMethods...)), + obs.With(labels(code, method, path, r.Method, d.Status(), hOpts.getRequestPathFn(r), hOpts.extraMethods...)), float64(size), hOpts.getExemplarFn(r.Context()), ) @@ -249,7 +249,7 @@ func InstrumentHandlerRequestSize(obs prometheus.ObserverVec, next http.Handler, next.ServeHTTP(w, r) size := computeApproximateRequestSize(r) observeWithExemplar( - obs.With(labels(code, method, r.Method, 0, hOpts.extraMethods...)), + obs.With(labels(code, method, path, r.Method, 0, hOpts.getRequestPathFn(r), hOpts.extraMethods...)), float64(size), hOpts.getExemplarFn(r.Context()), ) @@ -260,15 +260,15 @@ func InstrumentHandlerRequestSize(obs prometheus.ObserverVec, next http.Handler, // http.Handler to observe the response size with the provided ObserverVec. The // ObserverVec must have valid metric and label names and must have zero, one, // or two non-const non-curried labels. For those, the only allowed label names -// are "code" and "method". The function panics otherwise. For the "method" +// are "code", "method" and "path". The function panics otherwise. For the "method" // label a predefined default label value set is used to filter given values. // Values besides predefined values will count as `unknown` method. // `WithExtraMethods` can be used to add more methods to the set. The Observe // method of the Observer in the ObserverVec is called with the response size in -// bytes. Partitioning happens by HTTP status code and/or HTTP method if the -// respective instance label names are present in the ObserverVec. For -// unpartitioned observations, use an ObserverVec with zero labels. Note that -// partitioning of Histograms is expensive and should be used judiciously. +// bytes. Partitioning happens by HTTP status code and/or HTTP method and/or path +// in the context if the respective instance label names are present in the +// ObserverVec. For unpartitioned observations, use an ObserverVec with zero labels. +// Note that partitioning of Histograms is expensive and should be used judiciously. // // If the wrapped Handler does not set a status code, a status code of 200 is assumed. // @@ -281,13 +281,13 @@ func InstrumentHandlerResponseSize(obs prometheus.ObserverVec, next http.Handler o.apply(hOpts) } - code, method := checkLabels(obs) + code, method, path := checkLabels(obs) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { d := newDelegator(w, nil) next.ServeHTTP(d, r) observeWithExemplar( - obs.With(labels(code, method, r.Method, d.Status(), hOpts.extraMethods...)), + obs.With(labels(code, method, path, r.Method, d.Status(), hOpts.getRequestPathFn(r), hOpts.extraMethods...)), float64(d.Written()), hOpts.getExemplarFn(r.Context()), ) @@ -299,7 +299,7 @@ func InstrumentHandlerResponseSize(obs prometheus.ObserverVec, next http.Handler // Collector does not have a Desc or has more than one Desc or its Desc is // invalid. It also panics if the Collector has any non-const, non-curried // labels that are not named "code" or "method". -func checkLabels(c prometheus.Collector) (code, method bool) { +func checkLabels(c prometheus.Collector) (code, method, path bool) { // TODO(beorn7): Remove this hacky way to check for instance labels // once Descriptors can have their dimensionality queried. var ( @@ -339,7 +339,7 @@ func checkLabels(c prometheus.Collector) (code, method bool) { // Write out the metric into a proto message and look at the labels. // If the value is not the magicString, it is a constLabel, which doesn't interest us. // If the label is curried, it doesn't interest us. - // In all other cases, only "code" or "method" is allowed. + // In all other cases, only "code", "method" or "path" is allowed. if err := m.Write(&pm); err != nil { panic("error checking metric for labels") } @@ -353,6 +353,8 @@ func checkLabels(c prometheus.Collector) (code, method bool) { code = true case "method": method = true + case "path": + path = true default: panic("metric partitioned with non-supported labels") } @@ -384,8 +386,8 @@ func isLabelCurried(c prometheus.Collector, label string) bool { // unnecessary allocations on each request. var emptyLabels = prometheus.Labels{} -func labels(code, method bool, reqMethod string, status int, extraMethods ...string) prometheus.Labels { - if !(code || method) { +func labels(code, method, path bool, reqMethod string, status int, reqPath string, extraMethods ...string) prometheus.Labels { + if !(code || method || path) { return emptyLabels } labels := prometheus.Labels{} @@ -396,6 +398,9 @@ func labels(code, method bool, reqMethod string, status int, extraMethods ...str if method { labels["method"] = sanitizeMethod(reqMethod, extraMethods...) } + if path { + labels["path"] = reqPath + } return labels } diff --git a/prometheus/promhttp/instrument_server_test.go b/prometheus/promhttp/instrument_server_test.go index 2a2cbf251..23ff6bb8f 100644 --- a/prometheus/promhttp/instrument_server_test.go +++ b/prometheus/promhttp/instrument_server_test.go @@ -50,22 +50,28 @@ func TestLabelCheck(t *testing.T) { curriedLabels: []string{}, ok: true, }, - "code and method as var labels": { - varLabels: []string{"method", "code"}, + "path as single var label": { + varLabels: []string{"path"}, + constLabels: []string{}, + curriedLabels: []string{}, + ok: true, + }, + "code, method and path as var labels": { + varLabels: []string{"method", "code", "path"}, constLabels: []string{}, curriedLabels: []string{}, ok: true, }, "valid case with all labels used": { - varLabels: []string{"code", "method"}, - constLabels: []string{"foo", "bar"}, - curriedLabels: []string{"dings", "bums"}, + varLabels: []string{"code", "method", "path"}, + constLabels: []string{"foo", "bar", "baz"}, + curriedLabels: []string{"dings", "bums", "pat"}, ok: true, }, "all labels used with an invalid const label name": { - varLabels: []string{"code", "method"}, - constLabels: []string{"in-valid", "bar"}, - curriedLabels: []string{"dings", "bums"}, + varLabels: []string{"code", "method", "path"}, + constLabels: []string{"in-valid", "bar", "baz"}, + curriedLabels: []string{"dings", "bums", "pat"}, ok: false, }, "unsupported var label": { @@ -75,7 +81,7 @@ func TestLabelCheck(t *testing.T) { ok: false, }, "mixed var labels": { - varLabels: []string{"method", "foo", "code"}, + varLabels: []string{"method", "foo", "code", "path"}, constLabels: []string{}, curriedLabels: []string{}, ok: false, @@ -87,7 +93,7 @@ func TestLabelCheck(t *testing.T) { ok: true, }, "mixed var labels but unsupported curried": { - varLabels: []string{"code", "method"}, + varLabels: []string{"code", "method", "path"}, constLabels: []string{}, curriedLabels: []string{"foo"}, ok: true, @@ -113,9 +119,9 @@ func TestLabelCheck(t *testing.T) { }, "invalid name with all the otherwise valid labels": { metricName: "in-valid", - varLabels: []string{"code", "method"}, - constLabels: []string{"foo", "bar"}, - curriedLabels: []string{"dings", "bums"}, + varLabels: []string{"code", "method", "path"}, + constLabels: []string{"foo", "bar", "baz"}, + curriedLabels: []string{"dings", "bums", "pat"}, ok: false, }, } @@ -177,7 +183,7 @@ func TestLabelCheck(t *testing.T) { }() if sc.ok { // Test if wantCode and wantMethod were detected correctly. - var wantCode, wantMethod bool + var wantCode, wantMethod, wantPath bool for _, l := range sc.varLabels { if l == "code" { wantCode = true @@ -185,21 +191,30 @@ func TestLabelCheck(t *testing.T) { if l == "method" { wantMethod = true } + if l == "path" { + wantPath = true + } } - gotCode, gotMethod := checkLabels(c) + gotCode, gotMethod, gotPath := checkLabels(c) if gotCode != wantCode { t.Errorf("wanted code=%t for counter, got code=%t", wantCode, gotCode) } if gotMethod != wantMethod { t.Errorf("wanted method=%t for counter, got method=%t", wantMethod, gotMethod) } - gotCode, gotMethod = checkLabels(o) + if gotPath != wantPath { + t.Errorf("wanted path=%t for counter, got path=%t", wantPath, gotPath) + } + gotCode, gotMethod, gotPath = checkLabels(o) if gotCode != wantCode { t.Errorf("wanted code=%t for observer, got code=%t", wantCode, gotCode) } if gotMethod != wantMethod { t.Errorf("wanted method=%t for observer, got method=%t", wantMethod, gotMethod) } + if gotPath != wantPath { + t.Errorf("wanted path=%t for observer, got path=%t", wantPath, gotPath) + } } }) } @@ -209,6 +224,7 @@ func TestLabels(t *testing.T) { scenarios := map[string]struct { varLabels []string reqMethod string + reqPath string respStatus int extraMethods []string wantLabels prometheus.Labels @@ -218,12 +234,14 @@ func TestLabels(t *testing.T) { varLabels: []string{}, wantLabels: emptyLabels, reqMethod: "GET", + reqPath: "/path", respStatus: 200, ok: true, }, "code as single var label": { varLabels: []string{"code"}, reqMethod: "GET", + reqPath: "/path", respStatus: 200, wantLabels: prometheus.Labels{"code": "200"}, ok: true, @@ -231,6 +249,7 @@ func TestLabels(t *testing.T) { "code as single var label and out-of-range code": { varLabels: []string{"code"}, reqMethod: "GET", + reqPath: "/path", respStatus: 99, wantLabels: prometheus.Labels{"code": "unknown"}, ok: true, @@ -238,6 +257,7 @@ func TestLabels(t *testing.T) { "code as single var label and in-range but unrecognized code": { varLabels: []string{"code"}, reqMethod: "GET", + reqPath: "/path", respStatus: 308, wantLabels: prometheus.Labels{"code": "308"}, ok: true, @@ -245,6 +265,7 @@ func TestLabels(t *testing.T) { "method as single var label": { varLabels: []string{"method"}, reqMethod: "GET", + reqPath: "/path", respStatus: 200, wantLabels: prometheus.Labels{"method": "get"}, ok: true, @@ -252,15 +273,25 @@ func TestLabels(t *testing.T) { "method as single var label and unknown method": { varLabels: []string{"method"}, reqMethod: "CUSTOM_METHOD", + reqPath: "/path", respStatus: 200, wantLabels: prometheus.Labels{"method": "unknown"}, ok: true, }, - "code and method as var labels": { - varLabels: []string{"method", "code"}, + "path as single var label": { + varLabels: []string{"path"}, + reqMethod: "GET", + reqPath: "/path", + respStatus: 200, + wantLabels: prometheus.Labels{"path": "/path"}, + ok: true, + }, + "code, method and path as var labels": { + varLabels: []string{"method", "code", "path"}, reqMethod: "GET", + reqPath: "/path", respStatus: 200, - wantLabels: prometheus.Labels{"method": "get", "code": "200"}, + wantLabels: prometheus.Labels{"method": "get", "code": "200", "path": "/path"}, ok: true, }, "method as single var label with extra methods specified": { @@ -279,13 +310,15 @@ func TestLabels(t *testing.T) { ok: false, }, } - checkLabels := func(labels []string) (gotCode, gotMethod bool) { + checkLabels := func(labels []string) (gotCode, gotMethod, gotPath bool) { for _, label := range labels { switch label { case "code": gotCode = true case "method": gotMethod = true + case "path": + gotPath = true default: panic("metric partitioned with non-supported labels for this test") } @@ -311,8 +344,8 @@ func TestLabels(t *testing.T) { for name, sc := range scenarios { t.Run(name, func(t *testing.T) { if sc.ok { - gotCode, gotMethod := checkLabels(sc.varLabels) - gotLabels := labels(gotCode, gotMethod, sc.reqMethod, sc.respStatus, sc.extraMethods...) + gotCode, gotMethod, gotPath := checkLabels(sc.varLabels) + gotLabels := labels(gotCode, gotMethod, gotPath, sc.reqMethod, sc.respStatus, sc.reqPath, sc.extraMethods...) if !equalLabels(gotLabels, sc.wantLabels) { t.Errorf("wanted labels=%v for counter, got code=%v", sc.wantLabels, gotLabels) } @@ -334,7 +367,7 @@ func makeInstrumentedHandler(handler http.HandlerFunc, opts ...Option) (http.Han Name: "api_requests_total", Help: "A counter for requests to the wrapped handler.", }, - []string{"code", "method"}, + []string{"code", "method", "path"}, ) histVec := prometheus.NewHistogramVec( @@ -500,7 +533,7 @@ func ExampleInstrumentHandlerDuration() { Name: "api_requests_total", Help: "A counter for requests to the wrapped handler.", }, - []string{"code", "method"}, + []string{"code", "method", "path"}, ) // duration is partitioned by the HTTP method and handler. It uses custom diff --git a/prometheus/promhttp/option.go b/prometheus/promhttp/option.go index c590d912c..74147eca6 100644 --- a/prometheus/promhttp/option.go +++ b/prometheus/promhttp/option.go @@ -15,6 +15,7 @@ package promhttp import ( "context" + "net/http" "github.com/prometheus/client_golang/prometheus" ) @@ -26,12 +27,16 @@ type Option interface { // options store options for both a handler or round tripper. type options struct { - extraMethods []string - getExemplarFn func(requestCtx context.Context) prometheus.Labels + extraMethods []string + getExemplarFn func(requestCtx context.Context) prometheus.Labels + getRequestPathFn func(request *http.Request) string } func defaultOptions() *options { - return &options{getExemplarFn: func(ctx context.Context) prometheus.Labels { return nil }} + return &options{ + getExemplarFn: func(ctx context.Context) prometheus.Labels { return nil }, + getRequestPathFn: func(request *http.Request) string { return pathFromContext(request.Context()) }, + } } type optionApplyFunc func(*options) @@ -56,3 +61,9 @@ func WithExemplarFromContext(getExemplarFn func(requestCtx context.Context) prom o.getExemplarFn = getExemplarFn }) } + +func WithRequestPathFromContext(getRequestPathFn func(request *http.Request) string) Option { + return optionApplyFunc(func(o *options) { + o.getRequestPathFn = getRequestPathFn + }) +}