aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorStefan Majewsky <majewsky@gmx.net>2026-08-11 23:14:44 +0200
committerStefan Majewsky <majewsky@gmx.net>2026-08-17 16:41:59 +0200
commita48f97c25d421c0da21467f8e0e736c19c58c6e1 (patch)
tree053da4e524305ac3d3673ee7e088bfde4385464e
parentab5012e0083ff6623ffab78f52f9ea1120d80ffb (diff)
downloadgo-gg-a48f97c25d421c0da21467f8e0e736c19c58c6e1.tar.gz
add package microprom
This does not have test coverage yet, because the test coverage will be in the `testing/` module, but `go mod tidy` will not run there until there is a commit that has the microprom package itself.
-rw-r--r--README.md1
-rw-r--r--microprom/handler.go109
-rw-r--r--microprom/labels.go94
-rw-r--r--microprom/labels_test.go24
-rw-r--r--microprom/microprom.go152
5 files changed, 380 insertions, 0 deletions
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/microprom/handler.go b/microprom/handler.go
new file mode 100644
index 0000000..db5e53f
--- /dev/null
+++ b/microprom/handler.go
@@ -0,0 +1,109 @@
+// 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"
+)
+
+// 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) {
+ ms := NewMetricSet(SyntaxOpenMetricsV1, h.Families)
+ err := h.Collect(r.Context(), ms)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // TODO: add support for `Content-Type: application/openmetrics-text; version=1.0.0; charset=utf-8` if requested in `Accept` header
+ w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8; escaping=underscores")
+ w.WriteHeader(http.StatusOK)
+
+ bw := bufio.NewWriter(w)
+ if h.SortOutput {
+ for _, familyName := range slices.Sorted(maps.Keys(h.Families)) {
+ h.printMetricFamily(bw, familyName, h.Families[familyName], ms.metrics[familyName])
+ }
+ } else {
+ for familyName, familyInfo := range h.Families {
+ h.printMetricFamily(bw, familyName, familyInfo, ms.metrics[familyName])
+ }
+ }
+
+ 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, 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
+ }
+
+ 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 %g\n", metricName, m.value)
+ } else {
+ fmt.Fprintf(w, "%s{%s} %g\n", metricName, m.labels, m.value)
+ }
+ }
+}
diff --git a/microprom/labels.go b/microprom/labels.go
new file mode 100644
index 0000000..51cdbb1
--- /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("arguments are not of equal length")
+ }
+ 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..2b6305d
--- /dev/null
+++ b/microprom/microprom.go
@@ -0,0 +1,152 @@
+// 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_:]*$
+//
+// [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: " + name)
+ }
+ ms.metrics[name] = append(ms.metrics[name], metric{labels, value})
+}
+
+// Syntax is an enum, defining which exposition format will be used by [MetricSet].
+//
+// - SyntaxOpenMetricsV1 corresponds to the [OpenMetrics 1.0] text format,
+// which is functionally equivalent to the Prometheus text format v0.0.4.
+// - Additional formats may be added in the future
+// (e.g. OpenMetrics 2.0, once it is stabilized).
+//
+// [OpenMetrics 1.0]: https://prometheus.io/docs/specs/om/open_metrics_spec/
+type Syntax int
+
+const (
+ // SyntaxOpenMetricsV1 corresponds to the OpenMetrics 1.0 text format.
+ SyntaxOpenMetricsV1 Syntax = iota
+)