Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
46 changes: 39 additions & 7 deletions expfmt/encode.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"io"
"mime"
"net/http"
"slices"

"github.com/munnerz/goautoneg"
dto "github.com/prometheus/client_model/go"
Expand All @@ -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 {
Expand All @@ -45,6 +46,37 @@ func init() {
}
}

var (
defaultAcceptedFormats = []Format{
FmtProtoDelim,
FmtProtoText,
FmtProtoCompact,
FmtText,
}

defaultOpenMetricsAcceptedFormats = []Format{

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the idea is to add 2.0 once stable

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still FmtOpenMetrics_2_0_0 emits no warning (it did before)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What warning you have in mind? I don't recall any in the past? Also we probably don't want to pass logger to NewEncoder 🤔

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
Expand Down Expand Up @@ -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
Expand Down
51 changes: 44 additions & 7 deletions expfmt/encode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
{
Expand All @@ -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",
},
{
Expand All @@ -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",
},
{
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
},
{
Expand Down
107 changes: 56 additions & 51 deletions expfmt/expfmt.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,54 +16,48 @@ 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"
//nolint:revive // Allow for underscores.
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 = `<unknown>`
// 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 = `<unknown>`
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`
)
Expand All @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
22 changes: 22 additions & 0 deletions expfmt/expfmt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
Loading