Skip to content
Open
14 changes: 14 additions & 0 deletions expfmt/benchmark_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,5 +209,19 @@ func BenchmarkConvertMetricFamily(b *testing.B) {
out.Reset()
}
})
b.Run("OM2.0/"+mf.GetType().String(), func(b *testing.B) {
out := bytes.NewBuffer(make([]byte, 0, 1024))
if _, err := MetricFamilyToOpenMetrics20(out, mf); err != nil {
b.Skipf("skipping unsupported type: %v", err)
}
out.Reset()
for b.Loop() {
_, err := MetricFamilyToOpenMetrics20(out, mf)
if err != nil {
b.Fatal(err)
}
out.Reset()
}
})
}
}
101 changes: 66 additions & 35 deletions expfmt/encode.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"fmt"
"io"
"net/http"
"strings"

"github.com/munnerz/goautoneg"
dto "github.com/prometheus/client_model/go"
Expand Down Expand Up @@ -59,41 +60,25 @@ func (ec encoderCloser) Close() error {
// appropriate accepted type is found, FmtText is returned (which is the
// Prometheus text format). This function will never negotiate FmtOpenMetrics,
// as the support is still experimental. To include the option to negotiate
// FmtOpenMetrics, use NegotiateIncludingOpenMetrics.
// FmtOpenMetrics, use NegotiateIncludingOpenMetrics or NegotiateIncluding.
func Negotiate(h http.Header) Format {
escapingScheme := Format(fmt.Sprintf("; escaping=%s", Format(model.NameEscapingScheme.String())))
for _, ac := range goautoneg.ParseAccept(h.Get(hdrAccept)) {
if escapeParam := ac.Params[model.EscapingKey]; escapeParam != "" {
switch Format(escapeParam) {
case model.AllowUTF8, model.EscapeUnderscores, model.EscapeDots, model.EscapeValues:
escapingScheme = Format("; escaping=" + escapeParam)
default:
// If the escaping parameter is unknown, ignore it.
}
}
ver := ac.Params["version"]
if ac.Type+"/"+ac.SubType == ProtoType && ac.Params["proto"] == ProtoProtocol {
switch ac.Params["encoding"] {
case "delimited":
return FmtProtoDelim + escapingScheme
case "text":
return FmtProtoText + escapingScheme
case "compact-text":
return FmtProtoCompact + escapingScheme
}
}
if ac.Type == "text" && ac.SubType == "plain" && (ver == TextVersion || ver == "") {
return FmtText + escapingScheme
}
}
return FmtText + escapingScheme
return NegotiateIncluding(h)
}

// NegotiateIncludingOpenMetrics works like Negotiate but includes
// FmtOpenMetrics as an option for the result. Note that this function is
// temporary and will disappear once FmtOpenMetrics is fully supported and as
// such may be negotiated by the normal Negotiate function.
func NegotiateIncludingOpenMetrics(h http.Header) Format {
return NegotiateIncluding(h, FmtOpenMetrics_1_0_0, FmtOpenMetrics_0_0_1)
Comment thread
dashpole marked this conversation as resolved.
}

// NegotiateIncluding returns the Content-Type based on the given Accept header.
// It automatically includes the default formats (Protobuf and Text) as options.
// Additional formats provided in the arguments are also included as options,
// and are checked in the order they are provided, respecting the preference
// order in the Accept header.
func NegotiateIncluding(h http.Header, additionalFormats ...Format) Format {
Comment thread
dashpole marked this conversation as resolved.
escapingScheme := Format(fmt.Sprintf("; escaping=%s", Format(model.NameEscapingScheme.String())))
for _, ac := range goautoneg.ParseAccept(h.Get(hdrAccept)) {
if escapeParam := ac.Params[model.EscapingKey]; escapeParam != "" {
Expand All @@ -105,6 +90,13 @@ func NegotiateIncludingOpenMetrics(h http.Header) Format {
}
}
ver := ac.Params["version"]

for _, f := range additionalFormats {
if matchFormat(ac, f) {
return f + escapingScheme
}
}

if ac.Type+"/"+ac.SubType == ProtoType && ac.Params["proto"] == ProtoProtocol {
switch ac.Params["encoding"] {
case "delimited":
Expand All @@ -118,18 +110,45 @@ func NegotiateIncludingOpenMetrics(h http.Header) Format {
if ac.Type == "text" && ac.SubType == "plain" && (ver == TextVersion || ver == "") {
return FmtText + escapingScheme
}
if ac.Type+"/"+ac.SubType == OpenMetricsType && (ver == OpenMetricsVersion_0_0_1 || ver == OpenMetricsVersion_1_0_0 || ver == "") {
switch ver {
case OpenMetricsVersion_1_0_0:
return FmtOpenMetrics_1_0_0 + escapingScheme
default:
return FmtOpenMetrics_0_0_1 + escapingScheme
}
}
}
return FmtText + escapingScheme
}

// matchFormat checks if a parsed accept clause matches a given Format.
func matchFormat(ac goautoneg.Accept, f Format) bool {
parsed := goautoneg.ParseAccept(string(f))
if len(parsed) == 0 {
return false
}
target := parsed[0]

if ac.Type != target.Type || ac.SubType != target.SubType {
return false
}

// Default OpenMetrics version to OpenMetricsVersion_0_0_1
acVersion := ac.Params["version"]
if acVersion == "" && ac.Type+"/"+ac.SubType == OpenMetricsType {
acVersion = OpenMetricsVersion_0_0_1
}

// General param matching
for k, v := range target.Params {
if k == "charset" {
continue
}
acVal := ac.Params[k]
if k == "version" {
acVal = acVersion
}
if acVal != v {
return false
}
}

return true
}

// NewEncoder returns a new encoder based on content type negotiation. All
// Encoder implementations returned by NewEncoder also implement Closer, and
// callers should always call the Close method. It is currently only required
Expand Down Expand Up @@ -181,6 +200,18 @@ 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) {
return encoderCloser{
encode: func(v *dto.MetricFamily) error {
_, err := MetricFamilyToOpenMetrics20(w, model.EscapeMetricFamily(v, escapingScheme), options...)
return err
},
close: func() error {
_, err := FinalizeOpenMetrics(w)
return err
},
}
}
return encoderCloser{
encode: func(v *dto.MetricFamily) error {
_, err := MetricFamilyToOpenMetrics(w, model.EscapeMetricFamily(v, escapingScheme), options...)
Expand Down
71 changes: 71 additions & 0 deletions expfmt/encode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,11 @@ func TestNegotiateIncludingOpenMetrics(t *testing.T) {
acceptHeaderValue: "application/openmetrics-text;version=1.0.0",
expectedFmt: "application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=values",
},
{
name: "OM format, 2.0.0 version",
acceptHeaderValue: "application/openmetrics-text;version=2.0.0",
expectedFmt: "text/plain; version=0.0.4; charset=utf-8; escaping=values",
},
{
name: "OM format, 0.0.1 version with utf-8 is not valid, falls back",
acceptHeaderValue: "application/openmetrics-text;version=0.0.1",
Expand Down Expand Up @@ -200,6 +205,63 @@ func TestNegotiateIncludingOpenMetrics(t *testing.T) {
}
}

func TestNegotiateIncluding(t *testing.T) {
tests := []struct {
name string
acceptHeaderValue string
additionalFormats []Format
expectedFmt string
}{
{
name: "requested OM 2.0, supported OM 2.0",
acceptHeaderValue: "application/openmetrics-text;version=2.0.0",
additionalFormats: []Format{FmtOpenMetrics_2_0_0},
expectedFmt: "application/openmetrics-text; version=2.0.0; charset=utf-8; escaping=values",
},
{
name: "requested OM 2.0, not supported",
acceptHeaderValue: "application/openmetrics-text;version=2.0.0",
additionalFormats: []Format{FmtOpenMetrics_1_0_0},
expectedFmt: "text/plain; version=0.0.4; charset=utf-8; escaping=values", // falls back to text
},
{
name: "requested OM 1.0 and 2.0, supported both, prefers first in Accept",
acceptHeaderValue: "application/openmetrics-text;version=1.0.0, application/openmetrics-text;version=2.0.0;q=0.9",
additionalFormats: []Format{FmtOpenMetrics_2_0_0, FmtOpenMetrics_1_0_0},
expectedFmt: "application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=values",
},
{
name: "requested OM 1.0 and 2.0, supported both, prefers higher q",
acceptHeaderValue: "application/openmetrics-text;version=1.0.0;q=0.8, application/openmetrics-text;version=2.0.0",
additionalFormats: []Format{FmtOpenMetrics_2_0_0, FmtOpenMetrics_1_0_0},
expectedFmt: "application/openmetrics-text; version=2.0.0; charset=utf-8; escaping=values",
},
{
name: "fallback to default text",
acceptHeaderValue: "application/unknown",
additionalFormats: []Format{FmtOpenMetrics_2_0_0},
expectedFmt: "text/plain; version=0.0.4; charset=utf-8; escaping=values",
},
}

oldDefault := model.NameEscapingScheme
model.NameEscapingScheme = model.ValueEncodingEscaping
defer func() {
model.NameEscapingScheme = oldDefault
}()

for i, test := range tests {
t.Run(test.name, func(t *testing.T) {
h := http.Header{}
h.Add(hdrAccept, test.acceptHeaderValue)
actualFmt := string(NegotiateIncluding(h, test.additionalFormats...))
if actualFmt != test.expectedFmt {
t.Errorf("case %d: expected NegotiateIncluding to return format %s, but got %s instead", i, test.expectedFmt, actualFmt)
}
})
}
}

func TestEncode(t *testing.T) {
metric1 := &dto.MetricFamily{
Name: proto.String("foo_metric"),
Expand Down Expand Up @@ -268,6 +330,15 @@ foo_metric 1.234
expOut: `# TYPE foo_metric unknown
# UNIT foo_metric seconds
foo_metric 1.234
`,
},
// 8: Untyped FmtOpenMetrics_2_0_0
{
metric: metric1,
format: FmtOpenMetrics_2_0_0,
expOut: `# TYPE foo_metric unknown
# UNIT foo_metric seconds
foo_metric 1.234
`,
},
}
Expand Down
7 changes: 7 additions & 0 deletions expfmt/expfmt.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ const (
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"

// The Content-Type values for the different wire protocols. Do not do direct
// comparisons to these constants, instead use the comparison functions.
Expand All @@ -59,6 +61,8 @@ const (
// 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.
//nolint:revive // Allow for underscores.
FmtOpenMetrics_0_0_1 Format = OpenMetricsType + `; version=` + OpenMetricsVersion_0_0_1 + `; charset=utf-8`
Expand Down Expand Up @@ -114,6 +118,9 @@ func NewOpenMetricsFormat(version string) (Format, error) {
if version == OpenMetricsVersion_1_0_0 {
return FmtOpenMetrics_1_0_0, nil
}
if version == OpenMetricsVersion_2_0_0 {
return FmtOpenMetrics_2_0_0, nil
}
return FmtUnknown, errors.New("unknown open metrics version string")
}

Expand Down
Loading
Loading