diff options
| author | Stefan Majewsky <majewsky@gmx.net> | 2026-08-11 23:14:44 +0200 |
|---|---|---|
| committer | Stefan Majewsky <majewsky@gmx.net> | 2026-08-17 16:41:59 +0200 |
| commit | a48f97c25d421c0da21467f8e0e736c19c58c6e1 (patch) | |
| tree | 053da4e524305ac3d3673ee7e088bfde4385464e | |
| parent | ab5012e0083ff6623ffab78f52f9ea1120d80ffb (diff) | |
| download | go-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.md | 1 | ||||
| -rw-r--r-- | microprom/handler.go | 109 | ||||
| -rw-r--r-- | microprom/labels.go | 94 | ||||
| -rw-r--r-- | microprom/labels_test.go | 24 | ||||
| -rw-r--r-- | microprom/microprom.go | 152 |
5 files changed, 380 insertions, 0 deletions
@@ -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 +) |
