Skip to content
Open
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
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)
}

// 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 {
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