diff --git a/expfmt/encode.go b/expfmt/encode.go index 6945356c..651e69a2 100644 --- a/expfmt/encode.go +++ b/expfmt/encode.go @@ -18,6 +18,7 @@ import ( "io" "mime" "net/http" + "slices" "github.com/munnerz/goautoneg" dto "github.com/prometheus/client_model/go" @@ -36,7 +37,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 +46,37 @@ func init() { } } +var ( + defaultAcceptedFormats = []Format{ + FmtProtoDelim, + FmtProtoText, + FmtProtoCompact, + FmtText, + } + + defaultOpenMetricsAcceptedFormats = []Format{ + FmtOpenMetrics_1_0_0, + FmtOpenMetrics_0_0_1, + FmtProtoDelim, + FmtProtoText, + FmtProtoCompact, + FmtText, + } +) + +// DefaultAcceptedFormats returns a copy of standard accepted formats for Negotiate, +// ordered by preference (delimited protobuf, protobuf text, compact protobuf text, +// and Prometheus text format). +func DefaultAcceptedFormats() []Format { + return slices.Clone(defaultAcceptedFormats) +} + +// DefaultOpenMetricsAcceptedFormats returns a copy of standard accepted formats including +// stable OpenMetrics formats, ordered by preference. +func DefaultOpenMetricsAcceptedFormats() []Format { + return slices.Clone(defaultOpenMetricsAcceptedFormats) +} + // Encoder types encode metric families into an underlying wire protocol. type Encoder interface { Encode(*dto.MetricFamily) error @@ -78,19 +110,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. +// NegotiateIncludingOpenMetrics works like Negotiate but includes all stable +// FmtOpenMetrics formats 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 670c5da5..3c8bc220 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", }, { @@ -318,6 +318,43 @@ 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()) + + // Verify slices are cloned and mutating the returned slice doesn't alter subsequent calls. + cloned := DefaultAcceptedFormats() + cloned[0] = FmtUnknown + require.NotEqual(t, cloned[0], DefaultAcceptedFormats()[0]) + + 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 +425,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 +688,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 e9caba46..ea9a0a2a 100644 --- a/expfmt/expfmt.go +++ b/expfmt/expfmt.go @@ -16,27 +16,20 @@ package expfmt import ( "errors" + "mime" "strings" "github.com/prometheus/common/model" ) -// Format specifies the HTTP content type of the different wire protocols. -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" @@ -44,26 +37,27 @@ const ( OpenMetricsVersion_1_0_0 = "1.0.0" //nolint:revive // Allow for underscores. OpenMetricsVersion_2_0_0 = "2.0.0" +) + +// Format specifies the HTTP content type of the different wire protocols. +// The Content-Type values for the different wire protocols 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 (==). +type Format string - // 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. - 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. +const ( + FmtUnknown Format = `` + 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` + FmtOpenMetrics_2_0_0 Format = OpenMetricsType + `; version=` + OpenMetricsVersion_2_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_1_0_0 Format = OpenMetricsType + `; version=` + OpenMetricsVersion_1_0_0 + `; charset=utf-8` //nolint:revive // Allow for underscores. FmtOpenMetrics_0_0_1 Format = OpenMetricsType + `; version=` + OpenMetricsVersion_0_0_1 + `; charset=utf-8` ) @@ -90,7 +84,7 @@ const ( // NewFormat generates a new Format from the type provided. Mostly used for // tests, most Formats should be generated as part of content negotiation in -// encode.go. If a type has more than one version, the latest version will be +// encode.go. If a type has more than one version, the latest stable version will be // returned. func NewFormat(t FormatType) Format { switch t { @@ -123,32 +117,20 @@ 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") } -// WithEscapingScheme returns a copy of Format with the specified escaping -// scheme appended to the end. If an escaping scheme already exists it is -// removed. -func (f Format) WithEscapingScheme(s model.EscapingScheme) Format { - var terms []string - for p := range strings.SplitSeq(string(f), ";") { - toks := strings.Split(p, "=") - if len(toks) != 2 { - trimmed := strings.TrimSpace(p) - if len(trimmed) > 0 { - terms = append(terms, trimmed) - } - continue - } - key := strings.TrimSpace(toks[0]) - if key != model.EscapingKey { - terms = append(terms, strings.TrimSpace(p)) - } +// Version returns the version parameter without validating whether it is supported. +// It returns an empty string if the parameter is absent or the Content-Type +// cannot be parsed. +func (f Format) Version() string { + _, params, err := mime.ParseMediaType(string(f)) + if err != nil { + return "" } - terms = append(terms, model.EscapingKey+"="+s.String()) - return Format(strings.Join(terms, "; ")) + return params["version"] } // FormatType deduces an overall FormatType for the given format. @@ -200,8 +182,31 @@ func (f Format) FormatType() FormatType { } } -// ToEscapingScheme returns an EscapingScheme depending on the Format. Iff the -// Format contains a escaping=allow-utf-8 term, it will select NoEscaping. If a valid +// WithEscapingScheme returns a copy of Format with the specified escaping +// scheme appended to the end. If an escaping scheme already exists it is +// removed. +func (f Format) WithEscapingScheme(s model.EscapingScheme) Format { + var terms []string + for p := range strings.SplitSeq(string(f), ";") { + toks := strings.Split(p, "=") + if len(toks) != 2 { + trimmed := strings.TrimSpace(p) + if len(trimmed) > 0 { + terms = append(terms, trimmed) + } + continue + } + key := strings.TrimSpace(toks[0]) + if key != model.EscapingKey { + terms = append(terms, strings.TrimSpace(p)) + } + } + terms = append(terms, model.EscapingKey+"="+s.String()) + return Format(strings.Join(terms, "; ")) +} + +// ToEscapingScheme returns an EscapingScheme depending on the Format. IFF the +// Format contains an escaping=allow-utf-8 term, it will select NoEscaping. If a valid // "escaping" term exists, that will be used. Otherwise, the global default will // be returned. func (f Format) ToEscapingScheme() model.EscapingScheme { diff --git a/expfmt/expfmt_test.go b/expfmt/expfmt_test.go index 7ac054f2..0f2431ac 100644 --- a/expfmt/expfmt_test.go +++ b/expfmt/expfmt_test.go @@ -160,3 +160,25 @@ 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()) + } +}