aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorStefan Majewsky <majewsky@gmx.net>2026-08-18 13:40:30 +0200
committerStefan Majewsky <majewsky@gmx.net>2026-08-18 13:40:30 +0200
commit5cc0f9db288ffaf44d104974707338d52530cece (patch)
treef0ad530dd072c55c6f46dfea23af993fb31333d4
parentab5012e0083ff6623ffab78f52f9ea1120d80ffb (diff)
parenta3ccdd9f491c20771cfda343ebebd4b5d0ea6602 (diff)
downloadgo-gg-5cc0f9db288ffaf44d104974707338d52530cece.tar.gz
Merge branch 'microprom'
-rw-r--r--CHANGELOG.md7
-rw-r--r--README.md1
-rw-r--r--assert/assert.go37
-rw-r--r--assert/errequal_test.go8
-rw-r--r--assert/panic.go32
-rw-r--r--assert/panic_test.go35
-rw-r--r--internal/accept/accept.go137
-rw-r--r--internal/accept/accept_test.go94
-rw-r--r--microprom/handler.go171
-rw-r--r--microprom/handler_test.go142
-rw-r--r--microprom/labels.go94
-rw-r--r--microprom/labels_test.go24
-rw-r--r--microprom/microprom.go158
-rw-r--r--pathrouter/pathrouter_test.go8
-rw-r--r--testcapture/capture.go59
-rw-r--r--testcapture/testcapture.go43
-rw-r--r--testing/go.mod14
-rw-r--r--testing/go.sum40
-rw-r--r--testing/microprom/handler_test.go95
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:
diff --git a/README.md b/README.md
index 2a2ad6e..c3826e1 100644
--- a/README.md
+++ b/README.md
@@ -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
+}