diff --git a/expfmt/decode.go b/expfmt/decode.go index 8f8dc65d..0339d8c1 100644 --- a/expfmt/decode.go +++ b/expfmt/decode.go @@ -72,13 +72,15 @@ func ResponseFormat(h http.Header) Format { // NewDecoder returns a new decoder based on the given input format. Metric // names are validated based on the provided Format -- if the format requires -// escaping, raditional Prometheues validity checking is used. Otherwise, names +// escaping, traditional Prometheus validity checking is used. Otherwise, names // are checked for UTF-8 validity. Supported formats include delimited protobuf -// and Prometheus text format. For historical reasons, this decoder fallbacks -// to classic text decoding for any other format. This decoder does not fully -// support OpenMetrics although it may often succeed due to the similarities -// between the formats. This decoder may not support the latest features of -// Prometheus text format and is not intended for high-performance applications. +// and Prometheus text format. 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. This decoder does not fully +// support OpenMetrics although it may often succeed for OpenMetrics 1.0 due to +// the similarities between the formats. 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 func NewDecoder(r io.Reader, format Format) Decoder { scheme := model.LegacyValidation @@ -90,6 +92,11 @@ func NewDecoder(r io.Reader, format Format) Decoder { return &protoDecoder{r: bufio.NewReader(r), s: scheme} case TypeProtoText, TypeProtoCompact: return &errDecoder{err: fmt.Errorf("format %s not supported for decoding", format)} + case TypeOpenMetrics: + _, params, err := mime.ParseMediaType(string(format)) + if err == nil && params["version"] == OpenMetricsVersion_2_0_0 { + return &errDecoder{err: fmt.Errorf("format %s not supported for decoding", format)} + } } return &textDecoder{r: r, s: scheme} } diff --git a/expfmt/decode_test.go b/expfmt/decode_test.go index baa761db..917a4a1c 100644 --- a/expfmt/decode_test.go +++ b/expfmt/decode_test.go @@ -17,6 +17,7 @@ import ( "bufio" "bytes" "errors" + "fmt" "io" "math" "net/http" @@ -577,3 +578,69 @@ func TestTextDecoderWithBufioReader(t *testing.T) { } require.Truef(t, decoded, "Metric foo not decoded") } + +func TestNewDecoder(t *testing.T) { + om20Format, err := NewOpenMetricsFormat(OpenMetricsVersion_2_0_0) + require.NoError(t, err) + + tests := []struct { + name string + format Format + expectError bool + }{ + { + name: "Text format", + format: FmtText, + expectError: false, + }, + { + name: "ProtoDelim format", + format: FmtProtoDelim, + expectError: false, + }, + { + name: "ProtoText format", + format: FmtProtoText, + expectError: true, + }, + { + name: "ProtoCompact format", + format: FmtProtoCompact, + expectError: true, + }, + { + name: "OpenMetrics 0.0.1", + format: FmtOpenMetrics_0_0_1, + expectError: false, + }, + { + name: "OpenMetrics 1.0.0", + format: FmtOpenMetrics_1_0_0, + expectError: false, + }, + { + name: "OpenMetrics 2.0.0", + format: om20Format, + expectError: true, + }, + { + name: "OpenMetrics 2.0.0 with escaping", + format: om20Format.WithEscapingScheme(model.ValueEncodingEscaping), + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dec := NewDecoder(strings.NewReader(""), tt.format) + var mf dto.MetricFamily + err := dec.Decode(&mf) + if tt.expectError { + require.Error(t, err) + require.Contains(t, err.Error(), fmt.Sprintf("format %s not supported for decoding", tt.format)) + } else if err != nil { + require.ErrorIs(t, err, io.EOF) + } + }) + } +} diff --git a/expfmt/encode.go b/expfmt/encode.go index 7d06d3d9..6945356c 100644 --- a/expfmt/encode.go +++ b/expfmt/encode.go @@ -16,8 +16,8 @@ package expfmt import ( "fmt" "io" + "mime" "net/http" - "strings" "github.com/munnerz/goautoneg" dto "github.com/prometheus/client_model/go" @@ -222,7 +222,8 @@ func NewEncoder(w io.Writer, format Format, options ...EncoderOption) Encoder { close: func() error { return nil }, } case TypeOpenMetrics: - if strings.Contains(string(format), "version="+OpenMetricsVersion_2_0_0) { + _, params, err := mime.ParseMediaType(string(format)) + if err == nil && params["version"] == OpenMetricsVersion_2_0_0 { return encoderCloser{ encode: func(v *dto.MetricFamily) error { _, err := MetricFamilyToOpenMetrics20(w, model.EscapeMetricFamily(v, escapingScheme), options...) diff --git a/expfmt/encode_test.go b/expfmt/encode_test.go index 7ff1c312..670c5da5 100644 --- a/expfmt/encode_test.go +++ b/expfmt/encode_test.go @@ -16,12 +16,14 @@ package expfmt import ( "bytes" "net/http" + "strings" "testing" dto "github.com/prometheus/client_model/go" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" "github.com/prometheus/common/model" ) @@ -614,3 +616,75 @@ func BenchmarkNegotiateAccept(b *testing.B) { _ = NegotiateAccept(h, accepted...) } } + +func TestNewEncoder_OpenMetricsVersionDispatch(t *testing.T) { + counterMetric := &dto.MetricFamily{ + Name: proto.String("test_counter"), + Type: dto.MetricType_COUNTER.Enum(), + Metric: []*dto.Metric{ + { + Counter: &dto.Counter{ + Value: proto.Float64(42), + CreatedTimestamp: ×tamppb.Timestamp{ + Seconds: 1234567890, + Nanos: 0, + }, + }, + }, + }, + } + + tests := []struct { + name string + format Format + expectedLine string + }{ + { + name: "OpenMetrics 1.0.0", + format: FmtOpenMetrics_1_0_0, + expectedLine: "# TYPE test_counter unknown\ntest_counter 42.0\n", + }, + { + name: "OpenMetrics 0.0.1", + format: FmtOpenMetrics_0_0_1, + expectedLine: "# TYPE test_counter unknown\ntest_counter 42.0\n", + }, + { + name: "OpenMetrics 2.0.0 standard", + format: fmtOpenMetrics_2_0_0, + expectedLine: "# TYPE test_counter counter\ntest_counter 42.0 st@1234567890\n", + }, + { + name: "OpenMetrics 2.0.0 reordered parameters", + format: Format("application/openmetrics-text; charset=utf-8; version=2.0.0"), + expectedLine: "# TYPE test_counter counter\ntest_counter 42.0 st@1234567890\n", + }, + { + name: "OpenMetrics 2.0.0 quoted version parameter", + format: Format(`application/openmetrics-text; version="2.0.0"; charset=utf-8`), + expectedLine: "# TYPE test_counter counter\ntest_counter 42.0 st@1234567890\n", + }, + { + name: "OpenMetrics 2.0.0 with escaping scheme", + format: Format("application/openmetrics-text; version=2.0.0; charset=utf-8; escaping=values"), + expectedLine: "# TYPE test_counter counter\ntest_counter 42.0 st@1234567890\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + enc := NewEncoder(&buf, tt.format) + err := enc.Encode(counterMetric) + require.NoError(t, err) + closer, ok := enc.(Closer) + require.True(t, ok) + err = closer.Close() + require.NoError(t, err) + + output := buf.String() + require.Contains(t, output, tt.expectedLine) + require.True(t, strings.HasSuffix(output, "# EOF\n")) + }) + } +} diff --git a/expfmt/expfmt.go b/expfmt/expfmt.go index a9092e6f..40035f20 100644 --- a/expfmt/expfmt.go +++ b/expfmt/expfmt.go @@ -111,6 +111,9 @@ func NewFormat(t FormatType) Format { // NewOpenMetricsFormat generates a new OpenMetrics format matching the // specified version number. +// +// Note: OpenMetrics version 2.0.0 is experimental and encode-only (currently +// supporting counter, gauge, and untyped metric types). func NewOpenMetricsFormat(version string) (Format, error) { if version == OpenMetricsVersion_0_0_1 { return FmtOpenMetrics_0_0_1, nil @@ -119,6 +122,7 @@ func NewOpenMetricsFormat(version string) (Format, error) { return FmtOpenMetrics_1_0_0, nil } if version == OpenMetricsVersion_2_0_0 { + // OpenMetrics 2.0.0 is experimental and encode-only (counter/gauge/untyped). return fmtOpenMetrics_2_0_0, nil } return FmtUnknown, errors.New("unknown open metrics version string") diff --git a/expfmt/openmetrics_2_0_create.go b/expfmt/openmetrics_2_0_create.go index a218226b..aa3ae48f 100644 --- a/expfmt/openmetrics_2_0_create.go +++ b/expfmt/openmetrics_2_0_create.go @@ -30,10 +30,14 @@ import ( // 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 implements OpenMetrics 2.0-rc.0 which is experimental. +// NOTE: This method targets OpenMetrics 2.0.0 (currently aligned with 2.0-rc.0) which is experimental and +// encode-only (currently supporting counter, gauge, and untyped metric types). // Breaking changes might happen in the future. This implementation is still a // work-in-progress, and does not yet support all features of the format. +// EncoderOptions are accepted for signature compatibility with +// MetricFamilyToOpenMetrics and are currently ignored. func MetricFamilyToOpenMetrics20(out io.Writer, in *dto.MetricFamily, options ...EncoderOption) (written int, err error) { + // Options are accepted for signature compatibility and ignored. _ = options name := in.GetName() if name == "" {