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 /microprom | |
| parent | ab5012e0083ff6623ffab78f52f9ea1120d80ffb (diff) | |
| parent | a3ccdd9f491c20771cfda343ebebd4b5d0ea6602 (diff) | |
| download | go-gg-5cc0f9db288ffaf44d104974707338d52530cece.tar.gz | |
Merge branch 'microprom'
Diffstat (limited to 'microprom')
| -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 |
5 files changed, 589 insertions, 0 deletions
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 +) |
