aboutsummaryrefslogtreecommitdiff
path: root/microprom/handler.go
diff options
context:
space:
mode:
Diffstat (limited to 'microprom/handler.go')
-rw-r--r--microprom/handler.go171
1 files changed, 171 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)
+}