From 11cda2079634b21b00337f4cb721c0ecd7aec6db Mon Sep 17 00:00:00 2001 From: Oleg Zaytsev Date: Wed, 3 Dec 2025 10:10:16 +0000 Subject: [PATCH 1/5] Add metric filtering support to promhttp.HandlerForTransactional This commit adds support for filtering metrics by name using the name[] query parameter in HandlerForTransactional. Multiple metric names can be specified by providing the parameter multiple times. Example usage: /metrics?name[]=http_requests_total&name[]=process_cpu_seconds_total Implementation details: - Query parameters are parsed and converted to a map for O(1) lookup - Filtering happens inline during the encoding loop to avoid allocating a new slice - When no name[] parameters are provided, all metrics are returned (backward compatible) - When name[] parameters don't match any metrics, an empty response is returned with HTTP 200 Tests include: - Single and multiple metric filtering - Backward compatibility (no filter returns all) - Non-matching filters - Empty and duplicate values - Verification that transactional gather lifecycle is maintained Signed-off-by: Oleg Zaytsev --- prometheus/promhttp/http.go | 18 +++++ prometheus/promhttp/http_test.go | 122 +++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) diff --git a/prometheus/promhttp/http.go b/prometheus/promhttp/http.go index fd3c76342..c71c2502d 100644 --- a/prometheus/promhttp/http.go +++ b/prometheus/promhttp/http.go @@ -112,6 +112,10 @@ func HandlerFor(reg prometheus.Gatherer, opts HandlerOpts) http.Handler { // HandlerForTransactional is like HandlerFor, but it uses transactional gather, which // can safely change in-place returned *dto.MetricFamily before call to `Gather` and after // call to `done` of that `Gather`. +// +// The handler supports filtering metrics by name using the `name[]` query parameter. +// Multiple metric names can be specified by providing the parameter multiple times. +// When no name[] parameters are provided, all metrics are returned. func HandlerForTransactional(reg prometheus.TransactionalGatherer, opts HandlerOpts) http.Handler { var ( inFlightSem chan struct{} @@ -245,7 +249,21 @@ func HandlerForTransactional(reg prometheus.TransactionalGatherer, opts HandlerO return false } + // Build metric name filter set from query params (if any) + var metricFilter map[string]struct{} + if metricNames := req.URL.Query()["name[]"]; len(metricNames) > 0 { + metricFilter = make(map[string]struct{}, len(metricNames)) + for _, name := range metricNames { + metricFilter[name] = struct{}{} + } + } + for _, mf := range mfs { + if metricFilter != nil { + if _, ok := metricFilter[mf.GetName()]; !ok { + continue + } + } if handleError(enc.Encode(mf)) { return } diff --git a/prometheus/promhttp/http_test.go b/prometheus/promhttp/http_test.go index 189ee357c..2cc246654 100644 --- a/prometheus/promhttp/http_test.go +++ b/prometheus/promhttp/http_test.go @@ -640,3 +640,125 @@ func BenchmarkCompression(b *testing.B) { } } } + +func TestHandlerWithMetricFilter(t *testing.T) { + reg := prometheus.NewRegistry() + + counter := prometheus.NewCounter(prometheus.CounterOpts{ + Name: "test_counter", + Help: "A test counter.", + }) + gauge := prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "test_gauge", + Help: "A test gauge.", + }) + histogram := prometheus.NewHistogram(prometheus.HistogramOpts{ + Name: "test_histogram", + Help: "A test histogram.", + }) + + reg.MustRegister(counter, gauge, histogram) + counter.Inc() + gauge.Set(42) + histogram.Observe(3.14) + + mReg := &mockTransactionGatherer{g: reg} + handler := HandlerForTransactional(mReg, HandlerOpts{}) + + testCases := []struct { + name string + url string + shouldContain []string + shouldNotContain []string + }{ + { + name: "single metric filter", + url: "/?name[]=test_counter", + shouldContain: []string{"test_counter"}, + shouldNotContain: []string{"test_gauge", "test_histogram"}, + }, + { + name: "multiple metric filters", + url: "/?name[]=test_counter&name[]=test_gauge", + shouldContain: []string{"test_counter", "test_gauge"}, + shouldNotContain: []string{"test_histogram"}, + }, + { + name: "no filter returns all metrics", + url: "/", + shouldContain: []string{"test_counter", "test_gauge", "test_histogram"}, + shouldNotContain: []string{}, + }, + { + name: "non-matching filter returns empty", + url: "/?name[]=nonexistent_metric", + shouldContain: []string{}, + shouldNotContain: []string{"test_counter", "test_gauge", "test_histogram"}, + }, + { + name: "empty name[] value", + url: "/?name[]=", + shouldContain: []string{}, + shouldNotContain: []string{"test_counter", "test_gauge", "test_histogram"}, + }, + { + name: "duplicate name[] values", + url: "/?name[]=test_counter&name[]=test_counter", + shouldContain: []string{"test_counter"}, + shouldNotContain: []string{"test_gauge", "test_histogram"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + writer := httptest.NewRecorder() + request, _ := http.NewRequest(http.MethodGet, tc.url, nil) + request.Header.Add(acceptHeader, acceptTextPlain) + + handler.ServeHTTP(writer, request) + + if got, want := writer.Code, http.StatusOK; got != want { + t.Errorf("got HTTP status code %d, want %d", got, want) + } + + body := writer.Body.String() + for _, expected := range tc.shouldContain { + if !strings.Contains(body, expected) { + t.Errorf("expected body to contain %q, got: %s", expected, body) + } + } + for _, notExpected := range tc.shouldNotContain { + if strings.Contains(body, notExpected) { + t.Errorf("expected body to NOT contain %q, got: %s", notExpected, body) + } + } + }) + } +} + +func TestHandlerWithMetricFilterTransactionalCalls(t *testing.T) { + reg := prometheus.NewRegistry() + + counter := prometheus.NewCounter(prometheus.CounterOpts{ + Name: "test_counter", + Help: "A test counter.", + }) + reg.MustRegister(counter) + + mReg := &mockTransactionGatherer{g: reg} + handler := HandlerForTransactional(mReg, HandlerOpts{}) + + writer := httptest.NewRecorder() + request, _ := http.NewRequest(http.MethodGet, "/?name[]=test_counter", nil) + request.Header.Add(acceptHeader, acceptTextPlain) + + handler.ServeHTTP(writer, request) + + // Verify that Gather and done are called even with filtering + if got := mReg.gatherInvoked; got != 1 { + t.Errorf("unexpected number of gather invokes, want 1, got %d", got) + } + if got := mReg.doneInvoked; got != 1 { + t.Errorf("unexpected number of done invokes, want 1, got %d", got) + } +} From 13f3eb9cd1853ee590315e643ecfb26b61df7f1b Mon Sep 17 00:00:00 2001 From: Oleg Zaytsev Date: Thu, 4 Dec 2025 18:27:32 +0000 Subject: [PATCH 2/5] Repeat comment on all methods Signed-off-by: Oleg Zaytsev --- prometheus/promhttp/http.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/prometheus/promhttp/http.go b/prometheus/promhttp/http.go index c71c2502d..e68160194 100644 --- a/prometheus/promhttp/http.go +++ b/prometheus/promhttp/http.go @@ -89,6 +89,10 @@ var gzipPool = sync.Pool{ // metrics used for instrumentation will be shared between them, providing // global scrape counts. // +// The handler supports filtering metrics by name using the `name[]` query parameter. +// Multiple metric names can be specified by providing the parameter multiple times. +// When no name[] parameters are provided, all metrics are returned. +// // This function is meant to cover the bulk of basic use cases. If you are doing // anything that requires more customization (including using a non-default // Gatherer, different instrumentation, and non-default HandlerOpts), use the @@ -105,6 +109,10 @@ func Handler() http.Handler { // Gatherers, with non-default HandlerOpts, and/or with custom (or no) // instrumentation. Use the InstrumentMetricHandler function to apply the same // kind of instrumentation as it is used by the Handler function. +// +// The handler supports filtering metrics by name using the `name[]` query parameter. +// Multiple metric names can be specified by providing the parameter multiple times. +// When no name[] parameters are provided, all metrics are returned. func HandlerFor(reg prometheus.Gatherer, opts HandlerOpts) http.Handler { return HandlerForTransactional(prometheus.ToTransactionalGatherer(reg), opts) } From 499e6f4be4737c3fac9d3630d7b0cb61d6edd0ff Mon Sep 17 00:00:00 2001 From: Oleg Zaytsev Date: Thu, 4 Dec 2025 18:29:22 +0000 Subject: [PATCH 3/5] Fail if can't create the request in the test Signed-off-by: Oleg Zaytsev --- prometheus/promhttp/http_test.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/prometheus/promhttp/http_test.go b/prometheus/promhttp/http_test.go index 2cc246654..1304f4be1 100644 --- a/prometheus/promhttp/http_test.go +++ b/prometheus/promhttp/http_test.go @@ -712,7 +712,11 @@ func TestHandlerWithMetricFilter(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { writer := httptest.NewRecorder() - request, _ := http.NewRequest(http.MethodGet, tc.url, nil) + request, err := http.NewRequest(http.MethodGet, tc.url, nil) + if err != nil { + t.Fatal(err) + } + request.Header.Add(acceptHeader, acceptTextPlain) handler.ServeHTTP(writer, request) From 0fbe04e4fd1f10f46d29da8e5e8ef815f40c5ad1 Mon Sep 17 00:00:00 2001 From: Oleg Zaytsev Date: Thu, 4 Dec 2025 18:31:01 +0000 Subject: [PATCH 4/5] Merge tests Signed-off-by: Oleg Zaytsev --- prometheus/promhttp/http_test.go | 41 +++++++++----------------------- 1 file changed, 11 insertions(+), 30 deletions(-) diff --git a/prometheus/promhttp/http_test.go b/prometheus/promhttp/http_test.go index 1304f4be1..60bed4242 100644 --- a/prometheus/promhttp/http_test.go +++ b/prometheus/promhttp/http_test.go @@ -662,9 +662,6 @@ func TestHandlerWithMetricFilter(t *testing.T) { gauge.Set(42) histogram.Observe(3.14) - mReg := &mockTransactionGatherer{g: reg} - handler := HandlerForTransactional(mReg, HandlerOpts{}) - testCases := []struct { name string url string @@ -711,6 +708,8 @@ func TestHandlerWithMetricFilter(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { + mReg := &mockTransactionGatherer{g: reg} + writer := httptest.NewRecorder() request, err := http.NewRequest(http.MethodGet, tc.url, nil) if err != nil { @@ -719,6 +718,7 @@ func TestHandlerWithMetricFilter(t *testing.T) { request.Header.Add(acceptHeader, acceptTextPlain) + handler := HandlerForTransactional(mReg, HandlerOpts{}) handler.ServeHTTP(writer, request) if got, want := writer.Code, http.StatusOK; got != want { @@ -736,33 +736,14 @@ func TestHandlerWithMetricFilter(t *testing.T) { t.Errorf("expected body to NOT contain %q, got: %s", notExpected, body) } } - }) - } -} - -func TestHandlerWithMetricFilterTransactionalCalls(t *testing.T) { - reg := prometheus.NewRegistry() - - counter := prometheus.NewCounter(prometheus.CounterOpts{ - Name: "test_counter", - Help: "A test counter.", - }) - reg.MustRegister(counter) - mReg := &mockTransactionGatherer{g: reg} - handler := HandlerForTransactional(mReg, HandlerOpts{}) - - writer := httptest.NewRecorder() - request, _ := http.NewRequest(http.MethodGet, "/?name[]=test_counter", nil) - request.Header.Add(acceptHeader, acceptTextPlain) - - handler.ServeHTTP(writer, request) - - // Verify that Gather and done are called even with filtering - if got := mReg.gatherInvoked; got != 1 { - t.Errorf("unexpected number of gather invokes, want 1, got %d", got) - } - if got := mReg.doneInvoked; got != 1 { - t.Errorf("unexpected number of done invokes, want 1, got %d", got) + // Verify that Gather and done are called even with filtering. + if got := mReg.gatherInvoked; got != 1 { + t.Errorf("unexpected number of gather invokes, want 1, got %d", got) + } + if got := mReg.doneInvoked; got != 1 { + t.Errorf("unexpected number of done invokes, want 1, got %d", got) + } + }) } } From 6ec4e4301d01088f19934c7c4fadbdee592edea9 Mon Sep 17 00:00:00 2001 From: Oleg Zaytsev Date: Fri, 5 Dec 2025 09:27:22 +0000 Subject: [PATCH 5/5] Update CHANGELOG.md Signed-off-by: Oleg Zaytsev --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bcf1cd8e..f542539c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Unreleased +* [FEATURE] HTTP handlers created by `promhttp` package now support metrics filtering by providing one or more `name[]` query parameters. The default behavior when none are provided remains the same, returning all metrics. #1925 + ## 1.23.2 / 2025-09-05 This release is made to upgrade to prometheus/common v0.66.1, which drops the dependencies github.com/grafana/regexp and go.uber.org/atomic and replaces gopkg.in/yaml.v2 with go.yaml.in/yaml/v2 (a drop-in replacement).