diff options
| author | Stefan Majewsky <majewsky@gmx.net> | 2026-08-18 13:40:30 +0200 |
|---|---|---|
| committer | Stefan Majewsky <majewsky@gmx.net> | 2026-08-18 13:40:30 +0200 |
| commit | 5cc0f9db288ffaf44d104974707338d52530cece (patch) | |
| tree | f0ad530dd072c55c6f46dfea23af993fb31333d4 | |
| parent | ab5012e0083ff6623ffab78f52f9ea1120d80ffb (diff) | |
| parent | a3ccdd9f491c20771cfda343ebebd4b5d0ea6602 (diff) | |
| download | go-gg-5cc0f9db288ffaf44d104974707338d52530cece.tar.gz | |
Merge branch 'microprom'
| -rw-r--r-- | CHANGELOG.md | 7 | ||||
| -rw-r--r-- | README.md | 1 | ||||
| -rw-r--r-- | assert/assert.go | 37 | ||||
| -rw-r--r-- | assert/errequal_test.go | 8 | ||||
| -rw-r--r-- | assert/panic.go | 32 | ||||
| -rw-r--r-- | assert/panic_test.go | 35 | ||||
| -rw-r--r-- | internal/accept/accept.go | 137 | ||||
| -rw-r--r-- | internal/accept/accept_test.go | 94 | ||||
| -rw-r--r-- | microprom/handler.go | 171 | ||||
| -rw-r--r-- | microprom/handler_test.go | 142 | ||||
| -rw-r--r-- | microprom/labels.go | 94 | ||||
| -rw-r--r-- | microprom/labels_test.go | 24 | ||||
| -rw-r--r-- | microprom/microprom.go | 158 | ||||
| -rw-r--r-- | pathrouter/pathrouter_test.go | 8 | ||||
| -rw-r--r-- | testcapture/capture.go | 59 | ||||
| -rw-r--r-- | testcapture/testcapture.go | 43 | ||||
| -rw-r--r-- | testing/go.mod | 14 | ||||
| -rw-r--r-- | testing/go.sum | 40 | ||||
| -rw-r--r-- | testing/microprom/handler_test.go | 95 |
19 files changed, 1119 insertions, 80 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 4033682..d48fd23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net> SPDX-License-Identifier: Apache-2.0 --> +# v1.14.0 (TBD) + +Changes: + +- Add package microprom. +- Add `assert.Panics()` and `assert.PanicsWith()`. + # v1.13.3 (2026-08-05) Changes: @@ -28,6 +28,7 @@ My personal extension of the standard library. ### Addons for net/http - [assetembed](./assetembed/): HTTP handler for efficiently serving embedded assets using the cache-busting pattern +- [microprom](./microprom/): a minimal alternative implementation of [promhttp](https://pkg.go.dev/github.com/prometheus/client_golang/prometheus/promhttp) - [pathrouter](./pathrouter/): HTTP router that differentiates endpoints based on paths without any regex matching ### Addons for testing diff --git a/assert/assert.go b/assert/assert.go index a13c15e..fe725f6 100644 --- a/assert/assert.go +++ b/assert/assert.go @@ -5,38 +5,9 @@ // Each assertion in this package returns a bool to indicate whether the check succeeded, and logs a t.Error() when the check does not succeed. package assert -import ( - "context" - "io" - "testing" -) +import "go.xyrillian.de/gg/testcapture" // TestingTB contains all the public functions of [testing.TB] (as of Go 1.26). -// Functions in this package use this type instead of [testing.TB] because the capture device used by package testcapture cannot implement [testing.TB]: It contains methods that are private to the standard library. -type TestingTB interface { - ArtifactDir() string - Attr(key, value string) - Chdir(dir string) - Cleanup(func()) - Context() context.Context - Error(args ...any) - Errorf(format string, args ...any) - Fail() - Failed() bool - FailNow() - Fatal(args ...any) - Fatalf(format string, args ...any) - Helper() - Log(args ...any) - Logf(format string, args ...any) - Name() string - Output() io.Writer - Setenv(key, value string) - Skip(args ...any) - Skipf(format string, args ...any) - SkipNow() - Skipped() bool - TempDir() string -} - -var _ TestingTB = testing.TB(nil) +// Functions in this package use this type instead of [testing.TB] to allow +// mocks of [testing.TB] to be substituted in tests for this package. +type TestingTB = testcapture.TestingTB diff --git a/assert/errequal_test.go b/assert/errequal_test.go index b97342a..2cefeab 100644 --- a/assert/errequal_test.go +++ b/assert/errequal_test.go @@ -11,7 +11,6 @@ import ( "testing" "go.xyrillian.de/gg/assert" - "go.xyrillian.de/gg/testcapture" ) func TestErrEqual(t *testing.T) { @@ -67,13 +66,10 @@ func TestErrEqual(t *testing.T) { }, `expected an error matching /foo/, but got no error`) // test matching against unexpected type - result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) { + result := assert.PanicsWith[string](t, func() { assert.ErrEqual(t, errors.New("42"), 42) }) - assert.Equal(t, result, testcapture.Result{ - Outcome: testcapture.OutcomePanicked, - Panic: "cannot handle `expected` of type int", - }) + assert.Equal(t, result, "cannot handle `expected` of type int") // an earlier version had a bug because this call caused reflect.Value.IsNil() to be called on a value of kind Struct expectErrors(t, func(t assert.TestingTB) { diff --git a/assert/panic.go b/assert/panic.go new file mode 100644 index 0000000..e19b815 --- /dev/null +++ b/assert/panic.go @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net> +// SPDX-License-Identifier: Apache-2.0 + +package assert + +import ( + "go.xyrillian.de/gg/testcapture" +) + +// Panics runs the provided action and fails the test if it does not panic. +// On success, the error value passed to the call of panic is returned. +func Panics(t TestingTB, action func()) any { + return PanicsWith[any](t, action) +} + +// PanicsWith is like Panics(), but also checks if the recovered error value +// is of type T, failing the test if the type assertion fails. +func PanicsWith[T any](t TestingTB, action func()) T { + t.Helper() + result := testcapture.Capture(t.Context(), t.Name(), func(_ TestingTB) { + action() + }) + if result.Outcome != testcapture.OutcomePanicked { + t.Fatal("did not panic") + } + value, ok := result.Panic.(T) + if !ok { + var zero T + t.Fatalf("panicked with incorrect type: expected %T, but got %T: %#v", zero, result.Panic, result.Panic) + } + return value +} diff --git a/assert/panic_test.go b/assert/panic_test.go new file mode 100644 index 0000000..a3d6877 --- /dev/null +++ b/assert/panic_test.go @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net> +// SPDX-License-Identifier: Apache-2.0 + +package assert_test + +import ( + "testing" + + "go.xyrillian.de/gg/assert" + "go.xyrillian.de/gg/testcapture" +) + +func TestPanics(t *testing.T) { + //only testing error cases here, coverage of the happy path is provided by + //usage of assert.Panics() in actual tests of other packages + tc := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) { + assert.Panics(t, func() { + // no panic + }) + }) + assert.Equal(t, tc.Outcome, testcapture.OutcomeFailed) + assert.Equal(t, tc.Messages, []testcapture.Message{ + testcapture.Log("did not panic"), + }) + + tc = testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) { + assert.PanicsWith[string](t, func() { + panic(42) + }) + }) + assert.Equal(t, tc.Outcome, testcapture.OutcomeFailed) + assert.Equal(t, tc.Messages, []testcapture.Message{ + testcapture.Log("panicked with incorrect type: expected string, but got int: 42"), + }) +} diff --git a/internal/accept/accept.go b/internal/accept/accept.go new file mode 100644 index 0000000..c23905a --- /dev/null +++ b/internal/accept/accept.go @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net> +// SPDX-License-Identifier: Apache-2.0 + +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 + } + if _, ok := params["q"]; ok { + // malformed q-value that was not caught by the regex + return none + } + opt := option{mediaType, params, 1.0} + if weightStr != "" { + opt.Weight, err = strconv.ParseFloat(weightStr, 64) + if err != nil { + // defense in depth: unreachable because the regex match has + // extremely constrained grammar for `weightStr` + 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(offers) == 0 { + return None[string]() + } + + // if nothing was offered, we default to our own preferred option + if len(h.options) == 0 { + return Some(offers[0].OriginalValue) + } + + // 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 None[string]() +} diff --git a/internal/accept/accept_test.go b/internal/accept/accept_test.go new file mode 100644 index 0000000..edfef42 --- /dev/null +++ b/internal/accept/accept_test.go @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net> +// SPDX-License-Identifier: Apache-2.0 + +package accept_test + +import ( + "testing" + + "go.xyrillian.de/gg/assert" + "go.xyrillian.de/gg/internal/accept" + . "go.xyrillian.de/gg/option" +) + +func TestAcceptWithHeader(t *testing.T) { + // asking for a wide range of formats, including wildcard matches + h := accept.ParseHeader([]string{"text/*;q=0.3, text/plain;format=flowed, text/plain;format=fixed;q=0.4, */*;q=0.5"}) + + assert.Equal(t, h.Negotiate( + "image/png", // matches with q=0.5 + "text/plain; format=fixed", // matches with q=0.4 + ), Some("image/png")) + + assert.Equal(t, h.Negotiate( + "image/png", // matches with q=0.5 + "text/plain; format=flowed", // matches with q=1.0 + ), Some("text/plain; format=flowed")) + + assert.Equal(t, h.Negotiate( + "text/plain", // matches with q=0.7 + "text/plain; format=flowed", // matches with q=1.0 + ), Some("text/plain; format=flowed")) + + assert.Equal(t, h.Negotiate( + "text/plain", // matches with q=0.7 + "text/plain; format=other", // matches with q=0.3 + ), Some("text/plain")) + + assert.Equal(t, h.Negotiate( + "text/markdown", // matches with q=0.3 + "text/plain", // matches with q=0.3 (but first wins) + ), Some("text/markdown")) + + // asking for specific formats only + h = accept.ParseHeader([]string{"image/png, image/jpeg"}) + + assert.Equal(t, h.Negotiate( + "text/plain", + "image/png", + ), Some("image/png")) + + assert.Equal(t, h.Negotiate( + "text/plain", + ), None[string]()) +} + +func TestAcceptWithoutHeader(t *testing.T) { + // Negotiate() will always pick the first option + h := accept.ParseHeader(nil) + + assert.Equal(t, h.Negotiate( + "image/png", + "image/jpeg", + ), Some("image/png")) + + assert.Equal(t, h.Negotiate(nil...), None[string]()) + + // malformed media types are ignored + assert.Equal(t, h.Negotiate( + "image/png/foo", + "image/jpeg", + ), Some("image/jpeg")) + + assert.Equal(t, h.Negotiate( + "image/png/foo", + "image/jpeg/foo", + ), None[string]()) +} + +func TestAcceptWithMalformedHeader(t *testing.T) { + for _, brokenHeader := range []string{ + "text/plain, text/markdown/foo", // malformed media type + "text/plain, image/png; q=high", // malformed q-value + "text/plain, image/jpeg; q=1.25", // q-value out of range + } { + h := accept.ParseHeader([]string{brokenHeader}) + + // broken headers are ignored completely, so the first option wins by default + assert.Equal(t, h.Negotiate( + "image/png", + "image/jpeg", + "text/plain", + ), Some("image/png")) + } +} diff --git a/microprom/handler.go b/microprom/handler.go new file mode 100644 index 0000000..a505df3 --- /dev/null +++ b/microprom/handler.go @@ -0,0 +1,171 @@ +// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net> +// SPDX-License-Identifier: Apache-2.0 + +package microprom + +import ( + "bufio" + "context" + "fmt" + "io" + "maps" + "net/http" + "slices" + "strings" + + "go.xyrillian.de/gg/internal/accept" +) + +// Handler is an [http.Handler] rendering metrics in Prometheus exposition formats. +// +// If SortOutput is false: +// - Metric families will be printed in undefined order. +// - Metrics within the same family will be printed in the order in which they were added. +// - This behavior is the default because it is more efficient. +// +// If SortOutput is true: +// - Metric families will be sorted by name. +// - Metrics within the same family will be sorted by Labels. +// - This behavior may be useful in tests because it produces deterministic output. +// +// When asserting on metrics in tests, it may be useful to set SortOutput equal to testing.Testing(). +type Handler struct { + // The set of metric families for which this handler can report metrics. + Families map[MetricFamilyName]MetricFamilyInfo + // This function will be called for each request to the handler. + // The implementation shall provide metrics by calling [MetricSet.Add]. + Collect func(context.Context, *MetricSet) error + + // See documentation on type for details. + SortOutput bool +} + +var _ http.Handler = Handler{} + +// ServeHTTP implements the [http.Handler] interface. +func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + 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 + } + + w.WriteHeader(http.StatusOK) + bw := bufio.NewWriter(w) + if h.SortOutput { + for _, familyName := range slices.Sorted(maps.Keys(h.Families)) { + h.printMetricFamily(bw, syntax, familyName, h.Families[familyName], ms.metrics[familyName]) + } + } else { + for familyName, familyInfo := range h.Families { + h.printMetricFamily(bw, syntax, familyName, familyInfo, ms.metrics[familyName]) + } + } + + 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, + // and I also do not want to add a dependency injection slot to type Handler for this one extremely unlikely codepath. + // So instead, we're just going to wreck the response body and hope that Prometheus + // or whatever else receives this logs this as a syntax error or something. + fmt.Fprintf(w, "flush error: %s\n", err.Error()) + } +} + +func (h Handler) printMetricFamily(w io.Writer, syntax Syntax, familyName MetricFamilyName, info MetricFamilyInfo, metrics []metric) { + if len(metrics) == 0 { + return + } + + var metricName string + switch info.Type { + case MetricTypeGauge: + metricName = string(familyName) + case MetricTypeCounter: + metricName = string(familyName) + "_total" + case MetricTypeInfo: + metricName = string(familyName) + "_info" + default: + 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 { + slices.SortFunc(metrics, func(lhs, rhs metric) int { + return strings.Compare(string(lhs.labels), string(rhs.labels)) + }) + } + for _, m := range metrics { + if m.labels == "" { + 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 { + // 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/handler_test.go b/microprom/handler_test.go new file mode 100644 index 0000000..7278945 --- /dev/null +++ b/microprom/handler_test.go @@ -0,0 +1,142 @@ +// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net> +// SPDX-License-Identifier: Apache-2.0 + +package microprom_test + +import ( + "context" + "errors" + "io" + "maps" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "go.xyrillian.de/gg/assert" + "go.xyrillian.de/gg/microprom" +) + +func TestHandlerBasic(t *testing.T) { + // NOTE: Most happy path coverage is in `./testing/microprom`. + // This only covers the SortOutput = false case. + + h := microprom.Handler{ + Families: map[microprom.MetricFamilyName]microprom.MetricFamilyInfo{ + "process": { + Type: microprom.MetricTypeInfo, + Help: "Information about this process.", + }, + "foo": { + Type: microprom.MetricTypeGauge, + Help: "This metric family will not have any collected metrics and thus go unreported.", + }, + }, + Collect: func(ctx context.Context, ms *microprom.MetricSet) error { + names := microprom.NewLabelNames("version") + labels := ms.FormatLabels(names, "1.2.3") + ms.Add("process", labels, 1.0) + return nil + }, + } + + // test normal behavior + status, body, headers := getMetrics(t, h, nil) + assert.Equal(t, status, http.StatusOK) + assert.Equal(t, headers, http.Header{ + "Content-Type": {"text/plain; version=0.0.4; charset=utf-8; escaping=underscores"}, + }) + assert.Equal(t, body, strings.TrimSpace(` +# HELP process_info Information about this process. +# TYPE process_info info +process_info{version="1.2.3"} 1 + `)+"\n") +} + +func TestHandlerErrors(t *testing.T) { + h := microprom.Handler{ + Families: map[microprom.MetricFamilyName]microprom.MetricFamilyInfo{ + "process": { + Type: microprom.MetricTypeInfo, + Help: "Information about this process.", + }, + }, + Collect: func(ctx context.Context, ms *microprom.MetricSet) error { + return errors.New("kaboom") + }, + } + + // test unacceptable content negotiation + status, body, headers := getMetrics(t, h, http.Header{"Accept": {"application/json"}}) + assert.Equal(t, status, http.StatusNotAcceptable) + assert.Equal(t, headers.Get("Content-Type"), "text/plain; charset=utf-8") + assert.Equal(t, body, "supported formats are text/plain and application/openmetrics-text\n") + + // test error during h.Collect() + status, body, headers = getMetrics(t, h, nil) + assert.Equal(t, status, http.StatusInternalServerError) + assert.Equal(t, headers.Get("Content-Type"), "text/plain; charset=utf-8") + assert.Equal(t, body, "kaboom\n") + + // test panic from invalid metric family name + h.Families["what is this?"] = microprom.MetricFamilyInfo{ + Type: microprom.MetricTypeGauge, + Help: "invalid metric family name", + } + msg := assert.PanicsWith[string](t, func() { getMetrics(t, h, nil) }) + assert.Equal(t, msg, `in family "what is this?": invalid family name (does not match /^[a-zA-Z_:][a-zA-Z0-9_:]*$/)`) + delete(h.Families, "what is this?") + + // test panic from invalid metric type + h.Families["invalid"] = microprom.MetricFamilyInfo{ + Type: 100, + Help: "invalid metric type", + } + msg = assert.PanicsWith[string](t, func() { getMetrics(t, h, nil) }) + assert.Equal(t, msg, `in family "invalid": invalid value for microprom.MetricType: 100`) + delete(h.Families, "invalid") + + // test panic from invalid label name + h.Collect = func(ctx context.Context, ms *microprom.MetricSet) error { + names := microprom.NewLabelNames("app:version") + labels := ms.FormatLabels(names, "1.2.3") + ms.Add("process", labels, 1.0) + return nil + } + msg = assert.PanicsWith[string](t, func() { getMetrics(t, h, nil) }) + assert.Equal(t, msg, `invalid label name: "app:version"`) + + // test panic from wrong number of label values + h.Collect = func(ctx context.Context, ms *microprom.MetricSet) error { + names := microprom.NewLabelNames("version", "build_date") + labels := ms.FormatLabels(names, "1.2.3") // forgot build_date + ms.Add("process", labels, 1.0) + return nil + } + msg = assert.PanicsWith[string](t, func() { getMetrics(t, h, nil) }) + assert.Equal(t, msg, `expected 2 label values, but got 1`) + + // test panic from using an undeclared metric family + h.Collect = func(ctx context.Context, ms *microprom.MetricSet) error { + ms.Add("invalid", "", 1.0) + return nil + } + msg = assert.PanicsWith[string](t, func() { getMetrics(t, h, nil) }) + assert.Equal(t, msg, `no such family: invalid`) +} + +func getMetrics(t *testing.T, h http.Handler, requestHeaders http.Header) (status int, 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 resp.StatusCode, string(buf), resp.Header +} diff --git a/microprom/labels.go b/microprom/labels.go new file mode 100644 index 0000000..5b56c9c --- /dev/null +++ b/microprom/labels.go @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net> +// SPDX-License-Identifier: Apache-2.0 + +package microprom + +import ( + "fmt" + "strings" +) + +// Labels holds a label set, formatted according to the text protocol of [OpenMetrics 1.0]. +// Instances are constructed through [LabelNames.Format]. +// +// [OpenMetrics 1.0]: https://prometheus.io/docs/specs/om/open_metrics_spec/ +type Labels string + +// LabelNames holds a set of label names. +type LabelNames struct { + // NOTE: This is an opaque struct because, when adding support for OpenMetrics 2.0, + // it will be useful to precompute escaped forms for these names where necessary. + // + // Another interesting addition might be alphabetical sorting of labels, where we + // would need to remember the sort order because we need to apply it to the values + // slice during Format(). + names []string +} + +// NewLabelNames constructs a LabelNames instance. +// +// Per the [OpenMetrics 1.0] spec, label names must match the following regular expression: +// +// [a-zA-Z_][a-zA-Z0-9_]* +// +// [OpenMetrics 1.0]: https://prometheus.io/docs/specs/om/open_metrics_spec/ +func NewLabelNames(names ...string) LabelNames { + for _, name := range names { + if !labelNameRx.MatchString(name) { + panic(fmt.Sprintf("invalid label name: %q", name)) + } + } + return LabelNames{names} +} + +// FormatLabels serializes a Prometheus labelset into the string format used in Prometheus text expositions. +// For example: +// +// // once, e.g. during func init() +// var names = microprom.NewLabelNames("foo", "hello") +// +// // during microprom.Handler.Collect() +// labels := ms.FormatLabels(names, "bar", "world") +// assert.Equal(t, labels, `foo="bar",hello="world"`) +func (ms *MetricSet) FormatLabels(n LabelNames, values ...string) Labels { + // NOTE on API structure: This is not part of ms.Add() to allow reusing label sets for multiple metrics. + + if len(n.names) != len(values) { + panic(fmt.Sprintf("expected %d label values, but got %d", len(n.names), len(values))) + } + if len(n.names) == 0 { + return "" + } + + // estimate the perfect number of bytes for the result string to avoid reallocations + capacity := len(n.names) - 1 // number of "," between pairs + needsEscaping := make([]bool, len(n.names)) + for idx, value := range values { + // base length for an encoding in the form `label="value"` + capacity += len(n.names[idx]) + len(value) + 3 + // some characters within `value` need escaping (TODO: this could be optimized to only iterate through `value` once) + toEscape := strings.Count(value, "\n") + strings.Count(value, "\"") + strings.Count(value, "\\") + needsEscaping[idx] = toEscape > 0 + capacity += toEscape + } + + var b strings.Builder + b.Grow(capacity) + for idx, value := range values { + if idx > 0 { + _ = b.WriteByte(',') + } + _, _ = b.WriteString(n.names[idx]) + _ = b.WriteByte('=') + _ = b.WriteByte('"') + if needsEscaping[idx] { + // TODO: this could be optimized, but since this branch is unlikely in practice, I did not bother yet + value = strings.ReplaceAll(value, "\\", "\\\\") + value = strings.ReplaceAll(value, "\"", "\\\"") + value = strings.ReplaceAll(value, "\n", "\\n") + } + _, _ = b.WriteString(value) + _ = b.WriteByte('"') + } + return Labels(b.String()) +} diff --git a/microprom/labels_test.go b/microprom/labels_test.go new file mode 100644 index 0000000..0414be8 --- /dev/null +++ b/microprom/labels_test.go @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net> +// SPDX-License-Identifier: Apache-2.0 + +package microprom_test + +import ( + "testing" + + "go.xyrillian.de/gg/assert" + "go.xyrillian.de/gg/microprom" +) + +func TestFormatLabels(t *testing.T) { + ms := microprom.NewMetricSet(microprom.SyntaxOpenMetricsV1, nil) + + // the basic example from the documentation + names := microprom.NewLabelNames("foo", "hello") + labels := ms.FormatLabels(names, "bar", "world") + assert.Equal(t, labels, `foo="bar",hello="world"`) + + // test escaping label values + labels = ms.FormatLabels(names, "bar\\\n\\bar", `"universe\world"`) + assert.Equal(t, labels, `foo="bar\\\n\\bar",hello="\"universe\\world\""`) +} diff --git a/microprom/microprom.go b/microprom/microprom.go new file mode 100644 index 0000000..4055c8b --- /dev/null +++ b/microprom/microprom.go @@ -0,0 +1,158 @@ +// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net> +// SPDX-License-Identifier: Apache-2.0 + +// Package microprom is a minimal alternative implementation of [promhttp], +// intended for very specific situations where the design choices of [prometheus/client_golang] +// cause scaling problems: +// - metric families with very high cardinality, +// - that may have lots of label dimensions, +// - and which do not need to be held in memory, but can instead easily be generated at scrape time (e.g. from a database query). +// +// In this specific circumstance, the internal structure of [prometheus/client_golang] +// leads to abnormally high memory fragmentation and a spiky memory usage pattern overall. +// Implementing the same metrics endpoint with microprom will lead to a +// more stable memory consumption with less intense spikes during scrapes, +// at the cost of slightly more CPU time cost and GC pressure. +// +// A microprom handler produces output in the [Prometheus exposition format], matching the output of promhttp exactly; +// thus it can be scraped by Prometheus or any other OpenTelemetry-compatible metrics collector. +// However, because of the highly specialized focus on high-cardinality database metrics, +// significant parts of the OTLP Stream Model (e.g. summaries, histograms, exemplars) are not implemented. +// The only supported metric types are gauges, counters and info metrics. +// +// # How to use +// +// To get started with microprom, declare your metric families in a [Metadata] instance, +// and then call [Metadata.Handler] to obtain a handler for your "GET /metrics" endpoint. +// +// [promhttp]: https://pkg.go.dev/github.com/prometheus/client_golang/prometheus/promhttp +// [prometheus/client_golang]: https://pkg.go.dev/github.com/prometheus/client_golang +// [Prometheus exposition format]: https://prometheus.io/docs/instrumenting/exposition_formats/ +package microprom + +import ( + "fmt" + "regexp" +) + +// MetricFamilyInfo appears in type [HandlerInfo]. +type MetricFamilyInfo struct { + Type MetricType + Help string +} + +var ( + labelNameRx = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) + metricFamilyNameRx = regexp.MustCompile(`^[a-zA-Z_:][a-zA-Z0-9_:]*$`) +) + +func (i MetricFamilyInfo) validate(name MetricFamilyName) error { + if !metricFamilyNameRx.MatchString(string(name)) { + return fmt.Errorf("in family %q: invalid family name (does not match /%s/)", name, metricFamilyNameRx.String()) + } + if i.Type >= MetricType(len(metricTypeSuffixes)) { + return fmt.Errorf("in family %q: invalid value for microprom.MetricType: %d", name, i.Type) + } + return nil +} + +// MetricFamilyName is the name of a metric family. +// +// Per the [OpenMetrics 1.0] spec, metric family names must match the following regular expression: +// +// ^[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 + +// MetricType is a enum. It appears in type [MetricFamilyInfo]. +// +// As documented on the individual values below, +// the choice of metric type determines how [MetricSet.Add] derives the metric name. +type MetricType uint + +const ( + // MetricTypeGauge is used for metrics that are current measurements, + // where the absolute value is of interest to a user. + // + // For this metric type, the metric name is the same as the metric family name. + MetricTypeGauge MetricType = iota + + // MetricTypeCounter is used for counting discrete events, + // where the rate of increase over time is of interest to a user. + // + // For this metric type, the metric name is formed by appending "_total" to the metric family name. + MetricTypeCounter + + // MetricTypeInfo is used for info metrics, + // which only expose textual information in their labels. + // + // For this metric type, the metric name is formed by appending "_info" to the metric family name. + MetricTypeInfo +) + +var ( + metricTypeNames = []string{"gauge", "counter", "info"} + metricTypeSuffixes = []string{"", "_total", "_info"} +) + +// MetricSet holds a set of metrics. +type MetricSet struct { + syntax Syntax + metrics map[MetricFamilyName][]metric +} + +type metric struct { + labels Labels + value float64 +} + +// 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 { + panic(fmt.Sprintf("unknown value for Syntax: %d", syntax)) + } + m := make(map[MetricFamilyName][]metric, len(families)) + for name, family := range families { + err := family.validate(name) + if err != nil { + // this is fine to panic because it will only blow up in case of gross API misuse + panic(err.Error()) + } + m[name] = nil + } + return &MetricSet{syntax, m} +} + +// Add adds a metric to the MetricSet. +// +// The name must be of a metric family that was declared during [NewMetricSet], otherwise Add will panic. +// The metric name will be derived according to the rules documented on the respective [MetricType]. +func (ms *MetricSet) Add(name MetricFamilyName, labels Labels, value float64) { + _, ok := ms.metrics[name] + if !ok { + panic("no such family: " + string(name)) + } + ms.metrics[name] = append(ms.metrics[name], metric{labels, value}) +} + +// Syntax is an enum, defining which exposition format will be used by [MetricSet]. +// +// - 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 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 +) diff --git a/pathrouter/pathrouter_test.go b/pathrouter/pathrouter_test.go index 3e91ce9..54108ac 100644 --- a/pathrouter/pathrouter_test.go +++ b/pathrouter/pathrouter_test.go @@ -14,7 +14,6 @@ import ( "go.xyrillian.de/gg/assert" pr "go.xyrillian.de/gg/pathrouter" - "go.xyrillian.de/gg/testcapture" ) func TestRouting(t *testing.T) { @@ -157,11 +156,8 @@ func TestRouting(t *testing.T) { func TestPanics(t *testing.T) { check := func(expected string, action func()) { t.Helper() - result := testcapture.Capture(t.Context(), t.Name(), func(t assert.TestingTB) { action() }) - assert.Equal(t, result, testcapture.Result{ - Outcome: testcapture.OutcomePanicked, - Panic: expected, - }) + actual := assert.PanicsWith[string](t, action) + assert.Equal(t, actual, expected) } check(`matcher within CatchAllVariable() may not accept unlimited path lengths`, func() { diff --git a/testcapture/capture.go b/testcapture/capture.go index 3ac4f52..fd98c27 100644 --- a/testcapture/capture.go +++ b/testcapture/capture.go @@ -1,9 +1,6 @@ // SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net> // SPDX-License-Identifier: Apache-2.0 -// Package testcapture contains [Capture], a function that executes test code in a way that captures error messages and side effects without failing the overall test. -// -// The main intended use case is testing test assertions where calls to e.g. t.Error() are an expected part of a successful test run. package testcapture import ( @@ -17,8 +14,6 @@ import ( "slices" "sync" "sync/atomic" - - "go.xyrillian.de/gg/assert" ) // Result is returned by func [Capture]. @@ -81,11 +76,11 @@ const ( MessageTypeOutput MessageType = "output" ) -// Capture executes a test function with a stub implementation of [assert.TestingTB] that captures all calls to it. +// Capture executes a test function with a stub implementation of [TestingTB] that captures all calls to it. // It is intended for unit-testing test assertions. // // The name argument is what will be reported in t.Name() within the test. -func Capture(ctx context.Context, name string, test func(assert.TestingTB)) Result { +func Capture(ctx context.Context, name string, test func(TestingTB)) Result { r := Result{ Outcome: OutcomeFinished, // can be overridden by Fail() or SkipNow() } @@ -93,7 +88,7 @@ func Capture(ctx context.Context, name string, test func(assert.TestingTB)) Resu return r } -// capturer is the implementation of [assert.TestingTB] used by func [Capture]. +// capturer is the implementation of [TestingTB] used by func [Capture]. type capturer struct { context context.Context cleanups []func() @@ -109,7 +104,7 @@ type capturer struct { nonlocalMutex sync.Mutex // lock for non-local effects like Setenv() or filesystem operations } -func executeCapture(ctx context.Context, name string, r *Result, test func(assert.TestingTB)) { +func executeCapture(ctx context.Context, name string, r *Result, test func(TestingTB)) { ctx, cancel := context.WithCancel(ctx) t := capturer{ context: ctx, @@ -196,7 +191,7 @@ func collectArtifacts(dirPath string) (map[string]string, error) { return result, os.RemoveAll(dirPath) } -// ArtifactDir implements the [assert.TestingTB] interface. +// ArtifactDir implements the [TestingTB] interface. func (t *capturer) ArtifactDir() string { t.stateMutex.Lock() defer t.stateMutex.Unlock() @@ -221,7 +216,7 @@ func (t *capturer) ArtifactDir() string { return t.state.ArtifactDir } -// Attr implements the [assert.TestingTB] interface. +// Attr implements the [TestingTB] interface. func (t *capturer) Attr(key, value string) { t.resultMutex.Lock() defer t.resultMutex.Unlock() @@ -231,7 +226,7 @@ func (t *capturer) Attr(key, value string) { t.result.Attrs[key] = value } -// Chdir implements the [assert.TestingTB] interface. +// Chdir implements the [TestingTB] interface. func (t *capturer) Chdir(dir string) { t.doChdir(dir) @@ -269,82 +264,82 @@ func (t *capturer) doChdir(dir string) { }) } -// Cleanup implements the [assert.TestingTB] interface. +// Cleanup implements the [TestingTB] interface. func (t *capturer) Cleanup(action func()) { t.cleanupsMutex.Lock() defer t.cleanupsMutex.Unlock() t.cleanups = append(t.cleanups, action) } -// Context implements the [assert.TestingTB] interface. +// Context implements the [TestingTB] interface. func (t *capturer) Context() context.Context { return t.context } -// Error implements the [assert.TestingTB] interface. +// Error implements the [TestingTB] interface. func (t *capturer) Error(args ...any) { t.Log(args...) t.Fail() } -// Errorf implements the [assert.TestingTB] interface. +// Errorf implements the [TestingTB] interface. func (t *capturer) Errorf(format string, args ...any) { t.Logf(format, args...) t.Fail() } -// Fail implements the [assert.TestingTB] interface. +// Fail implements the [TestingTB] interface. func (t *capturer) Fail() { t.resultMutex.Lock() defer t.resultMutex.Unlock() t.result.Outcome = OutcomeFailed } -// Failed implements the [assert.TestingTB] interface. +// Failed implements the [TestingTB] interface. func (t *capturer) Failed() bool { t.resultMutex.RLock() defer t.resultMutex.RUnlock() return t.result.Outcome == OutcomeFailed } -// FailNow implements the [assert.TestingTB] interface. +// FailNow implements the [TestingTB] interface. func (t *capturer) FailNow() { panic(OutcomeFailed) } -// Fatal implements the [assert.TestingTB] interface. +// Fatal implements the [TestingTB] interface. func (t *capturer) Fatal(args ...any) { t.Log(args...) t.FailNow() } -// Fatalf implements the [assert.TestingTB] interface. +// Fatalf implements the [TestingTB] interface. func (t *capturer) Fatalf(format string, args ...any) { t.Logf(format, args...) t.FailNow() } -// Helper implements the [assert.TestingTB] interface. +// Helper implements the [TestingTB] interface. func (t *capturer) Helper() { // no-op because we do not collect file and line information at the moment } -// Log implements the [assert.TestingTB] interface. +// Log implements the [TestingTB] interface. func (t *capturer) Log(args ...any) { t.pushOutput(fmt.Append(nil, args...), MessageTypeLog) } -// Logf implements the [assert.TestingTB] interface. +// Logf implements the [TestingTB] interface. func (t *capturer) Logf(format string, args ...any) { t.pushOutput(fmt.Appendf(nil, format, args...), MessageTypeLog) } -// Name implements the [assert.TestingTB] interface. +// Name implements the [TestingTB] interface. func (t *capturer) Name() string { return t.name } -// Output implements the [assert.TestingTB] interface. +// Output implements the [TestingTB] interface. func (t *capturer) Output() io.Writer { return outputCapturer{t} } @@ -359,7 +354,7 @@ func (c outputCapturer) Write(buf []byte) (int, error) { return len(buf), nil } -// Setenv implements the [assert.TestingTB] interface. +// Setenv implements the [TestingTB] interface. func (t *capturer) Setenv(key, value string) { t.nonlocalMutex.Lock() defer t.nonlocalMutex.Unlock() @@ -376,31 +371,31 @@ func (t *capturer) Setenv(key, value string) { }) } -// Skip implements the [assert.TestingTB] interface. +// Skip implements the [TestingTB] interface. func (t *capturer) Skip(args ...any) { t.Log(args...) t.SkipNow() } -// Skipf implements the [assert.TestingTB] interface. +// Skipf implements the [TestingTB] interface. func (t *capturer) Skipf(format string, args ...any) { t.Logf(format, args...) t.SkipNow() } -// SkipNow implements the [assert.TestingTB] interface. +// SkipNow implements the [TestingTB] interface. func (t *capturer) SkipNow() { panic(OutcomeSkipped) } -// Skipped implements the [assert.TestingTB] interface. +// Skipped implements the [TestingTB] interface. func (t *capturer) Skipped() bool { t.resultMutex.RLock() defer t.resultMutex.RUnlock() return t.result.Outcome == OutcomeSkipped } -// TempDir implements the [assert.TestingTB] interface. +// TempDir implements the [TestingTB] interface. func (t *capturer) TempDir() string { path, err := pickTempdir() if err != nil { diff --git a/testcapture/testcapture.go b/testcapture/testcapture.go new file mode 100644 index 0000000..b0c0fac --- /dev/null +++ b/testcapture/testcapture.go @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net> +// SPDX-License-Identifier: Apache-2.0 + +// Package testcapture contains [Capture], a function that executes test code in a way that captures error messages and side effects without failing the overall test. +// +// The main intended use case is testing test assertions where calls to e.g. t.Error() are an expected part of a successful test run. +package testcapture + +import ( + "context" + "io" + "testing" +) + +// TestingTB contains all the public functions of [testing.TB] (as of Go 1.26). +// Functions in this package use this type instead of [testing.TB] because the capture device used by package testcapture cannot implement [testing.TB]: It contains methods that are private to the standard library. +type TestingTB interface { + ArtifactDir() string + Attr(key, value string) + Chdir(dir string) + Cleanup(func()) + Context() context.Context + Error(args ...any) + Errorf(format string, args ...any) + Fail() + Failed() bool + FailNow() + Fatal(args ...any) + Fatalf(format string, args ...any) + Helper() + Log(args ...any) + Logf(format string, args ...any) + Name() string + Output() io.Writer + Setenv(key, value string) + Skip(args ...any) + Skipf(format string, args ...any) + SkipNow() + Skipped() bool + TempDir() string +} + +var _ TestingTB = testing.TB(nil) 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..93027e3 --- /dev/null +++ b/testing/microprom/handler_test.go @@ -0,0 +1,95 @@ +// 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) +} + +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 +} |
