aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorStefan Majewsky <majewsky@gmx.net>2026-08-17 22:56:47 +0200
committerStefan Majewsky <majewsky@gmx.net>2026-08-17 22:56:47 +0200
commit22e8f3548a72c78deccedc5a65c1cba029f52e6d (patch)
tree9b4ad006f91ea2c6c3c97d00f665f79e544c79c5
parenta48f97c25d421c0da21467f8e0e736c19c58c6e1 (diff)
downloadgo-gg-22e8f3548a72c78deccedc5a65c1cba029f52e6d.tar.gz
add testing/microprom, initial parity with promhttp
-rw-r--r--internal/accept/accept.go127
-rw-r--r--microprom/handler.go82
-rw-r--r--microprom/microprom.go20
-rw-r--r--testing/go.mod14
-rw-r--r--testing/go.sum40
-rw-r--r--testing/microprom/handler_test.go101
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
+}