aboutsummaryrefslogtreecommitdiff
path: root/microprom/handler.go
blob: a505df3332a56082f6e5ea6405ce0f426de45473 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
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)
}