From b0a582740d5068341ac692a2d1fadca298e075ca Mon Sep 17 00:00:00 2001 From: bwplotka Date: Tue, 22 Sep 2026 16:10:08 +0100 Subject: [PATCH] expfmt: simplify format constants and provide default negotiation slices - Undeprecate Fmt... constants and ProtoFmt, clarifying documentation that these represent baseline Content-Types. - Export FmtOpenMetrics_2_0_0 constant. - Add Format.Version() to extract format version parameter. - Add Format.Matches() to safely compare formats while ignoring dynamic parameters (e.g. escaping scheme) and whitespace differences. - Provide DefaultAcceptedFormats, DefaultOpenMetricsAcceptedFormats, and DefaultOpenMetrics2AcceptedFormats slices for use with NegotiateAccept. - Update Negotiate and NegotiateIncludingOpenMetrics to use the new slices. Signed-off-by: bwplotka --- expfmt/encode.go | 45 +++++++++-- expfmt/encode_test.go | 80 ++++++++++++++++++-- expfmt/expfmt.go | 78 +++++++++++++------ expfmt/expfmt_test.go | 169 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 337 insertions(+), 35 deletions(-) diff --git a/expfmt/encode.go b/expfmt/encode.go index 6945356ca..79790999f 100644 --- a/expfmt/encode.go +++ b/expfmt/encode.go @@ -36,7 +36,7 @@ func init() { FmtProtoText, FmtProtoCompact, FmtOpenMetrics_1_0_0, - fmtOpenMetrics_2_0_0, + FmtOpenMetrics_2_0_0, FmtOpenMetrics_0_0_1, } { if parsed := goautoneg.ParseAccept(string(f)); len(parsed) > 0 { @@ -45,6 +45,41 @@ func init() { } } +var ( + // DefaultAcceptedFormats contains the standard accepted formats for Negotiate, + // ordered by preference (delimited protobuf, protobuf text, compact protobuf text, + // and Prometheus text format). + DefaultAcceptedFormats = []Format{ + FmtProtoDelim, + FmtProtoText, + FmtProtoCompact, + FmtText, + } + + // DefaultOpenMetricsAcceptedFormats contains the standard accepted formats including + // OpenMetrics 1.0.0 and 0.0.1, ordered by preference. + DefaultOpenMetricsAcceptedFormats = []Format{ + FmtOpenMetrics_1_0_0, + FmtOpenMetrics_0_0_1, + FmtProtoDelim, + FmtProtoText, + FmtProtoCompact, + FmtText, + } + + // DefaultOpenMetrics2AcceptedFormats contains the accepted formats including + // OpenMetrics 2.0.0, 1.0.0, and 0.0.1, ordered by preference. + DefaultOpenMetrics2AcceptedFormats = []Format{ + FmtOpenMetrics_2_0_0, + FmtOpenMetrics_1_0_0, + FmtOpenMetrics_0_0_1, + FmtProtoDelim, + FmtProtoText, + FmtProtoCompact, + FmtText, + } +) + // Encoder types encode metric families into an underlying wire protocol. type Encoder interface { Encode(*dto.MetricFamily) error @@ -78,19 +113,19 @@ func (ec encoderCloser) Close() error { // appropriate accepted type is found, FmtText is returned (which is the // Prometheus text format). // -// Deprecated: Use NegotiateAccept(h, FmtProtoDelim, FmtProtoText, FmtProtoCompact, FmtText) +// Deprecated: Use NegotiateAccept(h, DefaultAcceptedFormats...) // or specify only the formats supported by your server. func Negotiate(h http.Header) Format { - return NegotiateAccept(h, FmtProtoDelim, FmtProtoText, FmtProtoCompact, FmtText) + return NegotiateAccept(h, DefaultAcceptedFormats...) } // NegotiateIncludingOpenMetrics works like Negotiate but includes // FmtOpenMetrics as an option for the result. // -// Deprecated: Use NegotiateAccept(h, FmtOpenMetrics_1_0_0, FmtOpenMetrics_0_0_1, FmtProtoDelim, FmtProtoText, FmtProtoCompact, FmtText) +// Deprecated: Use NegotiateAccept(h, DefaultOpenMetricsAcceptedFormats...) // or specify only the formats supported by your server. func NegotiateIncludingOpenMetrics(h http.Header) Format { - return NegotiateAccept(h, FmtOpenMetrics_1_0_0, FmtOpenMetrics_0_0_1, FmtProtoDelim, FmtProtoText, FmtProtoCompact, FmtText) + return NegotiateAccept(h, DefaultOpenMetricsAcceptedFormats...) } // NegotiateAccept returns the Content-Type based on the given Accept header diff --git a/expfmt/encode_test.go b/expfmt/encode_test.go index 670c5da58..d13565796 100644 --- a/expfmt/encode_test.go +++ b/expfmt/encode_test.go @@ -247,7 +247,7 @@ func TestNegotiateAccept(t *testing.T) { { name: "requested OM 2.0, accepted OM 2.0", acceptHeaderValue: "application/openmetrics-text;version=2.0.0", - acceptedFormats: []Format{fmtOpenMetrics_2_0_0, FmtText}, + acceptedFormats: []Format{FmtOpenMetrics_2_0_0, FmtText}, expectedFmt: "application/openmetrics-text; version=2.0.0; charset=utf-8; escaping=values", }, { @@ -265,13 +265,13 @@ func TestNegotiateAccept(t *testing.T) { { name: "requested OM 1.0 and 2.0, prefers higher q value", acceptHeaderValue: "application/openmetrics-text;version=1.0.0;q=0.8, application/openmetrics-text;version=2.0.0;q=0.9", - acceptedFormats: []Format{FmtOpenMetrics_1_0_0, fmtOpenMetrics_2_0_0, FmtText}, + acceptedFormats: []Format{FmtOpenMetrics_1_0_0, FmtOpenMetrics_2_0_0, FmtText}, expectedFmt: "application/openmetrics-text; version=2.0.0; charset=utf-8; escaping=values", }, { name: "wildcard */* matches text format if present", acceptHeaderValue: "*/*", - acceptedFormats: []Format{fmtOpenMetrics_2_0_0, FmtProtoDelim, FmtText}, + acceptedFormats: []Format{FmtOpenMetrics_2_0_0, FmtProtoDelim, FmtText}, expectedFmt: "text/plain; version=0.0.4; charset=utf-8; escaping=values", }, { @@ -283,7 +283,7 @@ func TestNegotiateAccept(t *testing.T) { { name: "wildcard */* falls back to first format when no text in accepted", acceptHeaderValue: "*/*", - acceptedFormats: []Format{fmtOpenMetrics_2_0_0, FmtProtoDelim}, + acceptedFormats: []Format{FmtOpenMetrics_2_0_0, FmtProtoDelim}, expectedFmt: "application/openmetrics-text; version=2.0.0; charset=utf-8; escaping=values", }, { @@ -298,6 +298,30 @@ func TestNegotiateAccept(t *testing.T) { acceptedFormats: nil, expectedFmt: "text/plain; version=0.0.4; charset=utf-8; escaping=values", }, + { + name: "DefaultOpenMetrics2AcceptedFormats with OM 2.0 accept header", + acceptHeaderValue: "application/openmetrics-text;version=2.0.0", + acceptedFormats: DefaultOpenMetrics2AcceptedFormats, + expectedFmt: "application/openmetrics-text; version=2.0.0; charset=utf-8; escaping=values", + }, + { + name: "DefaultOpenMetrics2AcceptedFormats with OM 1.0 accept header", + acceptHeaderValue: "application/openmetrics-text;version=1.0.0", + acceptedFormats: DefaultOpenMetrics2AcceptedFormats, + expectedFmt: "application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=values", + }, + { + name: "DefaultOpenMetrics2AcceptedFormats with proto accept header", + acceptHeaderValue: "application/vnd.google.protobuf;proto=io.prometheus.client.MetricFamily;encoding=delimited", + acceptedFormats: DefaultOpenMetrics2AcceptedFormats, + expectedFmt: "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=values", + }, + { + name: "DefaultOpenMetrics2AcceptedFormats with wildcard accept header", + acceptHeaderValue: "*/*", + acceptedFormats: DefaultOpenMetrics2AcceptedFormats, + expectedFmt: "text/plain; version=0.0.4; charset=utf-8; escaping=values", + }, } oldDefault := model.NameEscapingScheme @@ -318,6 +342,48 @@ func TestNegotiateAccept(t *testing.T) { } } +func TestDefaultAcceptedFormats(t *testing.T) { + require.Equal(t, []Format{ + FmtProtoDelim, + FmtProtoText, + FmtProtoCompact, + FmtText, + }, DefaultAcceptedFormats) + + require.Equal(t, []Format{ + FmtOpenMetrics_1_0_0, + FmtOpenMetrics_0_0_1, + FmtProtoDelim, + FmtProtoText, + FmtProtoCompact, + FmtText, + }, DefaultOpenMetricsAcceptedFormats) + + require.Equal(t, []Format{ + FmtOpenMetrics_2_0_0, + FmtOpenMetrics_1_0_0, + FmtOpenMetrics_0_0_1, + FmtProtoDelim, + FmtProtoText, + FmtProtoCompact, + FmtText, + }, DefaultOpenMetrics2AcceptedFormats) + + for _, accept := range []string{ + "application/vnd.google.protobuf;proto=io.prometheus.client.MetricFamily;encoding=delimited", + "application/openmetrics-text;version=1.0.0", + "application/openmetrics-text;version=2.0.0", + "text/plain", + "*/*", + "unknown/format", + } { + h := http.Header{} + h.Set(hdrAccept, accept) + require.Equal(t, NegotiateAccept(h, DefaultAcceptedFormats...), Negotiate(h)) + require.Equal(t, NegotiateAccept(h, DefaultOpenMetricsAcceptedFormats...), NegotiateIncludingOpenMetrics(h)) + } +} + func TestEncode(t *testing.T) { metric1 := &dto.MetricFamily{ Name: proto.String("foo_metric"), @@ -388,10 +454,10 @@ foo_metric 1.234 foo_metric 1.234 `, }, - // 8: Untyped fmtOpenMetrics_2_0_0 + // 8: Untyped FmtOpenMetrics_2_0_0 { metric: metric1, - format: fmtOpenMetrics_2_0_0, + format: FmtOpenMetrics_2_0_0, expOut: `# TYPE foo_metric unknown # UNIT foo_metric seconds foo_metric 1.234 @@ -651,7 +717,7 @@ func TestNewEncoder_OpenMetricsVersionDispatch(t *testing.T) { }, { name: "OpenMetrics 2.0.0 standard", - format: fmtOpenMetrics_2_0_0, + format: FmtOpenMetrics_2_0_0, expectedLine: "# TYPE test_counter counter\ntest_counter 42.0 st@1234567890\n", }, { diff --git a/expfmt/expfmt.go b/expfmt/expfmt.go index e9caba463..b74395d5f 100644 --- a/expfmt/expfmt.go +++ b/expfmt/expfmt.go @@ -16,6 +16,7 @@ package expfmt import ( "errors" + "mime" "strings" "github.com/prometheus/common/model" @@ -25,18 +26,13 @@ import ( type Format string // Constants to assemble the Content-Type values for the different wire -// protocols. The Content-Type strings here are all for the legacy exposition -// formats, where valid characters for metric names and label names are limited. -// Support for arbitrary UTF-8 characters in those names is already partially -// implemented in this module (see model.ValidationScheme), but to actually use -// it on the wire, new content-type strings will have to be agreed upon and -// added here. +// protocols. const ( TextVersion = "0.0.4" ProtoType = `application/vnd.google.protobuf` ProtoProtocol = `io.prometheus.client.MetricFamily` - // Deprecated: Use expfmt.NewFormat(expfmt.TypeProtoCompact) instead. - ProtoFmt = ProtoType + "; proto=" + ProtoProtocol + ";" + ProtoFmt = ProtoType + "; proto=" + ProtoProtocol + ";" + OpenMetricsType = `application/openmetrics-text` //nolint:revive // Allow for underscores. OpenMetricsVersion_0_0_1 = "0.0.1" @@ -45,25 +41,23 @@ const ( //nolint:revive // Allow for underscores. OpenMetricsVersion_2_0_0 = "2.0.0" - // The Content-Type values for the different wire protocols. Do not do direct - // comparisons to these constants, instead use the comparison functions. - // - // Deprecated: Use expfmt.NewFormat(expfmt.TypeUnknown) instead. + // The Content-Type values for the different wire protocols. These represent + // baseline Content-Types used for HTTP headers and content negotiation. + // Because Content-Types on the wire may include dynamic parameters + // (such as "; escaping=...") or whitespace variations, avoid comparing Format + // values using direct equality (==). Instead, use the Matches method, e.g. + // format.Matches(expfmt.FmtText). FmtUnknown Format = `` - // Deprecated: Use expfmt.NewFormat(expfmt.TypeTextPlain) instead. - FmtText Format = `text/plain; version=` + TextVersion + `; charset=utf-8` - // Deprecated: Use expfmt.NewFormat(expfmt.TypeProtoDelim) instead. - FmtProtoDelim Format = ProtoFmt + ` encoding=delimited` - // Deprecated: Use expfmt.NewFormat(expfmt.TypeProtoText) instead. - FmtProtoText Format = ProtoFmt + ` encoding=text` - // Deprecated: Use expfmt.NewFormat(expfmt.TypeProtoCompact) instead. + FmtText Format = `text/plain; version=` + TextVersion + `; charset=utf-8` + + FmtProtoDelim Format = ProtoFmt + ` encoding=delimited` + FmtProtoText Format = ProtoFmt + ` encoding=text` FmtProtoCompact Format = ProtoFmt + ` encoding=compact-text` - // Deprecated: Use expfmt.NewFormat(expfmt.TypeOpenMetrics) instead. + //nolint:revive // Allow for underscores. FmtOpenMetrics_1_0_0 Format = OpenMetricsType + `; version=` + OpenMetricsVersion_1_0_0 + `; charset=utf-8` //nolint:revive // Allow for underscores. - fmtOpenMetrics_2_0_0 Format = OpenMetricsType + `; version=` + OpenMetricsVersion_2_0_0 + `; charset=utf-8` - // Deprecated: Use expfmt.NewFormat(expfmt.TypeOpenMetrics) instead. + FmtOpenMetrics_2_0_0 Format = OpenMetricsType + `; version=` + OpenMetricsVersion_2_0_0 + `; charset=utf-8` //nolint:revive // Allow for underscores. FmtOpenMetrics_0_0_1 Format = OpenMetricsType + `; version=` + OpenMetricsVersion_0_0_1 + `; charset=utf-8` ) @@ -123,11 +117,49 @@ func NewOpenMetricsFormat(version string) (Format, error) { } if version == OpenMetricsVersion_2_0_0 { // OpenMetrics 2.0.0 is experimental and encode-only. - return fmtOpenMetrics_2_0_0, nil + return FmtOpenMetrics_2_0_0, nil } return FmtUnknown, errors.New("unknown open metrics version string") } +// Version returns the format version parameter (e.g. "0.0.4", "1.0.0", "2.0.0"), +// or an empty string if unspecified or invalid. +func (f Format) Version() string { + _, params, err := mime.ParseMediaType(string(f)) + if err != nil { + return "" + } + return params["version"] +} + +// Matches reports whether format f matches the target Format. +// The comparison matches the media type and essential parameters (such as +// version, proto, and encoding) while ignoring dynamic parameters like +// escaping scheme ("; escaping=...") or formatting/whitespace differences. +func (f Format) Matches(target Format) bool { + if f == target { + return true + } + mediaType1, params1, err1 := mime.ParseMediaType(string(f)) + mediaType2, params2, err2 := mime.ParseMediaType(string(target)) + if err1 != nil || err2 != nil { + return f == target + } + if mediaType1 != mediaType2 { + return false + } + if params1["version"] != params2["version"] { + return false + } + if params1["encoding"] != params2["encoding"] { + return false + } + if params1["proto"] != params2["proto"] { + return false + } + return true +} + // WithEscapingScheme returns a copy of Format with the specified escaping // scheme appended to the end. If an escaping scheme already exists it is // removed. diff --git a/expfmt/expfmt_test.go b/expfmt/expfmt_test.go index 7ac054f23..390573f4d 100644 --- a/expfmt/expfmt_test.go +++ b/expfmt/expfmt_test.go @@ -160,3 +160,172 @@ func TestWithEscapingScheme(t *testing.T) { require.Equal(t, test.expected, string(test.format.WithEscapingScheme(test.scheme))) } } + +func TestFormat_Version(t *testing.T) { + tests := []struct { + format Format + expected string + }{ + {format: FmtText, expected: "0.0.4"}, + {format: FmtText.WithEscapingScheme(model.NoEscaping), expected: "0.0.4"}, + {format: FmtOpenMetrics_0_0_1, expected: "0.0.1"}, + {format: FmtOpenMetrics_1_0_0, expected: "1.0.0"}, + {format: FmtOpenMetrics_2_0_0, expected: "2.0.0"}, + {format: FmtOpenMetrics_2_0_0.WithEscapingScheme(model.DotsEscaping), expected: "2.0.0"}, + {format: FmtProtoDelim, expected: ""}, + {format: FmtProtoText, expected: ""}, + {format: FmtProtoCompact, expected: ""}, + {format: FmtUnknown, expected: ""}, + {format: Format("invalid"), expected: ""}, + } + for _, test := range tests { + require.Equal(t, test.expected, test.format.Version()) + } +} + +func TestFormat_Matches(t *testing.T) { + tests := []struct { + name string + f Format + target Format + expected bool + }{ + { + name: "FmtText matches FmtText", + f: FmtText, + target: FmtText, + expected: true, + }, + { + name: "FmtText matches FmtText with escaping scheme", + f: FmtText.WithEscapingScheme(model.NoEscaping), + target: FmtText, + expected: true, + }, + { + name: "FmtText matches FmtText with another escaping scheme", + f: FmtText.WithEscapingScheme(model.DotsEscaping), + target: FmtText.WithEscapingScheme(model.NoEscaping), + expected: true, + }, + { + name: "FmtText matches without charset parameter", + f: Format("text/plain; version=0.0.4"), + target: FmtText, + expected: true, + }, + { + name: "FmtText matches with whitespace variation", + f: Format("text/plain;version=0.0.4;charset=utf-8"), + target: FmtText, + expected: true, + }, + { + name: "FmtText does not match different version", + f: Format("text/plain; version=0.0.5; charset=utf-8"), + target: FmtText, + expected: false, + }, + { + name: "FmtText does not match FmtProtoDelim", + f: FmtText, + target: FmtProtoDelim, + expected: false, + }, + { + name: "FmtText does not match FmtUnknown", + f: FmtText, + target: FmtUnknown, + expected: false, + }, + { + name: "FmtOpenMetrics_1_0_0 matches itself", + f: FmtOpenMetrics_1_0_0, + target: FmtOpenMetrics_1_0_0, + expected: true, + }, + { + name: "FmtOpenMetrics_1_0_0 matches with escaping scheme", + f: FmtOpenMetrics_1_0_0.WithEscapingScheme(model.ValueEncodingEscaping), + target: FmtOpenMetrics_1_0_0, + expected: true, + }, + { + name: "FmtOpenMetrics_1_0_0 does not match FmtOpenMetrics_2_0_0", + f: FmtOpenMetrics_1_0_0, + target: FmtOpenMetrics_2_0_0, + expected: false, + }, + { + name: "FmtOpenMetrics_1_0_0 does not match FmtOpenMetrics_0_0_1", + f: FmtOpenMetrics_1_0_0, + target: FmtOpenMetrics_0_0_1, + expected: false, + }, + { + name: "FmtOpenMetrics_2_0_0 matches itself", + f: FmtOpenMetrics_2_0_0, + target: FmtOpenMetrics_2_0_0, + expected: true, + }, + { + name: "FmtOpenMetrics_2_0_0 matches with escaping scheme", + f: FmtOpenMetrics_2_0_0.WithEscapingScheme(model.NoEscaping), + target: FmtOpenMetrics_2_0_0, + expected: true, + }, + { + name: "FmtProtoDelim matches itself", + f: FmtProtoDelim, + target: FmtProtoDelim, + expected: true, + }, + { + name: "FmtProtoDelim matches with escaping scheme", + f: FmtProtoDelim.WithEscapingScheme(model.UnderscoreEscaping), + target: FmtProtoDelim, + expected: true, + }, + { + name: "FmtProtoDelim does not match FmtProtoText", + f: FmtProtoDelim, + target: FmtProtoText, + expected: false, + }, + { + name: "FmtProtoDelim does not match FmtProtoCompact", + f: FmtProtoDelim, + target: FmtProtoCompact, + expected: false, + }, + { + name: "FmtUnknown matches FmtUnknown", + f: FmtUnknown, + target: FmtUnknown, + expected: true, + }, + { + name: "FmtUnknown does not match FmtProtoDelim", + f: FmtUnknown, + target: FmtProtoDelim, + expected: false, + }, + { + name: "invalid format matches itself", + f: Format("not a media type"), + target: Format("not a media type"), + expected: true, + }, + { + name: "invalid format does not match different invalid format", + f: Format("not a media type 1"), + target: Format("not a media type 2"), + expected: false, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.expected, test.f.Matches(test.target)) + }) + } +}