diff options
| author | Stefan Majewsky <majewsky@gmx.net> | 2026-08-17 22:56:47 +0200 |
|---|---|---|
| committer | Stefan Majewsky <majewsky@gmx.net> | 2026-08-17 22:56:47 +0200 |
| commit | 22e8f3548a72c78deccedc5a65c1cba029f52e6d (patch) | |
| tree | 9b4ad006f91ea2c6c3c97d00f665f79e544c79c5 | |
| parent | a48f97c25d421c0da21467f8e0e736c19c58c6e1 (diff) | |
| download | go-gg-22e8f3548a72c78deccedc5a65c1cba029f52e6d.tar.gz | |
add testing/microprom, initial parity with promhttp
| -rw-r--r-- | internal/accept/accept.go | 127 | ||||
| -rw-r--r-- | microprom/handler.go | 82 | ||||
| -rw-r--r-- | microprom/microprom.go | 20 | ||||
| -rw-r--r-- | testing/go.mod | 14 | ||||
| -rw-r--r-- | testing/go.sum | 40 | ||||
| -rw-r--r-- | testing/microprom/handler_test.go | 101 |
6 files changed, 364 insertions, 20 deletions
diff --git a/internal/accept/accept.go b/internal/accept/accept.go new file mode 100644 index 0000000..5e35f45 --- /dev/null +++ b/internal/accept/accept.go @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net> +// SPDX-License-Identifier: Apache-2.0 + +// TODO: unit test coverage (use the examples from RFC 9110) +package accept + +import ( + "cmp" + "mime" + "regexp" + "slices" + "strconv" + "strings" + + . "go.xyrillian.de/gg/option" +) + +// Header contains a parsed set of "Accept" HTTP headers [RFC 9110, 12.5.1]. +type Header struct { + options []option +} + +type option struct { + MediaType string + Params map[string]string + Weight float64 +} + +var weightRx = regexp.MustCompile(`\s*;\s*q=([01](?:\.[0-9]{0,3})?)$`) + +// ParseHeader parses a Set of "Accept" HTTP headers [RFC 9110, 12.5.1]. +// If any part of the header is malformed, an empty Header struct is returned. +func ParseHeader(headers []string) Header { + var ( + result Header + none Header // return in case of errors + ) + for _, header := range headers { + for _, section := range strings.Split(header, ",") { + section = strings.TrimSpace(section) + + // remove weight from `section` while capturing the weight number in `weightStr` + var weightStr string + section = weightRx.ReplaceAllStringFunc(section, func(match string) string { + _, weightStr, _ = strings.Cut(match, "=") + return "" + }) + + mediaType, params, err := mime.ParseMediaType(section) + if err != nil { + return none + } + opt := option{mediaType, params, 1.0} + if weightStr != "" { + opt.Weight, err = strconv.ParseFloat(weightStr, 64) + if err != nil { + return none + } + if opt.Weight > 1.0 { // this boundary is easier to express here than in the regex + return none + } + } + + result.options = append(result.options, opt) + } + } + + // sort options by descending weight to simplify lookups + slices.SortFunc(result.options, func(lhs, rhs option) int { + return cmp.Compare(rhs.Weight, lhs.Weight) + }) + return result +} + +// Negotiate picks from a list of supported media types according to the client's request. +// If h is empty, the first argument is returned (thus the first argument is the server's preference). +// If none of the arguments (the server's options) satisfy the client's request, +// None is returned and a 406 response shall be generated. +func (h Header) Negotiate(mediaTypes ...string) Option[string] { + // parse all `mediaTypes` once + // TODO: if we decide to turn this package into public API, change the API to allow precomputing this + type offer struct { + OriginalValue string + MediaType string + Params map[string]string + } + var offers []offer + for _, mt := range mediaTypes { + mediaType, params, err := mime.ParseMediaType(mt) + if err == nil { + offers = append(offers, offer{mt, mediaType, params}) + } + } + + // we cannot choose from an empty set of options (this can only happen if the + // caller gave us no or only malformed media types) + if len(mediaTypes) == 0 { + return None[string]() + } + + // NOTE: ParseHeader() sorts options by descending weight, so the first match wins. + for _, opt := range h.options { + MEDIATYPE: + for _, offer := range offers { + // can only consider offered media types that match on all requested parameters + for k, v1 := range opt.Params { + if v2, ok := offer.Params[k]; !ok || v1 != v2 { + continue MEDIATYPE + } + } + + // check if offered media type matches requested media type or media type pattern + if opt.MediaType == "*/*" { + return Some(offer.OriginalValue) + } + if category, ok := strings.CutSuffix(opt.MediaType, "/*"); ok { + if rest, ok := strings.CutPrefix(offer.MediaType, category); ok && strings.HasPrefix(rest, "/") { + return Some(offer.OriginalValue) + } + } else if opt.MediaType == offer.MediaType { + return Some(offer.OriginalValue) + } + } + } + + return Some(offers[0].OriginalValue) +} diff --git a/microprom/handler.go b/microprom/handler.go index db5e53f..a505df3 100644 --- a/microprom/handler.go +++ b/microprom/handler.go @@ -12,6 +12,8 @@ import ( "net/http" "slices" "strings" + + "go.xyrillian.de/gg/internal/accept" ) // Handler is an [http.Handler] rendering metrics in Prometheus exposition formats. @@ -42,29 +44,57 @@ var _ http.Handler = Handler{} // ServeHTTP implements the [http.Handler] interface. func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - ms := NewMetricSet(SyntaxOpenMetricsV1, h.Families) + acceptedFormat, ok := accept.ParseHeader(r.Header["Accept"]).Negotiate( + // The way that Prometheus handles `Accept` is insane. + // They put a billion parameters in there, with `escaping=` possibly depending on server configuration. + // (I have not read enough of the Prometheus source code to be sure.) + // The easiest way for us is to negotiate for all possible combinations. + // + // Note that it is fine for the client to request less specific formats, e.g. just "application/openmetrics-text", + // in which case the first match will be used. + // Each set of similar choices has `escaping=underscores` on top each time because that's the default escaping scheme in promhttp. + "text/plain; version=0.0.4; charset=utf-8; escaping=underscores", + "text/plain; version=0.0.4; charset=utf-8; escaping=allow-utf-8", + "text/plain; version=0.0.4; charset=utf-8; escaping=dots", + "text/plain; version=0.0.4; charset=utf-8; escaping=values", + "application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=underscores", + "application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=allow-utf-8", + "application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=dots", + "application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=values", + ).Unpack() + if !ok { + http.Error(w, "supported formats are text/plain and application/openmetrics-text", http.StatusNotAcceptable) + return + } + + w.Header().Set("Content-Type", acceptedFormat) + syntax := SyntaxPrometheusLegacy + if strings.HasPrefix(acceptedFormat, "application/openmetrics-text; version=1.0.0;") { + syntax = SyntaxOpenMetricsV1 + } + + ms := NewMetricSet(syntax, h.Families) err := h.Collect(r.Context(), ms) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - // TODO: add support for `Content-Type: application/openmetrics-text; version=1.0.0; charset=utf-8` if requested in `Accept` header - w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8; escaping=underscores") w.WriteHeader(http.StatusOK) - bw := bufio.NewWriter(w) if h.SortOutput { for _, familyName := range slices.Sorted(maps.Keys(h.Families)) { - h.printMetricFamily(bw, familyName, h.Families[familyName], ms.metrics[familyName]) + h.printMetricFamily(bw, syntax, familyName, h.Families[familyName], ms.metrics[familyName]) } } else { for familyName, familyInfo := range h.Families { - h.printMetricFamily(bw, familyName, familyInfo, ms.metrics[familyName]) + h.printMetricFamily(bw, syntax, familyName, familyInfo, ms.metrics[familyName]) } } - fmt.Fprint(bw, "# EOF\n") + if syntax != SyntaxPrometheusLegacy { + fmt.Fprint(bw, "# EOF\n") + } err = bw.Flush() if err != nil { // We do not have a way to log this because we do not know what log library the application uses, @@ -75,7 +105,7 @@ func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } -func (h Handler) printMetricFamily(w io.Writer, familyName MetricFamilyName, info MetricFamilyInfo, metrics []metric) { +func (h Handler) printMetricFamily(w io.Writer, syntax Syntax, familyName MetricFamilyName, info MetricFamilyInfo, metrics []metric) { if len(metrics) == 0 { return } @@ -92,6 +122,11 @@ func (h Handler) printMetricFamily(w io.Writer, familyName MetricFamilyName, inf panic("unreachable") // NewMetricSet() should have rejected unknown MetricType values } + if syntax == SyntaxPrometheusLegacy { + // Prometheus Text Format does not distinguish between metric names and metric family names + familyName = MetricFamilyName(metricName) + } + fmt.Fprintf(w, "# HELP %s %s\n# TYPE %s %s\n", familyName, info.Help, familyName, metricTypeNames[info.Type]) if h.SortOutput { @@ -101,9 +136,36 @@ func (h Handler) printMetricFamily(w io.Writer, familyName MetricFamilyName, inf } for _, m := range metrics { if m.labels == "" { - fmt.Fprintf(w, "%s %g\n", metricName, m.value) + fmt.Fprintf(w, "%s ", metricName) + } else { + fmt.Fprintf(w, "%s{%s} ", metricName, m.labels) + } + if syntax == SyntaxPrometheusLegacy { + fmt.Fprintf(w, "%g\n", m.value) } else { - fmt.Fprintf(w, "%s{%s} %g\n", metricName, m.labels, m.value) + // TODO: ugly + fi := floatInspector{inner: w} + fmt.Fprintf(&fi, "%g", m.value) + if fi.clearlyFloat { + fmt.Fprintf(w, "\n") + } else { + fmt.Fprintf(w, ".0\n") + } } } } + +type floatInspector struct { + inner io.Writer + clearlyFloat bool +} + +func (fi *floatInspector) Write(buf []byte) (int, error) { + if slices.Contains(buf, '.') { + fi.clearlyFloat = true + } + if slices.Contains(buf, 'e') { + fi.clearlyFloat = true + } + return fi.inner.Write(buf) +} diff --git a/microprom/microprom.go b/microprom/microprom.go index 2b6305d..86a5419 100644 --- a/microprom/microprom.go +++ b/microprom/microprom.go @@ -62,6 +62,10 @@ func (i MetricFamilyInfo) validate(name MetricFamilyName) error { // // ^[a-zA-Z_:][a-zA-Z0-9_:]*$ // +// Package microprom does not implement escaping at the moment; +// metric family names not matching this pattern are invalid and will cause a panic. +// This restriction may be lifted in a future version. +// // [OpenMetrics 1.0]: https://prometheus.io/docs/specs/om/open_metrics_spec/ type MetricFamilyName string @@ -109,7 +113,7 @@ type metric struct { // NewMetricSet constructs an initially empty [MetricSet] that accepts metrics for the given metric families. func NewMetricSet(syntax Syntax, families map[MetricFamilyName]MetricFamilyInfo) *MetricSet { - if syntax != SyntaxOpenMetricsV1 { + if syntax > SyntaxOpenMetricsV1 { panic(fmt.Sprintf("unknown value for Syntax: %d", syntax)) } m := make(map[MetricFamilyName][]metric, len(families)) @@ -138,15 +142,17 @@ func (ms *MetricSet) Add(name MetricFamilyName, labels Labels, value float64) { // Syntax is an enum, defining which exposition format will be used by [MetricSet]. // -// - SyntaxOpenMetricsV1 corresponds to the [OpenMetrics 1.0] text format, -// which is functionally equivalent to the Prometheus text format v0.0.4. -// - Additional formats may be added in the future -// (e.g. OpenMetrics 2.0, once it is stabilized). +// - SyntaxPrometheusLegacy corresponds to the [Prometheus Text Format]. +// - SyntaxOpenMetricsV1 corresponds to the [OpenMetrics 1.0] text format +// - Additional formats may be added in the future (e.g. OpenMetrics 2.0, once it is stabilized). // +// [Prometheus Text Format]: https://prometheus.io/docs/instrumenting/exposition_formats/ // [OpenMetrics 1.0]: https://prometheus.io/docs/specs/om/open_metrics_spec/ -type Syntax int +type Syntax uint const ( + // SyntaxPrometheusLegacy corresponds to the Prometheus text format (currently version 0.0.4). + SyntaxPrometheusLegacy Syntax = iota // SyntaxOpenMetricsV1 corresponds to the OpenMetrics 1.0 text format. - SyntaxOpenMetricsV1 Syntax = iota + SyntaxOpenMetricsV1 ) diff --git a/testing/go.mod b/testing/go.mod index 9206521..9c371c2 100644 --- a/testing/go.mod +++ b/testing/go.mod @@ -4,5 +4,17 @@ go 1.26 require ( github.com/lib/pq v1.12.3 - go.xyrillian.de/gg v1.13.0 + go.xyrillian.de/gg v1.13.4-0.20260817144159-a48f97c25d42 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_golang v1.24.1 + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.70.1 // indirect + github.com/prometheus/procfs v0.21.1 // indirect + golang.org/x/sys v0.47.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/testing/go.sum b/testing/go.sum index df1974e..6a62175 100644 --- a/testing/go.sum +++ b/testing/go.sum @@ -1,4 +1,40 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= -go.xyrillian.de/gg v1.12.1-0.20260731210839-e26a214de395 h1:nA6DhnjgGw1sg+Piv4xZupuoD0dM+x4Fyd91Gi7XgOo= -go.xyrillian.de/gg v1.12.1-0.20260731210839-e26a214de395/go.mod h1:DoO4fQSWIrBRlNlCjVyrYM0kAEBt/Jg2GkMH+cGRZ0k= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= +github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= +github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.xyrillian.de/gg v1.13.4-0.20260817144159-a48f97c25d42 h1:QACQsLeI3fAWQBbZZ2/VCXBjGeeY9v+vsXnzsl0hJig= +go.xyrillian.de/gg v1.13.4-0.20260817144159-a48f97c25d42/go.mod h1:DoO4fQSWIrBRlNlCjVyrYM0kAEBt/Jg2GkMH+cGRZ0k= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/testing/microprom/handler_test.go b/testing/microprom/handler_test.go new file mode 100644 index 0000000..6aa60f0 --- /dev/null +++ b/testing/microprom/handler_test.go @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net> +// SPDX-License-Identifier: Apache-2.0 + +package microprom_test + +import ( + "context" + "fmt" + "io" + "maps" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" + "go.xyrillian.de/gg/assert" + "go.xyrillian.de/gg/microprom" +) + +func TestHandlerFunctionallyIdenticalToPromhttp(t *testing.T) { + // build a microprom.Handler rendering two metric families + h1 := microprom.Handler{ + Families: map[microprom.MetricFamilyName]microprom.MetricFamilyInfo{ + "events": { + Type: microprom.MetricTypeCounter, + Help: "Counts events that happened.", + }, + "memory_usage_bytes": { + Type: microprom.MetricTypeGauge, + Help: "How much memory is currently used.", + }, + }, + SortOutput: true, + Collect: func(ctx context.Context, ms *microprom.MetricSet) error { + labelNames := microprom.NewLabelNames("shard", "type") + for idx := range 5 { + labels := ms.FormatLabels(labelNames, fmt.Sprintf("node%d", idx), "update") + ms.Add("events", labels, float64(10*idx)) + } + ms.Add("memory_usage_bytes", "", 42<<20) + return nil + }, + } + + // build a promhttp.Handler rendering the same metric families + eventsCounter := prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "events_total", + Help: "Counts events that happened.", + }, []string{"type", "shard"}) + for idx := range 5 { + eventsCounter.With(prometheus.Labels{ + "type": "update", + "shard": fmt.Sprintf("node%d", idx), + }).Add(float64(10 * idx)) + } + memoryUsageBytesGauge := prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "memory_usage_bytes", + Help: "How much memory is currently used.", + }) + memoryUsageBytesGauge.Set(42 << 20) + r := prometheus.NewRegistry() + r.MustRegister(eventsCounter) + r.MustRegister(memoryUsageBytesGauge) + h2 := promhttp.HandlerFor(r, promhttp.HandlerOpts{EnableOpenMetrics: true}) + + // test identical behavior for Prometheus Text Format + body1, headers1 := getMetrics(t, h1, nil) + body2, headers2 := getMetrics(t, h2, nil) + assert.Equal(t, strings.Split(body1, "\n"), strings.Split(body2, "\n")) + assert.Equal(t, headers1, headers2) + + // test identical behavior for OpenMetrics 1.0 text format + body1, headers1 = getMetrics(t, h1, http.Header{"Accept": {"application/openmetrics-text; version=1.0.0"}}) + body2, headers2 = getMetrics(t, h2, http.Header{"Accept": {"application/openmetrics-text; version=1.0.0"}}) + assert.Equal(t, strings.Split(body1, "\n"), strings.Split(body2, "\n")) + assert.Equal(t, headers1, headers2) + + // test invalid Accept header + body1, headers1 = getMetrics(t, h1, http.Header{"Accept": {"image/*"}}) + body2, headers2 = getMetrics(t, h2, http.Header{"Accept": {"image/*"}}) + assert.Equal(t, strings.Split(body1, "\n"), strings.Split(body2, "\n")) + assert.Equal(t, headers1, headers2) +} + +func getMetrics(t *testing.T, h http.Handler, requestHeaders http.Header) (responseBody string, responseHeaders http.Header) { + t.Helper() + r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/metrics", nil) + maps.Copy(r.Header, requestHeaders) + + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + resp := w.Result() + + buf, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err.Error()) + } + return string(buf), resp.Header +} |
