Skip to content

Commit 394d565

Browse files
committed
feat: implement gauge and counter support for OpenMetrics 2.0
Signed-off-by: David Ashpole <dashpole@google.com>
1 parent 0dfcdfb commit 394d565

5 files changed

Lines changed: 428 additions & 1 deletion

File tree

expfmt/encode.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"fmt"
1818
"io"
1919
"net/http"
20+
"strings"
2021

2122
"github.com/munnerz/goautoneg"
2223
dto "github.com/prometheus/client_model/go"
@@ -118,8 +119,10 @@ func NegotiateIncludingOpenMetrics(h http.Header) Format {
118119
if ac.Type == "text" && ac.SubType == "plain" && (ver == TextVersion || ver == "") {
119120
return FmtText + escapingScheme
120121
}
121-
if ac.Type+"/"+ac.SubType == OpenMetricsType && (ver == OpenMetricsVersion_0_0_1 || ver == OpenMetricsVersion_1_0_0 || ver == "") {
122+
if ac.Type+"/"+ac.SubType == OpenMetricsType && (ver == OpenMetricsVersion_0_0_1 || ver == OpenMetricsVersion_1_0_0 || ver == OpenMetricsVersion_2_0_0 || ver == "") {
122123
switch ver {
124+
case OpenMetricsVersion_2_0_0:
125+
return FmtOpenMetrics_2_0_0 + escapingScheme
123126
case OpenMetricsVersion_1_0_0:
124127
return FmtOpenMetrics_1_0_0 + escapingScheme
125128
default:
@@ -181,6 +184,18 @@ func NewEncoder(w io.Writer, format Format, options ...EncoderOption) Encoder {
181184
close: func() error { return nil },
182185
}
183186
case TypeOpenMetrics:
187+
if strings.Contains(string(format), "version="+OpenMetricsVersion_2_0_0) {
188+
return encoderCloser{
189+
encode: func(v *dto.MetricFamily) error {
190+
_, err := MetricFamilyToOpenMetrics20(w, model.EscapeMetricFamily(v, escapingScheme), options...)
191+
return err
192+
},
193+
close: func() error {
194+
_, err := FinalizeOpenMetrics(w)
195+
return err
196+
},
197+
}
198+
}
184199
return encoderCloser{
185200
encode: func(v *dto.MetricFamily) error {
186201
_, err := MetricFamilyToOpenMetrics(w, model.EscapeMetricFamily(v, escapingScheme), options...)

expfmt/encode_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,11 @@ func TestNegotiateIncludingOpenMetrics(t *testing.T) {
120120
acceptHeaderValue: "application/openmetrics-text;version=1.0.0",
121121
expectedFmt: "application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=values",
122122
},
123+
{
124+
name: "OM format, 2.0.0 version",
125+
acceptHeaderValue: "application/openmetrics-text;version=2.0.0",
126+
expectedFmt: "application/openmetrics-text; version=2.0.0; charset=utf-8; escaping=values",
127+
},
123128
{
124129
name: "OM format, 0.0.1 version with utf-8 is not valid, falls back",
125130
acceptHeaderValue: "application/openmetrics-text;version=0.0.1",
@@ -268,6 +273,15 @@ foo_metric 1.234
268273
expOut: `# TYPE foo_metric unknown
269274
# UNIT foo_metric seconds
270275
foo_metric 1.234
276+
`,
277+
},
278+
// 8: Untyped FmtOpenMetrics_2_0_0
279+
{
280+
metric: metric1,
281+
format: FmtOpenMetrics_2_0_0,
282+
expOut: `# TYPE foo_metric unknown
283+
# UNIT foo_metric seconds
284+
foo_metric 1.234
271285
`,
272286
},
273287
}

expfmt/expfmt.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ const (
4242
OpenMetricsVersion_0_0_1 = "0.0.1"
4343
//nolint:revive // Allow for underscores.
4444
OpenMetricsVersion_1_0_0 = "1.0.0"
45+
//nolint:revive // Allow for underscores.
46+
OpenMetricsVersion_2_0_0 = "2.0.0"
4547

4648
// The Content-Type values for the different wire protocols. Do not do direct
4749
// comparisons to these constants, instead use the comparison functions.
@@ -59,6 +61,8 @@ const (
5961
// Deprecated: Use expfmt.NewFormat(expfmt.TypeOpenMetrics) instead.
6062
//nolint:revive // Allow for underscores.
6163
FmtOpenMetrics_1_0_0 Format = OpenMetricsType + `; version=` + OpenMetricsVersion_1_0_0 + `; charset=utf-8`
64+
//nolint:revive // Allow for underscores.
65+
FmtOpenMetrics_2_0_0 Format = OpenMetricsType + `; version=` + OpenMetricsVersion_2_0_0 + `; charset=utf-8`
6266
// Deprecated: Use expfmt.NewFormat(expfmt.TypeOpenMetrics) instead.
6367
//nolint:revive // Allow for underscores.
6468
FmtOpenMetrics_0_0_1 Format = OpenMetricsType + `; version=` + OpenMetricsVersion_0_0_1 + `; charset=utf-8`
@@ -114,6 +118,9 @@ func NewOpenMetricsFormat(version string) (Format, error) {
114118
if version == OpenMetricsVersion_1_0_0 {
115119
return FmtOpenMetrics_1_0_0, nil
116120
}
121+
if version == OpenMetricsVersion_2_0_0 {
122+
return FmtOpenMetrics_2_0_0, nil
123+
}
117124
return FmtUnknown, errors.New("unknown open metrics version string")
118125
}
119126

expfmt/openmetrics_2_0_create.go

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
// Copyright 2026 The Prometheus Authors
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
package expfmt
15+
16+
import (
17+
"bufio"
18+
"fmt"
19+
"io"
20+
"math"
21+
"strconv"
22+
23+
dto "github.com/prometheus/client_model/go"
24+
)
25+
26+
// MetricFamilyToOpenMetrics20 converts a MetricFamily proto message into the
27+
// OpenMetrics text format version 2.0.0 and writes the resulting lines to 'out'.
28+
// It returns the number of bytes written and any error encountered.
29+
func MetricFamilyToOpenMetrics20(out io.Writer, in *dto.MetricFamily, options ...EncoderOption) (written int, err error) {
30+
name := in.GetName()
31+
if name == "" {
32+
return 0, fmt.Errorf("MetricFamily has no name: %s", in)
33+
}
34+
35+
// Try the interface upgrade. If it doesn't work, we'll use a
36+
// bufio.Writer from the sync.Pool.
37+
w, ok := out.(enhancedWriter)
38+
if !ok {
39+
b := bufPool.Get().(*bufio.Writer)
40+
b.Reset(out)
41+
w = b
42+
defer func() {
43+
bErr := b.Flush()
44+
if err == nil {
45+
err = bErr
46+
}
47+
bufPool.Put(b)
48+
}()
49+
}
50+
51+
var (
52+
n int
53+
metricType = in.GetType()
54+
)
55+
56+
// Comments, first HELP, then TYPE.
57+
if in.Help != nil {
58+
n, err = w.WriteString("# HELP ")
59+
written += n
60+
if err != nil {
61+
return written, err
62+
}
63+
n, err = writeName(w, name)
64+
written += n
65+
if err != nil {
66+
return written, err
67+
}
68+
err = w.WriteByte(' ')
69+
written++
70+
if err != nil {
71+
return written, err
72+
}
73+
n, err = writeEscapedString(w, *in.Help, true)
74+
written += n
75+
if err != nil {
76+
return written, err
77+
}
78+
err = w.WriteByte('\n')
79+
written++
80+
if err != nil {
81+
return written, err
82+
}
83+
}
84+
n, err = w.WriteString("# TYPE ")
85+
written += n
86+
if err != nil {
87+
return written, err
88+
}
89+
n, err = writeName(w, name)
90+
written += n
91+
if err != nil {
92+
return written, err
93+
}
94+
switch metricType {
95+
case dto.MetricType_COUNTER:
96+
n, err = w.WriteString(" counter\n")
97+
case dto.MetricType_GAUGE:
98+
n, err = w.WriteString(" gauge\n")
99+
case dto.MetricType_SUMMARY:
100+
n, err = w.WriteString(" summary\n")
101+
case dto.MetricType_UNTYPED:
102+
n, err = w.WriteString(" unknown\n")
103+
case dto.MetricType_HISTOGRAM:
104+
n, err = w.WriteString(" histogram\n")
105+
case dto.MetricType_GAUGE_HISTOGRAM:
106+
n, err = w.WriteString(" gaugehistogram\n")
107+
default:
108+
return written, fmt.Errorf("unknown metric type %s", metricType.String())
109+
}
110+
written += n
111+
if err != nil {
112+
return written, err
113+
}
114+
if in.Unit != nil {
115+
n, err = w.WriteString("# UNIT ")
116+
written += n
117+
if err != nil {
118+
return written, err
119+
}
120+
n, err = writeName(w, name)
121+
written += n
122+
if err != nil {
123+
return written, err
124+
}
125+
126+
err = w.WriteByte(' ')
127+
written++
128+
if err != nil {
129+
return written, err
130+
}
131+
n, err = writeEscapedString(w, *in.Unit, true)
132+
written += n
133+
if err != nil {
134+
return written, err
135+
}
136+
err = w.WriteByte('\n')
137+
written++
138+
if err != nil {
139+
return written, err
140+
}
141+
}
142+
143+
// Finally the samples, one line for each.
144+
for _, metric := range in.Metric {
145+
switch metricType {
146+
case dto.MetricType_COUNTER:
147+
if metric.Counter == nil {
148+
return written, fmt.Errorf("expected counter in metric %s %s", name, metric)
149+
}
150+
n, err = writeOpenMetrics20Sample(w, name, metric, metric.Counter.GetValue(), 0, false, metric.Counter.Exemplar)
151+
case dto.MetricType_GAUGE:
152+
if metric.Gauge == nil {
153+
return written, fmt.Errorf("expected gauge in metric %s %s", name, metric)
154+
}
155+
n, err = writeOpenMetrics20Sample(w, name, metric, metric.Gauge.GetValue(), 0, false, nil)
156+
case dto.MetricType_UNTYPED:
157+
if metric.Untyped == nil {
158+
return written, fmt.Errorf("expected untyped in metric %s %s", name, metric)
159+
}
160+
n, err = writeOpenMetrics20Sample(w, name, metric, metric.Untyped.GetValue(), 0, false, nil)
161+
case dto.MetricType_SUMMARY:
162+
if metric.Summary == nil {
163+
return written, fmt.Errorf("expected summary in metric %s %s", name, metric)
164+
}
165+
n, err = writeCompositeSummary(w, name, metric)
166+
case dto.MetricType_HISTOGRAM, dto.MetricType_GAUGE_HISTOGRAM:
167+
if metric.Histogram == nil {
168+
return written, fmt.Errorf("expected histogram in metric %s %s", name, metric)
169+
}
170+
n, err = writeCompositeHistogram(w, name, metric, metricType == dto.MetricType_GAUGE_HISTOGRAM)
171+
default:
172+
return written, fmt.Errorf("unexpected type in metric %s %s", name, metric)
173+
}
174+
written += n
175+
if err != nil {
176+
return written, err
177+
}
178+
}
179+
return written, nil
180+
}
181+
182+
// writeOpenMetrics20Sample writes a single sample for simple types (Counter, Gauge, Untyped).
183+
func writeOpenMetrics20Sample(w enhancedWriter, name string, metric *dto.Metric, floatValue float64, intValue uint64, useIntValue bool, exemplar *dto.Exemplar) (int, error) {
184+
written := 0
185+
n, err := writeOpenMetricsNameAndLabelPairs(w, name, metric.Label, "", 0)
186+
written += n
187+
if err != nil {
188+
return written, err
189+
}
190+
err = w.WriteByte(' ')
191+
written++
192+
if err != nil {
193+
return written, err
194+
}
195+
196+
if useIntValue {
197+
n, err = writeUint(w, intValue)
198+
} else {
199+
n, err = writeFloat(w, floatValue)
200+
}
201+
written += n
202+
if err != nil {
203+
return written, err
204+
}
205+
206+
if metric.TimestampMs != nil {
207+
err = w.WriteByte(' ')
208+
written++
209+
if err != nil {
210+
return written, err
211+
}
212+
n, err = writeOpenMetrics20Timestamp(w, float64(*metric.TimestampMs)/1000)
213+
written += n
214+
if err != nil {
215+
return written, err
216+
}
217+
}
218+
219+
// Start Timestamp for Counter
220+
if metric.Counter != nil && metric.Counter.CreatedTimestamp != nil {
221+
n, err = w.WriteString(" st@")
222+
written += n
223+
if err != nil {
224+
return written, err
225+
}
226+
ts := metric.Counter.CreatedTimestamp
227+
n, err = writeOpenMetrics20Timestamp(w, float64(ts.GetSeconds())+float64(ts.GetNanos())/1e9)
228+
written += n
229+
if err != nil {
230+
return written, err
231+
}
232+
}
233+
234+
if exemplar != nil && len(exemplar.Label) > 0 {
235+
n, err = writeExemplar(w, exemplar)
236+
written += n
237+
if err != nil {
238+
return written, err
239+
}
240+
}
241+
242+
err = w.WriteByte('\n')
243+
written++
244+
if err != nil {
245+
return written, err
246+
}
247+
return written, nil
248+
}
249+
250+
// writeOpenMetrics20Timestamp writes a float64 as a timestamp without scientific notation.
251+
func writeOpenMetrics20Timestamp(w enhancedWriter, f float64) (int, error) {
252+
switch {
253+
case math.IsNaN(f):
254+
return w.WriteString("NaN")
255+
case math.IsInf(f, +1):
256+
return w.WriteString("+Inf")
257+
case math.IsInf(f, -1):
258+
return w.WriteString("-Inf")
259+
default:
260+
bp := numBufPool.Get().(*[]byte)
261+
*bp = strconv.AppendFloat((*bp)[:0], f, 'f', -1, 64)
262+
written, err := w.Write(*bp)
263+
numBufPool.Put(bp)
264+
return written, err
265+
}
266+
}
267+
268+
// Stubs for Summary and Histogram
269+
270+
func writeCompositeSummary(w enhancedWriter, name string, metric *dto.Metric) (int, error) {
271+
return 0, fmt.Errorf("summary not implemented yet")
272+
}
273+
274+
func writeCompositeHistogram(w enhancedWriter, name string, metric *dto.Metric, isGauge bool) (int, error) {
275+
return 0, fmt.Errorf("histogram not implemented yet")
276+
}

0 commit comments

Comments
 (0)