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
81 changes: 42 additions & 39 deletions expfmt/encode.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"fmt"
"io"
"net/http"
"strings"

"github.com/munnerz/goautoneg"
dto "github.com/prometheus/client_model/go"
Expand Down Expand Up @@ -61,41 +62,42 @@
// as the support is still experimental. To include the option to negotiate
// FmtOpenMetrics, use NegotiateIncludingOpenMetrics.
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 NegotiateFormats(h)[0]
}

// NegotiateFormats works like Negotiate but returns all recognised formats
// from the Accept header in descending preference order instead of stopping
// at the first match. If no recognised formats are found, the slice contains
// FmtText as the default fallback. This function will never include
// FmtOpenMetrics in the result. To include OpenMetrics formats, use
// NegotiateFormatsIncludingOpenMetrics.
func NegotiateFormats(h http.Header) []Format {
return negotiateFormats(h, false)
}

// 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 {
escapingScheme := Format(fmt.Sprintf("; escaping=%s", Format(model.NameEscapingScheme.String())))
for _, ac := range goautoneg.ParseAccept(h.Get(hdrAccept)) {
return NegotiateFormatsIncludingOpenMetrics(h)[0]
}

// NegotiateFormatsIncludingOpenMetrics works like NegotiateIncludingOpenMetrics
// but returns all recognised formats from the Accept header in descending
// preference order instead of stopping at the first match. If no recognised
// formats are found, the slice contains FmtText as the default fallback.
// This allows a server that supports only a subset of formats to iterate
// through the slice and pick the first format it can serve.
func NegotiateFormatsIncludingOpenMetrics(h http.Header) []Format {
return negotiateFormats(h, true)
}

func negotiateFormats(h http.Header, includeOpenMetrics bool) []Format {
baseEscapingScheme := Format(fmt.Sprintf("; escaping=%s", Format(model.NameEscapingScheme.String())))
var formats []Format
for _, ac := range goautoneg.ParseAccept(strings.Join(h.Values(hdrAccept), ", ")) {
escapingScheme := baseEscapingScheme
if escapeParam := ac.Params[model.EscapingKey]; escapeParam != "" {
switch Format(escapeParam) {
case model.AllowUTF8, model.EscapeUnderscores, model.EscapeDots, model.EscapeValues:
Expand All @@ -105,29 +107,30 @@
}
}
ver := ac.Params["version"]
if ac.Type+"/"+ac.SubType == ProtoType && ac.Params["proto"] == ProtoProtocol {

Check failure on line 110 in expfmt/encode.go

View workflow job for this annotation

GitHub Actions / lint

ifElseChain: rewrite if-else to switch statement (gocritic)

Check failure on line 110 in expfmt/encode.go

View workflow job for this annotation

GitHub Actions / Test (1.25.x)

ifElseChain: rewrite if-else to switch statement (gocritic)
switch ac.Params["encoding"] {
case "delimited":
return FmtProtoDelim + escapingScheme
formats = append(formats, FmtProtoDelim+escapingScheme)
case "text":
return FmtProtoText + escapingScheme
formats = append(formats, FmtProtoText+escapingScheme)
case "compact-text":
return FmtProtoCompact + escapingScheme
formats = append(formats, FmtProtoCompact+escapingScheme)
}
}
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 == "") {
} else if ac.Type == "text" && ac.SubType == "plain" && (ver == TextVersion || ver == "") {
formats = append(formats, FmtText+escapingScheme)
} else if includeOpenMetrics && 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
formats = append(formats, FmtOpenMetrics_1_0_0+escapingScheme)
default:
return FmtOpenMetrics_0_0_1 + escapingScheme
formats = append(formats, FmtOpenMetrics_0_0_1+escapingScheme)
}
}
}
return FmtText + escapingScheme
if len(formats) == 0 {
formats = append(formats, FmtText+baseEscapingScheme)
}
return formats
}

// NewEncoder returns a new encoder based on content type negotiation. All
Expand Down
214 changes: 214 additions & 0 deletions expfmt/encode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,111 @@
}
}

func TestNegotiateFormats(t *testing.T) {
acceptValuePrefix := "application/vnd.google.protobuf;proto=io.prometheus.client.MetricFamily"
tests := []struct {
name string
acceptHeaderValues []string
expectedFmts []string
}{
{
name: "empty accept header returns FmtText fallback",
acceptHeaderValues: []string{},
expectedFmts: []string{"text/plain; version=0.0.4; charset=utf-8; escaping=underscores"},
},
{
name: "single protobuf delimited",
acceptHeaderValues: []string{acceptValuePrefix + ";encoding=delimited"},
expectedFmts: []string{"application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=underscores"},
},
{
name: "single protobuf text",
acceptHeaderValues: []string{acceptValuePrefix + ";encoding=text"},
expectedFmts: []string{"application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=text; escaping=underscores"},
},
{
name: "single protobuf compact-text",
acceptHeaderValues: []string{acceptValuePrefix + ";encoding=compact-text"},
expectedFmts: []string{"application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=compact-text; escaping=underscores"},
},
{
name: "plain text",
acceptHeaderValues: []string{"text/plain;version=0.0.4"},
expectedFmts: []string{"text/plain; version=0.0.4; charset=utf-8; escaping=underscores"},
},
{
name: "openmetrics is not recognised",
acceptHeaderValues: []string{"application/openmetrics-text;version=1.0.0"},
expectedFmts: []string{"text/plain; version=0.0.4; charset=utf-8; escaping=underscores"},
},
{
name: "multiple formats returned in preference order",
acceptHeaderValues: []string{
acceptValuePrefix + ";encoding=delimited;q=0.6",
"text/plain;version=0.0.4;q=0.2",
},
expectedFmts: []string{
"application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=underscores",
"text/plain; version=0.0.4; charset=utf-8; escaping=underscores",
},
},
{
name: "multiple formats with openmetrics skipped",
acceptHeaderValues: []string{
acceptValuePrefix + ";encoding=delimited;q=0.6",
"application/openmetrics-text;version=1.0.0;q=0.5",
"text/plain;version=0.0.4;q=0.2",
},
expectedFmts: []string{
"application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=underscores",
"text/plain; version=0.0.4; charset=utf-8; escaping=underscores",
},
},
{
name: "escaping param is respected",
acceptHeaderValues: []string{acceptValuePrefix + ";encoding=delimited;escaping=allow-utf-8"},
expectedFmts: []string{"application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=allow-utf-8"},
},
{
name: "unknown escaping param falls back to default",
acceptHeaderValues: []string{acceptValuePrefix + ";encoding=delimited;escaping=bogus"},
expectedFmts: []string{"application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=underscores"},
},
{
name: "single header with multiple comma-separated formats",
acceptHeaderValues: []string{
acceptValuePrefix + ";encoding=delimited;q=0.6," +
"application/openmetrics-text;version=1.0.0;escaping=allow-utf-8;q=0.5," +
"text/plain;version=0.0.4;q=0.2",
},
expectedFmts: []string{
"application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=underscores",
"text/plain; version=0.0.4; charset=utf-8; escaping=underscores",
},
},
}

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

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
h := http.Header{}
for _, v := range test.acceptHeaderValues {
h.Add(hdrAccept, v)
}
actualFmts := NegotiateFormats(h)
require.Len(t, actualFmts, len(test.expectedFmts), "number of returned formats")

Check failure on line 198 in expfmt/encode_test.go

View workflow job for this annotation

GitHub Actions / lint

formatter: use require.Lenf (testifylint)

Check failure on line 198 in expfmt/encode_test.go

View workflow job for this annotation

GitHub Actions / Test (1.25.x)

formatter: use require.Lenf (testifylint)
for i, expected := range test.expectedFmts {
assert.Equal(t, expected, string(actualFmts[i]), "format at index %d", i)

Check failure on line 200 in expfmt/encode_test.go

View workflow job for this annotation

GitHub Actions / lint

formatter: use assert.Equalf (testifylint)

Check failure on line 200 in expfmt/encode_test.go

View workflow job for this annotation

GitHub Actions / Test (1.25.x)

formatter: use assert.Equalf (testifylint)
}
})
}
}

func TestNegotiateIncludingOpenMetrics(t *testing.T) {
acceptValuePrefix := "application/vnd.google.protobuf;proto=io.prometheus.client.MetricFamily"
tests := []struct {
Expand Down Expand Up @@ -200,6 +305,115 @@
}
}

func TestNegotiateFormatsIncludingOpenMetrics(t *testing.T) {
acceptValuePrefix := "application/vnd.google.protobuf;proto=io.prometheus.client.MetricFamily"
tests := []struct {
name string
acceptHeaderValues []string
expectedFmts []string
}{
{
name: "empty accept header returns FmtText fallback",
acceptHeaderValues: []string{},
expectedFmts: []string{"text/plain; version=0.0.4; charset=utf-8; escaping=values"},
},
{
name: "single protobuf delimited",
acceptHeaderValues: []string{acceptValuePrefix + ";encoding=delimited"},
expectedFmts: []string{"application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=values"},
},
{
name: "single OM 1.0.0",
acceptHeaderValues: []string{"application/openmetrics-text;version=1.0.0"},
expectedFmts: []string{"application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=values"},
},
{
name: "single OM 0.0.1",
acceptHeaderValues: []string{"application/openmetrics-text;version=0.0.1"},
expectedFmts: []string{"application/openmetrics-text; version=0.0.1; charset=utf-8; escaping=values"},
},
{
name: "single OM no version",
acceptHeaderValues: []string{"application/openmetrics-text"},
expectedFmts: []string{"application/openmetrics-text; version=0.0.1; charset=utf-8; escaping=values"},
},
{
name: "OM invalid version is not recognised",
acceptHeaderValues: []string{"application/openmetrics-text;version=0.0.4"},
expectedFmts: []string{"text/plain; version=0.0.4; charset=utf-8; escaping=values"},
},
{
name: "multiple formats returned in preference order",
acceptHeaderValues: []string{
acceptValuePrefix + ";encoding=delimited;q=0.6",
"application/openmetrics-text;version=1.0.0;escaping=allow-utf-8;q=0.5",
"text/plain;version=0.0.4;q=0.2",
},
expectedFmts: []string{
"application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=values",
"application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=allow-utf-8",
"text/plain; version=0.0.4; charset=utf-8; escaping=values",
},
},
{
name: "server supporting only OM and text/plain can skip protobuf",
acceptHeaderValues: []string{
acceptValuePrefix + ";encoding=delimited;q=0.6",
"application/openmetrics-text;version=1.0.0;q=0.5",
"text/plain;version=0.0.4;q=0.2",
},
expectedFmts: []string{
"application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=values",
"application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=values",
"text/plain; version=0.0.4; charset=utf-8; escaping=values",
},
},
{
name: "escaping param is respected",
acceptHeaderValues: []string{acceptValuePrefix + ";encoding=delimited;escaping=underscores"},
expectedFmts: []string{"application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=underscores"},
},
{
name: "unknown escaping param falls back to default",
acceptHeaderValues: []string{acceptValuePrefix + ";encoding=delimited;escaping=bogus"},
expectedFmts: []string{"application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=values"},
},
{
name: "single header with multiple comma-separated formats",
acceptHeaderValues: []string{
acceptValuePrefix + ";encoding=delimited;q=0.6," +
"application/openmetrics-text;version=1.0.0;escaping=allow-utf-8;q=0.5," +
"text/plain;version=0.0.4;q=0.2",
},
expectedFmts: []string{
"application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=values",
"application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=allow-utf-8",
"text/plain; version=0.0.4; charset=utf-8; escaping=values",
},
},
}

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

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
h := http.Header{}
for _, v := range test.acceptHeaderValues {
h.Add(hdrAccept, v)
}
actualFmts := NegotiateFormatsIncludingOpenMetrics(h)
require.Len(t, actualFmts, len(test.expectedFmts), "number of returned formats")

Check failure on line 409 in expfmt/encode_test.go

View workflow job for this annotation

GitHub Actions / lint

formatter: use require.Lenf (testifylint)

Check failure on line 409 in expfmt/encode_test.go

View workflow job for this annotation

GitHub Actions / Test (1.25.x)

formatter: use require.Lenf (testifylint)
for i, expected := range test.expectedFmts {
assert.Equal(t, expected, string(actualFmts[i]), "format at index %d", i)

Check failure on line 411 in expfmt/encode_test.go

View workflow job for this annotation

GitHub Actions / lint

formatter: use assert.Equalf (testifylint)

Check failure on line 411 in expfmt/encode_test.go

View workflow job for this annotation

GitHub Actions / Test (1.25.x)

formatter: use assert.Equalf (testifylint)
}
})
}
}

func TestEncode(t *testing.T) {
metric1 := &dto.MetricFamily{
Name: proto.String("foo_metric"),
Expand Down
Loading