Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
30ccb5a
expfmt: add ABNF parser for OpenMetrics 2.0
bwplotka Oct 12, 2026
1528fe4
Address review comment: reject interleaved metric families in OM 2.0 …
bwplotka Oct 27, 2026
dfd4219
Address review comment: allow NaN and Inf exemplar values in OM 2.0
bwplotka Oct 27, 2026
5b8d27c
Address review comment: guard against float64 to uint64 overflow in s…
bwplotka Oct 27, 2026
ed2c151
Address review comment: enforce classic and native histogram count in…
bwplotka Oct 27, 2026
898939c
Address review comment: reject negative native bucket counts in gauge…
bwplotka Oct 27, 2026
320e383
Address review comment: remove no-op sumValOrNaN helper
bwplotka Oct 27, 2026
3eb8757
Address review comment: validate int64 and protobuf bounds in timesta…
bwplotka Oct 27, 2026
e03c221
Address review comment: fail on duplicate MetricFamily in decodeOpenM…
bwplotka Oct 27, 2026
a1dc41f
Address review comment: add multi-span native histogram test case
bwplotka Oct 27, 2026
ae480ef
Address review comment: expand semantic error test cases in TestOpenM…
bwplotka Oct 27, 2026
5d42c0b
Address review comment: guard against uint64 overflow in native histo…
bwplotka Oct 27, 2026
483b739
Address review comment: reject start timestamps and exemplars on unsu…
bwplotka Oct 27, 2026
c819a48
Address review comment: rename enforceRuneLimit parameter to isOM1 in…
bwplotka Oct 27, 2026
20b4ce8
Address review comment: document OM2 float histogram, summary sum, es…
bwplotka Oct 27, 2026
e257672
Address review comment: expand TestOpenMetrics2RoundTrip and consolid…
bwplotka Oct 27, 2026
9819b55
Address review comment: emit or close curFamily when family changes o…
bwplotka Oct 28, 2026
5e0e573
Address review comment: allow negative sum and quantile values in OM …
bwplotka Oct 29, 2026
fb58205
Address review comment: enforce gaugehistogram bucket monotonicity an…
bwplotka Oct 29, 2026
05ceca7
Address review comment: reject reserved __name__ label in exemplars
bwplotka Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions expfmt/decode.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ func ResponseFormat(h http.Header) Format {
return FmtOpenMetrics_0_0_1
case OpenMetricsVersion_1_0_0:
return FmtOpenMetrics_1_0_0
case OpenMetricsVersion_2_0_0_rc:
return FmtOpenMetrics_2_0_0_rc
case OpenMetricsVersion_2_0_0:
return FmtOpenMetrics_2_0_0
default:
return FmtUnknown
}
Expand All @@ -84,9 +88,9 @@ func ResponseFormat(h http.Header) Format {
// names are validated based on the provided Format -- if the format requires
// escaping, traditional Prometheus validity checking is used. Otherwise, names
// are checked for UTF-8 validity. Supported formats include delimited protobuf,
// Prometheus text format, and OpenMetrics 0.0.1 / 1.0.0. For historical
// Prometheus text format, and OpenMetrics 0.0.1 / 1.0.0 / 2.0.0-rc / 2.0.0. For historical
// reasons, this decoder falls back to classic text decoding for other legacy
// formats, but returns an error for unsupported formats such as OpenMetrics 2.0.
// formats, and returns an error for unsupported formats such as text protobuf.
// This decoder may not support the latest features of Prometheus text format
// and is not intended for high-performance applications.
// See: https://github.com/prometheus/common/issues/812
Expand All @@ -108,6 +112,15 @@ func NewDecoder(r io.Reader, format Format) Decoder {
switch params["version"] {
case "", OpenMetricsVersion_0_0_1, OpenMetricsVersion_1_0_0:
return &openMetricsDecoder{p: newOpenMetricsParser(r, scheme)}
case OpenMetricsVersion_2_0_0_rc, OpenMetricsVersion_2_0_0:
// format.ToEscapingScheme() defaults to UnderscoreEscaping when no escaping
// parameter is set, whereas OpenMetrics 2.0 supports UTF-8 names by default
// unless a non-UTF-8 escaping scheme is explicitly requested.
om2Scheme := model.UTF8Validation
if _, hasEscaping := params[model.EscapingKey]; hasEscaping && format.ToEscapingScheme() != model.NoEscaping {
om2Scheme = model.LegacyValidation
}
return &openMetrics20Decoder{p: newOpenMetrics20Parser(r, om2Scheme)}
default:
return &errDecoder{err: fmt.Errorf("format %s not supported for decoding", format)}
}
Expand Down Expand Up @@ -178,6 +191,27 @@ func (d *openMetricsDecoder) Decode(v *dto.MetricFamily) error {
return nil
}

// openMetrics20Decoder implements the Decoder interface for the OpenMetrics 2.0 protocol.
// Like openMetricsDecoder, it uses goabnf.ParseForest per line without a line-length
// cap (which has superlinear parse time in line length) and is intended for
// reference/conformance decoding rather than high-performance or untrusted inputs.
type openMetrics20Decoder struct {
p *openMetrics20Parser
err error
}

// Decode implements the Decoder interface.
func (d *openMetrics20Decoder) Decode(v *dto.MetricFamily) error {
if d.err != nil {
return d.err
}
if err := d.p.next(v); err != nil {
d.err = err
return err
}
return nil
}

// textDecoder implements the Decoder interface for the text protocol.
type textDecoder struct {
r io.Reader
Expand Down
83 changes: 79 additions & 4 deletions expfmt/decode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,14 @@ func testDiscriminatorHTTPHeader(t testing.TB) {
input: map[string]string{"Content-Type": `application/openmetrics-text; version=1.0.0`},
output: FmtOpenMetrics_1_0_0,
},
{
input: map[string]string{"Content-Type": `application/openmetrics-text; version=2.0.0-rc`},
output: FmtOpenMetrics_2_0_0_rc,
},
{
input: map[string]string{"Content-Type": `application/openmetrics-text; version=2.0.0`},
output: FmtOpenMetrics_2_0_0,
},
{
input: map[string]string{"Content-Type": `application/openmetrics-text; version=0.0.2`},
output: FmtUnknown,
Expand Down Expand Up @@ -642,24 +650,26 @@ func TestNewDecoder(t *testing.T) {
{
name: "OpenMetrics 2.0.0-rc",
format: om20RCFormat,
expectError: true,
input: "# EOF\n",
expectError: false,
},
{
name: "OpenMetrics 2.0.0-rc with escaping",
format: om20RCFormat.WithEscapingScheme(model.ValueEncodingEscaping),
expectError: true,
input: "# EOF\n",
expectError: false,
},
{
name: "OpenMetrics 2.0.0",
format: om20Format,
input: "# EOF\n",
expectError: true,
expectError: false,
},
{
name: "OpenMetrics 2.0.0 with escaping",
format: om20Format.WithEscapingScheme(model.ValueEncodingEscaping),
input: "# EOF\n",
expectError: true,
expectError: false,
},
}

Expand Down Expand Up @@ -742,3 +752,68 @@ mf1{label="value2"} 42
sort.Sort(out)
require.Equal(t, out, all)
}

func TestOpenMetrics20Decoder(t *testing.T) {
var (
ts = model.Now()
in = `# TYPE mf2_total counter
mf2_total 3
mf2_total 4
mf1{label="value1"} -3.14 123.456
mf1{label="value2"} 42
# EOF
`
out = model.Vector{
&model.Sample{
Metric: model.Metric{
model.MetricNameLabel: "mf1",
"label": "value1",
},
Value: -3.14,
Timestamp: 123456,
},
&model.Sample{
Metric: model.Metric{
model.MetricNameLabel: "mf1",
"label": "value2",
},
Value: 42,
Timestamp: ts,
},
&model.Sample{
Metric: model.Metric{
model.MetricNameLabel: "mf2_total",
},
Value: 3,
Timestamp: ts,
},
&model.Sample{
Metric: model.Metric{
model.MetricNameLabel: "mf2_total",
},
Value: 4,
Timestamp: ts,
},
}
)

dec := &SampleDecoder{
Dec: NewDecoder(strings.NewReader(in), FmtOpenMetrics_2_0_0),
Opts: &DecodeOptions{
Timestamp: ts,
},
}
var all model.Vector
for {
var smpls model.Vector
err := dec.Decode(&smpls)
if errors.Is(err, io.EOF) {
break
}
require.NoError(t, err)
all = append(all, smpls...)
}
sort.Sort(all)
sort.Sort(out)
require.Equal(t, out, all)
}
6 changes: 3 additions & 3 deletions expfmt/expfmt.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ func NewFormat(t FormatType) Format {
// NewOpenMetricsFormat generates a new OpenMetrics format matching the
// specified version number.
//
// Note: OpenMetrics versions 2.0.0-rc and 2.0.0 are experimental and encode-only (supporting
// Note: OpenMetrics versions 2.0.0-rc and 2.0.0 are experimental (supporting
// counter, gauge, summary, untyped, histogram, and gaugehistogram metric types).
func NewOpenMetricsFormat(version string) (Format, error) {
switch version {
Expand All @@ -119,10 +119,10 @@ func NewOpenMetricsFormat(version string) (Format, error) {
case OpenMetricsVersion_1_0_0:
return FmtOpenMetrics_1_0_0, nil
case OpenMetricsVersion_2_0_0_rc:
// OpenMetrics 2.0.0-rc is experimental and encode-only.
// OpenMetrics 2.0.0-rc is experimental.
return FmtOpenMetrics_2_0_0_rc, nil
case OpenMetricsVersion_2_0_0:
// OpenMetrics 2.0.0 is experimental and encode-only.
// OpenMetrics 2.0.0 is experimental.
return FmtOpenMetrics_2_0_0, nil
default:
return FmtUnknown, errors.New("unknown open metrics version string")
Expand Down
43 changes: 21 additions & 22 deletions expfmt/openmetrics_2_0_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,16 @@ import (

dto "github.com/prometheus/client_model/go"
"google.golang.org/protobuf/types/known/timestamppb"

"github.com/prometheus/common/model"
)

// MetricFamilyToOpenMetrics20 converts a MetricFamily proto message into the
// OpenMetrics text format version 2.0.0 and writes the resulting lines to 'out'.
// It returns the number of bytes written and any error encountered.
//
// NOTE: This method targets OpenMetrics 2.0.0 (currently aligned with 2.0-rc.0) which is experimental and
// encode-only. All metric types defined in client_model (counter, gauge, summary, untyped, histogram,
// NOTE: This method targets OpenMetrics 2.0.0 (currently aligned with 2.0-rc.0) which is experimental.
// All metric types defined in client_model (counter, gauge, summary, untyped, histogram,
// and gaugehistogram) are supported; info and stateset are not supported as they are not present in
// client_model. Breaking changes might happen in the future.
// EncoderOptions are accepted for signature compatibility with
Expand Down Expand Up @@ -383,9 +385,6 @@ func writeCompositeSummary(w enhancedWriter, name string, metric *dto.Metric) (i
if math.IsNaN(sum) {
return 0, fmt.Errorf("summary sum cannot be NaN in metric %s", name)
}
if sum < 0 {
return 0, fmt.Errorf("summary sum cannot be negative (%g) in metric %s", sum, name)
}

var prevQuantile float64
for i, q := range s.Quantile {
Expand All @@ -403,11 +402,6 @@ func writeCompositeSummary(w enhancedWriter, name string, metric *dto.Metric) (i
return 0, fmt.Errorf("summary quantiles must be strictly increasing: %g <= %g in metric %s", qv, prevQuantile, name)
}
prevQuantile = qv

v := q.GetValue()
if !math.IsNaN(v) && v < 0 {
return 0, fmt.Errorf("summary quantile value cannot be negative (%g) in metric %s", v, name)
}
}

if s.CreatedTimestamp != nil {
Expand Down Expand Up @@ -547,7 +541,10 @@ func writeCompositeHistogram(w enhancedWriter, name string, metric *dto.Metric,
}
return 0, fmt.Errorf("histogram count cannot be NaN in metric %s", name)
}
if !isGauge && c < 0 {
if c < 0 {
if isGauge {
return 0, fmt.Errorf("gaugehistogram count cannot be negative (%g) in metric %s", c, name)
}
return 0, fmt.Errorf("histogram count cannot be negative (%g) in metric %s", c, name)
}
}
Expand All @@ -565,7 +562,7 @@ func writeCompositeHistogram(w enhancedWriter, name string, metric *dto.Metric,
}

if isNative {
if err := validateNativeHistogram(name, h, isGauge); err != nil {
if err := validateNativeHistogram(name, h); err != nil {
return 0, err
}
}
Expand Down Expand Up @@ -709,7 +706,7 @@ func writeCompositeHistogram(w enhancedWriter, name string, metric *dto.Metric,
return written, nil
}

func validateNativeHistogram(name string, h *dto.Histogram, isGauge bool) error {
func validateNativeHistogram(name string, h *dto.Histogram) error {
schema := *h.Schema
if schema < -4 || schema > 8 {
return fmt.Errorf("native histogram schema %d is out of range [-4, 8] in metric %s", schema, name)
Expand All @@ -725,15 +722,15 @@ func validateNativeHistogram(name string, h *dto.Histogram, isGauge bool) error
if math.IsNaN(c) {
return fmt.Errorf("native histogram zero_count cannot be NaN in metric %s", name)
}
if !isGauge && c < 0 {
if c < 0 {
return fmt.Errorf("native histogram zero_count cannot be negative (%g) in metric %s", c, name)
}
}

if err := validateSpansAndBuckets(name, "negative", h.NegativeSpan, h.NegativeDelta, h.NegativeCount, isGauge); err != nil {
if err := validateSpansAndBuckets(name, "negative", h.NegativeSpan, h.NegativeDelta, h.NegativeCount); err != nil {
return err
}
return validateSpansAndBuckets(name, "positive", h.PositiveSpan, h.PositiveDelta, h.PositiveCount, isGauge)
return validateSpansAndBuckets(name, "positive", h.PositiveSpan, h.PositiveDelta, h.PositiveCount)
}

func validateSpansAndBuckets(
Expand All @@ -742,7 +739,6 @@ func validateSpansAndBuckets(
spans []*dto.BucketSpan,
deltas []int64,
floatCounts []float64,
isGauge bool,
) error {
isFloatBuckets := len(floatCounts) > 0
var numBuckets int
Expand Down Expand Up @@ -779,15 +775,15 @@ func validateSpansAndBuckets(
if math.IsNaN(v) {
return fmt.Errorf("%s bucket count cannot be NaN in metric %s", spanName, name)
}
if !isGauge && v < 0 {
if v < 0 {
return fmt.Errorf("%s bucket count cannot be negative (%g) in metric %s", spanName, v, name)
}
}
} else {
var current int64
for _, d := range deltas {
current += d
if !isGauge && current < 0 {
if current < 0 {
return fmt.Errorf("%s bucket count cannot be negative (%d) in metric %s", spanName, current, name)
}
}
Expand Down Expand Up @@ -996,7 +992,7 @@ func validateClassicBuckets(
if math.IsNaN(c) {
return fmt.Errorf("classic bucket count cannot be NaN in metric %s", name)
}
if !isGauge && c < 0 {
if c < 0 {
return fmt.Errorf("classic bucket count cannot be negative (%g) in metric %s", c, name)
}
}
Expand All @@ -1009,7 +1005,7 @@ func validateClassicBuckets(
bCount = float64(*b.CumulativeCount)
}

if !isGauge && i > 0 && bCount < prevCount {
if i > 0 && bCount < prevCount {
return fmt.Errorf("classic bucket counts must be monotonically increasing: %g < %g in metric %s", bCount, prevCount, name)
}
prevCount = bCount
Expand Down Expand Up @@ -1037,7 +1033,7 @@ func validateClassicBuckets(
}
}

if !infSeen && len(h.Bucket) > 0 && !isGauge {
if !infSeen && len(h.Bucket) > 0 {
if sampleCount < prevCount {
return fmt.Errorf("sample count (%g) is less than highest bucket count (%g) in metric %s", sampleCount, prevCount, name)
}
Expand Down Expand Up @@ -1142,6 +1138,9 @@ func validateLabels20(labels []*dto.LabelPair) error {
if lname == "" {
return errors.New("label name cannot be empty")
}
if lname == string(model.MetricNameLabel) {
return fmt.Errorf("label name %q is reserved", model.MetricNameLabel)
}
if containsRawNewline(lname) {
return fmt.Errorf("label name %q contains raw newlines", lname)
}
Expand Down
Loading
Loading