diff --git a/expfmt/decode.go b/expfmt/decode.go index a909b171..3597bfd9 100644 --- a/expfmt/decode.go +++ b/expfmt/decode.go @@ -73,8 +73,8 @@ func ResponseFormat(h http.Header) Format { // NewDecoder returns a new decoder based on the given input format. // If the input format does not imply otherwise, a text format decoder is returned. func NewDecoder(r io.Reader, format Format) Decoder { - switch format { - case FmtProtoDelim: + switch format.FormatType() { + case TypeProtoDelim: return &protoDecoder{r: r} } return &textDecoder{r: r} diff --git a/expfmt/encode.go b/expfmt/encode.go index 02b7a5e8..97ee673d 100644 --- a/expfmt/encode.go +++ b/expfmt/encode.go @@ -22,6 +22,7 @@ import ( "google.golang.org/protobuf/encoding/prototext" "github.com/prometheus/common/internal/bitbucket.org/ww/goautoneg" + "github.com/prometheus/common/model" dto "github.com/prometheus/client_model/go" ) @@ -61,23 +62,32 @@ func (ec encoderCloser) Close() error { // as the support is still experimental. To include the option to negotiate // FmtOpenMetrics, use NegotiateOpenMetrics. 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(fmt.Sprintf("; escaping=%s", 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 + return FmtProtoDelim + escapingScheme case "text": - return FmtProtoText + return FmtProtoText + escapingScheme case "compact-text": - return FmtProtoCompact + return FmtProtoCompact + escapingScheme } } if ac.Type == "text" && ac.SubType == "plain" && (ver == TextVersion || ver == "") { - return FmtText + return FmtText + escapingScheme } } - return FmtText + return FmtText + escapingScheme } // NegotiateIncludingOpenMetrics works like Negotiate but includes @@ -85,29 +95,40 @@ func Negotiate(h http.Header) Format { // 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)) { + if escapeParam := ac.Params[model.EscapingKey]; escapeParam != "" { + switch Format(escapeParam) { + case model.AllowUTF8, model.EscapeUnderscores, model.EscapeDots, model.EscapeValues: + escapingScheme = Format(fmt.Sprintf("; escaping=%s", 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 + return FmtProtoDelim + escapingScheme case "text": - return FmtProtoText + return FmtProtoText + escapingScheme case "compact-text": - return FmtProtoCompact + return FmtProtoCompact + escapingScheme } } if ac.Type == "text" && ac.SubType == "plain" && (ver == TextVersion || ver == "") { - return FmtText + return FmtText + escapingScheme } if ac.Type+"/"+ac.SubType == OpenMetricsType && (ver == OpenMetricsVersion_0_0_1 || ver == OpenMetricsVersion_1_0_0 || ver == "") { - if ver == OpenMetricsVersion_1_0_0 { - return FmtOpenMetrics_1_0_0 + switch ver { + case OpenMetricsVersion_1_0_0: + return FmtOpenMetrics_1_0_0 + escapingScheme + default: + return FmtOpenMetrics_0_0_1 + escapingScheme } - return FmtOpenMetrics_0_0_1 } } - return FmtText + return FmtText + escapingScheme } // NewEncoder returns a new encoder based on content type negotiation. All @@ -116,9 +137,13 @@ func NegotiateIncludingOpenMetrics(h http.Header) Format { // for FmtOpenMetrics, but a future (breaking) release will add the Close method // to the Encoder interface directly. The current version of the Encoder // interface is kept for backwards compatibility. +// In cases where the Format does not allow for UTF-8 names, the global +// NameEscapingScheme will be applied. func NewEncoder(w io.Writer, format Format) Encoder { - switch format { - case FmtProtoDelim: + escapingScheme := format.ToEscapingScheme() + + switch format.FormatType() { + case TypeProtoDelim: return encoderCloser{ encode: func(v *dto.MetricFamily) error { _, err := protodelim.MarshalTo(w, v) @@ -126,34 +151,34 @@ func NewEncoder(w io.Writer, format Format) Encoder { }, close: func() error { return nil }, } - case FmtProtoCompact: + case TypeProtoCompact: return encoderCloser{ encode: func(v *dto.MetricFamily) error { - _, err := fmt.Fprintln(w, v.String()) + _, err := fmt.Fprintln(w, model.EscapeMetricFamily(v, escapingScheme).String()) return err }, close: func() error { return nil }, } - case FmtProtoText: + case TypeProtoText: return encoderCloser{ encode: func(v *dto.MetricFamily) error { - _, err := fmt.Fprintln(w, prototext.Format(v)) + _, err := fmt.Fprintln(w, prototext.Format(model.EscapeMetricFamily(v, escapingScheme))) return err }, close: func() error { return nil }, } - case FmtText: + case TypeTextPlain: return encoderCloser{ encode: func(v *dto.MetricFamily) error { - _, err := MetricFamilyToText(w, v) + _, err := MetricFamilyToText(w, model.EscapeMetricFamily(v, escapingScheme)) return err }, close: func() error { return nil }, } - case FmtOpenMetrics_0_0_1, FmtOpenMetrics_1_0_0: + case TypeOpenMetrics: return encoderCloser{ encode: func(v *dto.MetricFamily) error { - _, err := MetricFamilyToOpenMetrics(w, v) + _, err := MetricFamilyToOpenMetrics(w, model.EscapeMetricFamily(v, escapingScheme)) return err }, close: func() error { diff --git a/expfmt/encode_test.go b/expfmt/encode_test.go index 67614613..66893bc4 100644 --- a/expfmt/encode_test.go +++ b/expfmt/encode_test.go @@ -18,8 +18,11 @@ import ( "net/http" "testing" - dto "github.com/prometheus/client_model/go" "google.golang.org/protobuf/proto" + + "github.com/prometheus/common/model" + + dto "github.com/prometheus/client_model/go" ) func TestNegotiate(t *testing.T) { @@ -32,38 +35,70 @@ func TestNegotiate(t *testing.T) { { name: "delimited format", acceptHeaderValue: acceptValuePrefix + ";encoding=delimited", - expectedFmt: string(FmtProtoDelim), + expectedFmt: "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=underscores", }, { name: "text format", acceptHeaderValue: acceptValuePrefix + ";encoding=text", - expectedFmt: string(FmtProtoText), + expectedFmt: "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=text; escaping=underscores", }, { name: "compact text format", acceptHeaderValue: acceptValuePrefix + ";encoding=compact-text", - expectedFmt: string(FmtProtoCompact), + expectedFmt: "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=compact-text; escaping=underscores", }, { name: "plain text format", acceptHeaderValue: "text/plain;version=0.0.4", - expectedFmt: string(FmtText), + expectedFmt: "text/plain; version=0.0.4; charset=utf-8; escaping=underscores", + }, + { + name: "delimited format utf-8", + acceptHeaderValue: acceptValuePrefix + ";encoding=delimited; escaping=allow-utf-8;", + expectedFmt: "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=allow-utf-8", + }, + { + name: "text format utf-8", + acceptHeaderValue: acceptValuePrefix + ";encoding=text; escaping=allow-utf-8;", + expectedFmt: "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=text; escaping=allow-utf-8", + }, + { + name: "compact text format utf-8", + acceptHeaderValue: acceptValuePrefix + ";encoding=compact-text; escaping=allow-utf-8;", + expectedFmt: "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=compact-text; escaping=allow-utf-8", + }, + { + name: "plain text format 0.0.4 with utf-8 not valid, falls back", + acceptHeaderValue: "text/plain;version=0.0.4;", + expectedFmt: "text/plain; version=0.0.4; charset=utf-8; escaping=underscores", + }, + { + name: "plain text format 0.0.4 with utf-8 not valid, falls back", + acceptHeaderValue: "text/plain;version=0.0.4; escaping=values;", + expectedFmt: "text/plain; version=0.0.4; charset=utf-8; escaping=values", }, } - for _, test := range tests { + oldDefault := model.NameEscapingScheme + model.NameEscapingScheme = model.UnderscoreEscaping + 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(Negotiate(h)) if actualFmt != test.expectedFmt { - t.Errorf("expected Negotiate to return format %s, but got %s instead", test.expectedFmt, actualFmt) + t.Errorf("case %d: expected Negotiate to return format %s, but got %s instead", i, test.expectedFmt, actualFmt) } }) } } func TestNegotiateOpenMetrics(t *testing.T) { + acceptValuePrefix := "application/vnd.google.protobuf;proto=io.prometheus.client.MetricFamily" tests := []struct { name string acceptHeaderValue string @@ -72,32 +107,93 @@ func TestNegotiateOpenMetrics(t *testing.T) { { name: "OM format, no version", acceptHeaderValue: "application/openmetrics-text", - expectedFmt: string(FmtOpenMetrics_0_0_1), + expectedFmt: "application/openmetrics-text; version=0.0.1; charset=utf-8; escaping=values", }, { name: "OM format, 0.0.1 version", - acceptHeaderValue: "application/openmetrics-text;version=0.0.1", - expectedFmt: string(FmtOpenMetrics_0_0_1), + acceptHeaderValue: "application/openmetrics-text;version=0.0.1; escaping=underscores", + expectedFmt: "application/openmetrics-text; version=0.0.1; charset=utf-8; escaping=underscores", }, { name: "OM format, 1.0.0 version", acceptHeaderValue: "application/openmetrics-text;version=1.0.0", - expectedFmt: string(FmtOpenMetrics_1_0_0), + expectedFmt: "application/openmetrics-text; version=1.0.0; 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", + expectedFmt: "application/openmetrics-text; version=0.0.1; charset=utf-8; escaping=values", + }, + { + name: "OM format, 1.0.0 version with utf-8 is not valid, falls back", + acceptHeaderValue: "application/openmetrics-text;version=1.0.0; escaping=values;", + expectedFmt: "application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=values", }, { name: "OM format, invalid version", acceptHeaderValue: "application/openmetrics-text;version=0.0.4", - expectedFmt: string(FmtText), + expectedFmt: "text/plain; version=0.0.4; charset=utf-8; escaping=values", + }, + { + name: "compact text format", + acceptHeaderValue: acceptValuePrefix + ";encoding=compact-text; escaping=underscores", + expectedFmt: "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=compact-text; escaping=underscores", + }, + { + name: "plain text format", + acceptHeaderValue: "text/plain;version=0.0.4", + expectedFmt: "text/plain; version=0.0.4; charset=utf-8; escaping=values", + }, + { + name: "plain text format 0.0.4", + acceptHeaderValue: "text/plain;version=0.0.4; escaping=allow-utf-8", + expectedFmt: "text/plain; version=0.0.4; charset=utf-8; escaping=allow-utf-8", + }, + { + name: "delimited format utf-8", + acceptHeaderValue: acceptValuePrefix + ";encoding=delimited; escaping=allow-utf-8;", + expectedFmt: "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=allow-utf-8", + }, + { + name: "text format utf-8", + acceptHeaderValue: acceptValuePrefix + ";encoding=text; escaping=allow-utf-8;", + expectedFmt: "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=text; escaping=allow-utf-8", + }, + { + name: "compact text format utf-8", + acceptHeaderValue: acceptValuePrefix + ";encoding=compact-text; escaping=allow-utf-8;", + expectedFmt: "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=compact-text; escaping=allow-utf-8", + }, + { + name: "delimited format escaped", + acceptHeaderValue: acceptValuePrefix + ";encoding=delimited; escaping=underscores;", + expectedFmt: "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=underscores", + }, + { + name: "text format escaped", + acceptHeaderValue: acceptValuePrefix + ";encoding=text; escaping=underscores;", + expectedFmt: "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=text; escaping=underscores", + }, + { + name: "compact text format escaped", + acceptHeaderValue: acceptValuePrefix + ";encoding=compact-text; escaping=underscores;", + expectedFmt: "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=compact-text; escaping=underscores", }, } - for _, test := range tests { + 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(NegotiateIncludingOpenMetrics(h)) if actualFmt != test.expectedFmt { - t.Errorf("expected Negotiate to return format %s, but got %s instead", test.expectedFmt, actualFmt) + t.Errorf("case %d: expected Negotiate to return format %s, but got %s instead", i, test.expectedFmt, actualFmt) } }) } @@ -174,3 +270,88 @@ func TestEncode(t *testing.T) { t.Errorf("expected TextEncoder to return %s, but got %s instead", expected, string(out)) } } + +func TestEscapedEncode(t *testing.T) { + var buff bytes.Buffer + delimEncoder := NewEncoder(&buff, FmtProtoDelim+"; escaping=underscores") + metric := &dto.MetricFamily{ + Name: proto.String("foo.metric"), + Type: dto.MetricType_UNTYPED.Enum(), + Metric: []*dto.Metric{ + { + Untyped: &dto.Untyped{ + Value: proto.Float64(1.234), + }, + }, + { + Label: []*dto.LabelPair{ + { + Name: proto.String("dotted.label.name"), + Value: proto.String("my.label.value"), + }, + }, + Untyped: &dto.Untyped{ + Value: proto.Float64(8), + }, + }, + }, + } + + err := delimEncoder.Encode(metric) + if err != nil { + t.Errorf("unexpected error during encode: %s", err.Error()) + } + + out := buff.Bytes() + if len(out) == 0 { + t.Errorf("expected the output bytes buffer to be non-empty") + } + + buff.Reset() + + compactEncoder := NewEncoder(&buff, FmtProtoCompact) + err = compactEncoder.Encode(metric) + if err != nil { + t.Errorf("unexpected error during encode: %s", err.Error()) + } + + out = buff.Bytes() + if len(out) == 0 { + t.Errorf("expected the output bytes buffer to be non-empty") + } + + buff.Reset() + + protoTextEncoder := NewEncoder(&buff, FmtProtoText) + err = protoTextEncoder.Encode(metric) + if err != nil { + t.Errorf("unexpected error during encode: %s", err.Error()) + } + + out = buff.Bytes() + if len(out) == 0 { + t.Errorf("expected the output bytes buffer to be non-empty") + } + + buff.Reset() + + textEncoder := NewEncoder(&buff, FmtText) + err = textEncoder.Encode(metric) + if err != nil { + t.Errorf("unexpected error during encode: %s", err.Error()) + } + + out = buff.Bytes() + if len(out) == 0 { + t.Errorf("expected the output bytes buffer to be non-empty") + } + + expected := `# TYPE U__foo_2e_metric untyped +U__foo_2e_metric 1.234 +U__foo_2e_metric{U__dotted_2e_label_2e_name="my.label.value"} 8 +` + + if string(out) != expected { + t.Errorf("expected TextEncoder to return %s, but got %s instead", expected, string(out)) + } +} diff --git a/expfmt/expfmt.go b/expfmt/expfmt.go index d866b474..f9b6f70f 100644 --- a/expfmt/expfmt.go +++ b/expfmt/expfmt.go @@ -14,6 +14,12 @@ // Package expfmt contains tools for reading and writing Prometheus metrics. package expfmt +import ( + "strings" + + "github.com/prometheus/common/model" +) + // Format specifies the HTTP content type of the different wire protocols. type Format string @@ -33,7 +39,8 @@ const ( OpenMetricsVersion_0_0_1 = "0.0.1" OpenMetricsVersion_1_0_0 = "1.0.0" - // The Content-Type values for the different wire protocols. + // The Content-Type values for the different wire protocols. Do not do direct + // comparisons to these constants, instead use the comparison functions. FmtUnknown Format = `` FmtText Format = `text/plain; version=` + TextVersion + `; charset=utf-8` FmtProtoDelim Format = ProtoFmt + ` encoding=delimited` @@ -47,3 +54,93 @@ const ( hdrContentType = "Content-Type" hdrAccept = "Accept" ) + +// FormatType is a Go enum representing the overall category for the given +// Format. As the number of Format permutations increases, doing basic string +// comparisons are not feasible, so this enum captures the most useful +// high-level attribute of the Format string. +type FormatType int + +const ( + TypeUnknown = iota + TypeProtoCompact + TypeProtoDelim + TypeProtoText + TypeTextPlain + TypeOpenMetrics +) + +// FormatType deduces an overall FormatType for the given format. +func (f Format) FormatType() FormatType { + toks := strings.Split(string(f), ";") + if len(toks) < 2 { + return TypeUnknown + } + + params := make(map[string]string) + for i, t := range toks { + if i == 0 { + continue + } + args := strings.Split(t, "=") + if len(args) != 2 { + continue + } + params[strings.TrimSpace(args[0])] = strings.TrimSpace(args[1]) + } + + switch strings.TrimSpace(toks[0]) { + case ProtoType: + if params["proto"] != ProtoProtocol { + return TypeUnknown + } + switch params["encoding"] { + case "delimited": + return TypeProtoDelim + case "text": + return TypeProtoText + case "compact-text": + return TypeProtoCompact + default: + return TypeUnknown + } + case OpenMetricsType: + if params["charset"] != "utf-8" { + return TypeUnknown + } + return TypeOpenMetrics + case "text/plain": + v, ok := params["version"] + if !ok { + return TypeTextPlain + } + if v == TextVersion { + return TypeTextPlain + } + return TypeUnknown + default: + return TypeUnknown + } +} + +// 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 +// "escaping" term exists, that will be used. Otherwise, the global default will +// be returned. +func (format Format) ToEscapingScheme() model.EscapingScheme { + for _, p := range strings.Split(string(format), ";") { + toks := strings.Split(p, "=") + if len(toks) != 2 { + continue + } + key, value := strings.TrimSpace(toks[0]), strings.TrimSpace(toks[1]) + if key == model.EscapingKey { + scheme, err := model.ToEscapingScheme(value) + if err != nil { + return model.NameEscapingScheme + } + return scheme + } + } + return model.NameEscapingScheme +} diff --git a/expfmt/openmetrics_create_test.go b/expfmt/openmetrics_create_test.go index 7b201655..8601834a 100644 --- a/expfmt/openmetrics_create_test.go +++ b/expfmt/openmetrics_create_test.go @@ -24,6 +24,8 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" dto "github.com/prometheus/client_model/go" + + "github.com/prometheus/common/model" ) func TestCreateOpenMetrics(t *testing.T) { @@ -32,6 +34,12 @@ func TestCreateOpenMetrics(t *testing.T) { t.Error(err) } + oldDefaultScheme := model.NameEscapingScheme + model.NameEscapingScheme = model.NoEscaping + defer func() { + model.NameEscapingScheme = oldDefaultScheme + }() + scenarios := []struct { in *dto.MetricFamily out string @@ -199,7 +207,7 @@ gauge_name{name_1="val with\nnew line",name_2="val with \\backslash and \"quotes gauge_name{name_1="Björn",name_2="佖佥"} 3.14e+42 `, }, - // 4: Gauge, utf8, some escaping required, +Inf as value, multi-byte characters in label values. + // 4: Gauge, utf-8, some escaping required, +Inf as value, multi-byte characters in label values. { in: &dto.MetricFamily{ Name: proto.String("gauge.name\""), diff --git a/expfmt/text_create_test.go b/expfmt/text_create_test.go index 41bd408c..7cf04289 100644 --- a/expfmt/text_create_test.go +++ b/expfmt/text_create_test.go @@ -22,9 +22,17 @@ import ( "google.golang.org/protobuf/proto" dto "github.com/prometheus/client_model/go" + + "github.com/prometheus/common/model" ) func TestCreate(t *testing.T) { + oldDefaultScheme := model.NameEscapingScheme + model.NameEscapingScheme = model.NoEscaping + defer func() { + model.NameEscapingScheme = oldDefaultScheme + }() + scenarios := []struct { in *dto.MetricFamily out string @@ -120,7 +128,7 @@ gauge_name{name_1="val with\nnew line",name_2="val with \\backslash and \"quotes gauge_name{name_1="Björn",name_2="佖佥"} 3.14e+42 `, }, - // 2: Gauge, utf8, +Inf as value, multi-byte characters in label values. + // 2: Gauge, utf-8, +Inf as value, multi-byte characters in label values. { in: &dto.MetricFamily{ Name: proto.String("gauge.name"), diff --git a/go.mod b/go.mod index 06abd44e..95321f7a 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.20 require ( github.com/alecthomas/kingpin/v2 v2.4.0 github.com/go-kit/log v0.2.1 + github.com/google/go-cmp v0.6.0 github.com/julienschmidt/httprouter v1.3.0 github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f github.com/prometheus/client_golang v1.18.0 diff --git a/go.sum b/go.sum index 0a1ce92e..c38b5a5d 100644 --- a/go.sum +++ b/go.sum @@ -17,7 +17,8 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= diff --git a/model/labels.go b/model/labels.go index 73dafe43..3317ce22 100644 --- a/model/labels.go +++ b/model/labels.go @@ -172,7 +172,7 @@ func (l LabelNames) String() string { // A LabelValue is an associated value for a LabelName. type LabelValue string -// IsValid returns true iff the string is a valid UTF8. +// IsValid returns true iff the string is a valid UTF-8. func (lv LabelValue) IsValid() bool { return utf8.ValidString(string(lv)) } diff --git a/model/labels_test.go b/model/labels_test.go index 5ec94a40..23395432 100644 --- a/model/labels_test.go +++ b/model/labels_test.go @@ -153,7 +153,7 @@ func TestLabelNameIsValid(t *testing.T) { } NameValidationScheme = UTF8Validation if s.ln.IsValid() != s.utf8Valid { - t.Errorf("Expected %v for %q using UTF8 IsValid method", s.legacyValid, s.ln) + t.Errorf("Expected %v for %q using UTF-8 IsValid method", s.legacyValid, s.ln) } } } diff --git a/model/metric.go b/model/metric.go index 9aa0b511..0bd29b3a 100644 --- a/model/metric.go +++ b/model/metric.go @@ -19,6 +19,25 @@ import ( "sort" "strings" "unicode/utf8" + + dto "github.com/prometheus/client_model/go" + "google.golang.org/protobuf/proto" +) + +var ( + // NameValidationScheme determines the method of name validation to be used by + // all calls to IsValidMetricName() and LabelName IsValid(). Setting UTF-8 mode + // in isolation from other components that don't support UTF-8 may result in + // bugs or other undefined behavior. This value is intended to be set by + // UTF-8-aware binaries as part of their startup. To avoid need for locking, + // this value should be set once, ideally in an init(), before multiple + // goroutines are started. + NameValidationScheme = LegacyValidation + + // NameEscapingScheme defines the default way that names will be + // escaped when presented to systems that do not support UTF-8 names. If the + // Content-Type "escaping" term is specified, that will override this value. + NameEscapingScheme = ValueEncodingEscaping ) // ValidationScheme is a Go enum for determining how metric and label names will @@ -31,27 +50,52 @@ const ( // MetricNameRE and LabelNameRE. LegacyValidation ValidationScheme = iota - // UTF8Validation only requires that metric and label names be valid UTF8 + // UTF8Validation only requires that metric and label names be valid UTF-8 // strings. UTF8Validation ) -var ( - // NameValidationScheme determines the method of name validation to be used by - // all calls to IsValidMetricName() and LabelName IsValid(). Setting UTF8 mode - // in isolation from other components that don't support UTF8 may result in - // bugs or other undefined behavior. This value is intended to be set by - // UTF8-aware binaries as part of their startup. To avoid need for locking, - // this value should be set once, ideally in an init(), before multiple - // goroutines are started. - NameValidationScheme = LegacyValidation +type EscapingScheme int + +const ( + // NoEscaping indicates that a name will not be escaped. Unescaped names that + // do not conform to the legacy validity check will use a new exposition + // format syntax that will be officially standardized in future versions. + NoEscaping EscapingScheme = iota + + // UnderscoreEscaping replaces all legacy-invalid characters with underscores. + UnderscoreEscaping + + // DotsEscaping is similar to UnderscoreEscaping, except that dots are + // converted to `_dot_` and pre-existing underscores are converted to `__`. + DotsEscaping - // MetricNameRE is a regular expression matching valid metric - // names. Note that the IsValidMetricName function performs the same - // check but faster than a match with this regular expression. - MetricNameRE = regexp.MustCompile(`^[a-zA-Z_:][a-zA-Z0-9_:]*$`) + // ValueEncodingEscaping prepends the name with `U__` and replaces all invalid + // characters with the unicode value, surrounded by underscores. Single + // underscores are replaced with double underscores. + ValueEncodingEscaping ) +const ( + // EscapingKey is the key in an Accept or Content-Type header that defines how + // metric and label names that do not conform to the legacy character + // requirements should be escaped when being scraped by a legacy prometheus + // system. If a system does not explicitly pass an escaping parameter in the + // Accept header, the default NameEscapingScheme will be used. + EscapingKey = "escaping" + + // Possible values for Escaping Key: + AllowUTF8 = "allow-utf-8" // No escaping required. + EscapeUnderscores = "underscores" + EscapeDots = "dots" + EscapeValues = "values" +) + +// MetricNameRE is a regular expression matching valid metric +// names. Note that the IsValidMetricName function performs the same +// check but faster than a match with this regular expression. +var MetricNameRE = regexp.MustCompile(`^[a-zA-Z_:][a-zA-Z0-9_:]*$`) + // A Metric is similar to a LabelSet, but the key difference is that a Metric is // a singleton and refers to one and only one stream of samples. type Metric LabelSet @@ -137,9 +181,276 @@ func IsValidLegacyMetricName(n LabelValue) bool { return false } for i, b := range n { - if !((b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || b == '_' || b == ':' || (b >= '0' && b <= '9' && i > 0)) { + if !isValidLegacyRune(b, i) { return false } } return true } + +// EscapeMetricFamily escapes the given metric names and labels with the given +// escaping scheme. Returns a new object that uses the same pointers to fields +// when possible and creates new escaped versions so as not to mutate the +// input. +func EscapeMetricFamily(v *dto.MetricFamily, scheme EscapingScheme) *dto.MetricFamily { + if v == nil { + return nil + } + + if scheme == NoEscaping { + return v + } + + out := &dto.MetricFamily{ + Help: v.Help, + Type: v.Type, + } + + // If the name is nil, copy as-is, don't try to escape. + if v.Name == nil || IsValidLegacyMetricName(LabelValue(v.GetName())) { + out.Name = v.Name + } else { + out.Name = proto.String(EscapeName(v.GetName(), scheme)) + } + for _, m := range v.Metric { + if !metricNeedsEscaping(m) { + out.Metric = append(out.Metric, m) + continue + } + + escaped := &dto.Metric{ + Gauge: m.Gauge, + Counter: m.Counter, + Summary: m.Summary, + Untyped: m.Untyped, + Histogram: m.Histogram, + TimestampMs: m.TimestampMs, + } + + for _, l := range m.Label { + if l.GetName() == MetricNameLabel { + if l.Value == nil || IsValidLegacyMetricName(LabelValue(l.GetValue())) { + escaped.Label = append(escaped.Label, l) + continue + } + escaped.Label = append(escaped.Label, &dto.LabelPair{ + Name: proto.String(MetricNameLabel), + Value: proto.String(EscapeName(l.GetValue(), scheme)), + }) + continue + } + if l.Name == nil || IsValidLegacyMetricName(LabelValue(l.GetName())) { + escaped.Label = append(escaped.Label, l) + continue + } + escaped.Label = append(escaped.Label, &dto.LabelPair{ + Name: proto.String(EscapeName(l.GetName(), scheme)), + Value: l.Value, + }) + } + out.Metric = append(out.Metric, escaped) + } + return out +} + +func metricNeedsEscaping(m *dto.Metric) bool { + for _, l := range m.Label { + if l.GetName() == MetricNameLabel && !IsValidLegacyMetricName(LabelValue(l.GetValue())) { + return true + } + if !IsValidLegacyMetricName(LabelValue(l.GetName())) { + return true + } + } + return false +} + +const ( + lowerhex = "0123456789abcdef" +) + +// EscapeName escapes the incoming name according to the provided escaping +// scheme. Depending on the rules of escaping, this may cause no change in the +// string that is returned. (Especially NoEscaping, which by definition is a +// noop). This function does not do any validation of the name. +func EscapeName(name string, scheme EscapingScheme) string { + if len(name) == 0 { + return name + } + var escaped strings.Builder + switch scheme { + case NoEscaping: + return name + case UnderscoreEscaping: + if IsValidLegacyMetricName(LabelValue(name)) { + return name + } + for i, b := range name { + if isValidLegacyRune(b, i) { + escaped.WriteRune(b) + } else { + escaped.WriteRune('_') + } + } + return escaped.String() + case DotsEscaping: + // Do not early return for legacy valid names, we still escape underscores. + for i, b := range name { + if b == '_' { + escaped.WriteString("__") + } else if b == '.' { + escaped.WriteString("_dot_") + } else if isValidLegacyRune(b, i) { + escaped.WriteRune(b) + } else { + escaped.WriteRune('_') + } + } + return escaped.String() + case ValueEncodingEscaping: + if IsValidLegacyMetricName(LabelValue(name)) { + return name + } + escaped.WriteString("U__") + for i, b := range name { + if isValidLegacyRune(b, i) { + escaped.WriteRune(b) + } else if !utf8.ValidRune(b) { + escaped.WriteString("_FFFD_") + } else if b < 0x100 { + escaped.WriteRune('_') + for s := 4; s >= 0; s -= 4 { + escaped.WriteByte(lowerhex[b>>uint(s)&0xF]) + } + escaped.WriteRune('_') + } else if b < 0x10000 { + escaped.WriteRune('_') + for s := 12; s >= 0; s -= 4 { + escaped.WriteByte(lowerhex[b>>uint(s)&0xF]) + } + escaped.WriteRune('_') + } + } + return escaped.String() + default: + panic(fmt.Sprintf("invalid escaping scheme %d", scheme)) + } +} + +// lower function taken from strconv.atoi +func lower(c byte) byte { + return c | ('x' - 'X') +} + +// UnescapeName unescapes the incoming name according to the provided escaping +// scheme if possible. Some schemes are partially or totally non-roundtripable. +// If any error is enountered, returns the original input. +func UnescapeName(name string, scheme EscapingScheme) string { + if len(name) == 0 { + return name + } + switch scheme { + case NoEscaping: + return name + case UnderscoreEscaping: + // It is not possible to unescape from underscore replacement. + return name + case DotsEscaping: + name = strings.ReplaceAll(name, "_dot_", ".") + name = strings.ReplaceAll(name, "__", "_") + return name + case ValueEncodingEscaping: + escapedName, found := strings.CutPrefix(name, "U__") + if !found { + return name + } + + var unescaped strings.Builder + TOP: + for i := 0; i < len(escapedName); i++ { + // All non-underscores are treated normally. + if escapedName[i] != '_' { + unescaped.WriteByte(escapedName[i]) + continue + } + i++ + if i >= len(escapedName) { + return name + } + // A double underscore is a single underscore. + if escapedName[i] == '_' { + unescaped.WriteByte('_') + continue + } + // We think we are in a UTF-8 code, process it. + var utf8Val uint + for j := 0; i < len(escapedName); j++ { + // This is too many characters for a utf8 value. + if j > 4 { + return name + } + // Found a closing underscore, convert to a rune, check validity, and append. + if escapedName[i] == '_' { + utf8Rune := rune(utf8Val) + if !utf8.ValidRune(utf8Rune) { + return name + } + unescaped.WriteRune(utf8Rune) + continue TOP + } + r := lower(escapedName[i]) + utf8Val *= 16 + if r >= '0' && r <= '9' { + utf8Val += uint(r) - '0' + } else if r >= 'a' && r <= 'f' { + utf8Val += uint(r) - 'a' + 10 + } else { + return name + } + i++ + } + // Didn't find closing underscore, invalid. + return name + } + return unescaped.String() + default: + panic(fmt.Sprintf("invalid escaping scheme %d", scheme)) + } +} + +func isValidLegacyRune(b rune, i int) bool { + return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || b == '_' || b == ':' || (b >= '0' && b <= '9' && i > 0) +} + +func (e EscapingScheme) String() string { + switch e { + case NoEscaping: + return AllowUTF8 + case UnderscoreEscaping: + return EscapeUnderscores + case DotsEscaping: + return EscapeDots + case ValueEncodingEscaping: + return EscapeValues + default: + panic(fmt.Sprintf("unknown format scheme %d", e)) + } +} + +func ToEscapingScheme(s string) (EscapingScheme, error) { + if s == "" { + return NoEscaping, fmt.Errorf("got empty string instead of escaping scheme") + } + switch s { + case AllowUTF8: + return NoEscaping, nil + case EscapeUnderscores: + return UnderscoreEscaping, nil + case EscapeDots: + return DotsEscaping, nil + case EscapeValues: + return ValueEncodingEscaping, nil + default: + return NoEscaping, fmt.Errorf("unknown format scheme " + s) + } +} diff --git a/model/metric_test.go b/model/metric_test.go index 60f82930..b794c042 100644 --- a/model/metric_test.go +++ b/model/metric_test.go @@ -13,7 +13,14 @@ package model -import "testing" +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + dto "github.com/prometheus/client_model/go" + "google.golang.org/protobuf/proto" +) func testMetric(t testing.TB) { scenarios := []struct { @@ -150,7 +157,7 @@ func TestMetricNameIsLegacyValid(t *testing.T) { } NameValidationScheme = UTF8Validation if IsValidMetricName(s.mn) != s.utf8Valid { - t.Errorf("Expected %v for %q using utf8 IsValidMetricName method", s.legacyValid, s.mn) + t.Errorf("Expected %v for %q using utf-8 IsValidMetricName method", s.legacyValid, s.mn) } } } @@ -218,7 +225,423 @@ func TestMetricToString(t *testing.T) { t.Run(scenario.name, func(t *testing.T) { actual := scenario.input.String() if actual != scenario.expected { - t.Errorf("expected string output %s but got %s", actual, scenario.expected) + t.Errorf("expected string output %s but got %s", scenario.expected, actual) + } + }) + } +} + +func TestEscapeName(t *testing.T) { + scenarios := []struct { + name string + input string + expectedUnderscores string + expectedDots string + expectedUnescapedDots string + expectedValue string + }{ + { + name: "empty string", + }, + { + name: "legacy valid name", + input: "no:escaping_required", + expectedUnderscores: "no:escaping_required", + // Dots escaping will escape underscores even though it's not strictly + // necessary for compatibility. + expectedDots: "no:escaping__required", + expectedUnescapedDots: "no:escaping_required", + expectedValue: "no:escaping_required", + }, + { + name: "name with dots", + input: "mysystem.prod.west.cpu.load", + expectedUnderscores: "mysystem_prod_west_cpu_load", + expectedDots: "mysystem_dot_prod_dot_west_dot_cpu_dot_load", + expectedUnescapedDots: "mysystem.prod.west.cpu.load", + expectedValue: "U__mysystem_2e_prod_2e_west_2e_cpu_2e_load", + }, + { + name: "name with dots and colon", + input: "http.status:sum", + expectedUnderscores: "http_status:sum", + expectedDots: "http_dot_status:sum", + expectedUnescapedDots: "http.status:sum", + expectedValue: "U__http_2e_status:sum", + }, + { + name: "name with unicode characters > 0x100", + input: "花火", + expectedUnderscores: "__", + expectedDots: "__", + // Dots-replacement does not know the difference between two replaced + // characters and a single underscore. + expectedUnescapedDots: "_", + expectedValue: "U___82b1__706b_", + }, + } + + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + got := EscapeName(scenario.input, UnderscoreEscaping) + if got != scenario.expectedUnderscores { + t.Errorf("expected string output %s but got %s", scenario.expectedUnderscores, got) + } + // Unescaping with the underscore method is a noop. + got = UnescapeName(got, UnderscoreEscaping) + if got != scenario.expectedUnderscores { + t.Errorf("expected unescaped string output %s but got %s", scenario.expectedUnderscores, got) + } + + got = EscapeName(scenario.input, DotsEscaping) + if got != scenario.expectedDots { + t.Errorf("expected string output %s but got %s", scenario.expectedDots, got) + } + got = UnescapeName(got, DotsEscaping) + if got != scenario.expectedUnescapedDots { + t.Errorf("expected unescaped string output %s but got %s", scenario.expectedUnescapedDots, got) + } + + got = EscapeName(scenario.input, ValueEncodingEscaping) + if got != scenario.expectedValue { + t.Errorf("expected string output %s but got %s", scenario.expectedValue, got) + } + // Unescaped result should always be identical to the original input. + got = UnescapeName(got, ValueEncodingEscaping) + if got != scenario.input { + t.Errorf("expected unescaped string output %s but got %s", scenario.input, got) + } + }) + } +} + +func TestValueUnescapeErrors(t *testing.T) { + scenarios := []struct { + name string + input string + expected string + }{ + { + name: "empty string", + }, + { + name: "basic case, no error", + input: "U__no:unescapingrequired", + expected: "no:unescapingrequired", + }, + { + name: "capitals ok, no error", + input: "U__capitals_2E_ok", + expected: "capitals.ok", + }, + { + name: "underscores, no error", + input: "U__underscores__doubled__", + expected: "underscores_doubled_", + }, + { + name: "invalid single underscore", + input: "U__underscores_doubled_", + expected: "U__underscores_doubled_", + }, + { + name: "invalid single underscore, 2", + input: "U__underscores__doubled_", + expected: "U__underscores__doubled_", + }, + { + name: "giant fake utf-8 code", + input: "U__my__hack_2e_attempt_872348732fabdabbab_", + expected: "U__my__hack_2e_attempt_872348732fabdabbab_", + }, + { + name: "trailing utf-8", + input: "U__my__hack_2e", + expected: "U__my__hack_2e", + }, + { + name: "invalid utf-8 value", + input: "U__bad__utf_2eg_", + expected: "U__bad__utf_2eg_", + }, + { + name: "surrogate utf-8 value", + input: "U__bad__utf_D900_", + expected: "U__bad__utf_D900_", + }, + } + + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + got := UnescapeName(scenario.input, ValueEncodingEscaping) + if got != scenario.expected { + t.Errorf("expected unescaped string output %s but got %s", scenario.expected, got) + } + }) + } +} + +func TestEscapeMetricFamily(t *testing.T) { + scenarios := []struct { + name string + input *dto.MetricFamily + scheme EscapingScheme + expected *dto.MetricFamily + }{ + { + name: "empty", + input: &dto.MetricFamily{}, + scheme: ValueEncodingEscaping, + expected: &dto.MetricFamily{}, + }, + { + name: "simple, no escaping needed", + scheme: ValueEncodingEscaping, + input: &dto.MetricFamily{ + Name: proto.String("my_metric"), + Help: proto.String("some help text"), + Type: dto.MetricType_COUNTER.Enum(), + Metric: []*dto.Metric{ + { + Counter: &dto.Counter{ + Value: proto.Float64(34.2), + }, + Label: []*dto.LabelPair{ + { + Name: proto.String("__name__"), + Value: proto.String("my_metric"), + }, + { + Name: proto.String("some_label"), + Value: proto.String("labelvalue"), + }, + }, + }, + }, + }, + expected: &dto.MetricFamily{ + Name: proto.String("my_metric"), + Help: proto.String("some help text"), + Type: dto.MetricType_COUNTER.Enum(), + Metric: []*dto.Metric{ + { + Counter: &dto.Counter{ + Value: proto.Float64(34.2), + }, + Label: []*dto.LabelPair{ + { + Name: proto.String("__name__"), + Value: proto.String("my_metric"), + }, + { + Name: proto.String("some_label"), + Value: proto.String("labelvalue"), + }, + }, + }, + }, + }, + }, + { + name: "label name escaping needed", + scheme: ValueEncodingEscaping, + input: &dto.MetricFamily{ + Name: proto.String("my_metric"), + Help: proto.String("some help text"), + Type: dto.MetricType_COUNTER.Enum(), + Metric: []*dto.Metric{ + { + Counter: &dto.Counter{ + Value: proto.Float64(34.2), + }, + Label: []*dto.LabelPair{ + { + Name: proto.String("__name__"), + Value: proto.String("my_metric"), + }, + { + Name: proto.String("some.label"), + Value: proto.String("labelvalue"), + }, + }, + }, + }, + }, + expected: &dto.MetricFamily{ + Name: proto.String("my_metric"), + Help: proto.String("some help text"), + Type: dto.MetricType_COUNTER.Enum(), + Metric: []*dto.Metric{ + { + Counter: &dto.Counter{ + Value: proto.Float64(34.2), + }, + Label: []*dto.LabelPair{ + { + Name: proto.String("__name__"), + Value: proto.String("my_metric"), + }, + { + Name: proto.String("U__some_2e_label"), + Value: proto.String("labelvalue"), + }, + }, + }, + }, + }, + }, + { + name: "counter, escaping needed", + scheme: ValueEncodingEscaping, + input: &dto.MetricFamily{ + Name: proto.String("my.metric"), + Help: proto.String("some help text"), + Type: dto.MetricType_COUNTER.Enum(), + Metric: []*dto.Metric{ + { + Counter: &dto.Counter{ + Value: proto.Float64(34.2), + }, + Label: []*dto.LabelPair{ + { + Name: proto.String("__name__"), + Value: proto.String("my.metric"), + }, + { + Name: proto.String("some?label"), + Value: proto.String("label??value"), + }, + }, + }, + }, + }, + expected: &dto.MetricFamily{ + Name: proto.String("U__my_2e_metric"), + Help: proto.String("some help text"), + Type: dto.MetricType_COUNTER.Enum(), + Metric: []*dto.Metric{ + { + Counter: &dto.Counter{ + Value: proto.Float64(34.2), + }, + Label: []*dto.LabelPair{ + { + Name: proto.String("__name__"), + Value: proto.String("U__my_2e_metric"), + }, + { + Name: proto.String("U__some_3f_label"), + Value: proto.String("label??value"), + }, + }, + }, + }, + }, + }, + { + name: "gauge, escaping needed", + scheme: DotsEscaping, + input: &dto.MetricFamily{ + Name: proto.String("unicode.and.dots.花火"), + Help: proto.String("some help text"), + Type: dto.MetricType_GAUGE.Enum(), + Metric: []*dto.Metric{ + { + Gauge: &dto.Gauge{ + Value: proto.Float64(34.2), + }, + Label: []*dto.LabelPair{ + { + Name: proto.String("__name__"), + Value: proto.String("unicode.and.dots.花火"), + }, + { + Name: proto.String("some_label"), + Value: proto.String("label??value"), + }, + }, + }, + }, + }, + expected: &dto.MetricFamily{ + Name: proto.String("unicode_dot_and_dot_dots_dot___"), + Help: proto.String("some help text"), + Type: dto.MetricType_GAUGE.Enum(), + Metric: []*dto.Metric{ + { + Gauge: &dto.Gauge{ + Value: proto.Float64(34.2), + }, + Label: []*dto.LabelPair{ + { + Name: proto.String("__name__"), + Value: proto.String("unicode_dot_and_dot_dots_dot___"), + }, + { + Name: proto.String("some_label"), + Value: proto.String("label??value"), + }, + }, + }, + }, + }, + }, + } + + unexportList := []interface{}{dto.MetricFamily{}, dto.Metric{}, dto.LabelPair{}, dto.Counter{}, dto.Gauge{}} + + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + original := proto.Clone(scenario.input) + got := EscapeMetricFamily(scenario.input, scenario.scheme) + if !cmp.Equal(scenario.expected, got, cmpopts.IgnoreUnexported(unexportList...)) { + t.Errorf("unexpected difference in escaped output:" + cmp.Diff(scenario.expected, got, cmpopts.IgnoreUnexported(unexportList...))) + } + if !cmp.Equal(scenario.input, original, cmpopts.IgnoreUnexported(unexportList...)) { + t.Errorf("input was mutated during escaping" + cmp.Diff(scenario.expected, got, cmpopts.IgnoreUnexported(unexportList...))) + } + }) + } +} + +// TestProtoFormatUnchanged checks to see if the proto format changed, in which +// case EscapeMetricFamily will need to be updated. +func TestProtoFormatUnchanged(t *testing.T) { + scenarios := []struct { + name string + input proto.Message + expectFields []string + }{ + { + name: "MetricFamily", + input: &dto.MetricFamily{}, + expectFields: []string{"name", "help", "type", "metric"}, + }, + { + name: "Metric", + input: &dto.Metric{}, + expectFields: []string{"label", "gauge", "counter", "summary", "untyped", "histogram", "timestamp_ms"}, + }, + { + name: "LabelPair", + input: &dto.LabelPair{}, + expectFields: []string{"name", "value"}, + }, + } + + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + desc := scenario.input.ProtoReflect().Descriptor() + fields := desc.Fields() + if fields.Len() != len(scenario.expectFields) { + t.Errorf("dto.MetricFamily changed length, expected %d, got %d", len(scenario.expectFields), fields.Len()) + } + + for i := 0; i < fields.Len(); i++ { + got := fields.Get(i).TextName() + if got != scenario.expectFields[i] { + t.Errorf("dto.MetricFamily field mismatch, expected %s got %s", scenario.expectFields[i], got) + } } }) } diff --git a/model/silence_test.go b/model/silence_test.go index 0b5ad322..4e4508de 100644 --- a/model/silence_test.go +++ b/model/silence_test.go @@ -92,7 +92,7 @@ func TestMatcherValidate(t *testing.T) { t.Errorf("%d. Expected error for legacy validation %q but got none", i, c.legacyErr) } if c.utf8Err != "" { - t.Errorf("%d. Expected error for utf8 validation %q but got none", i, c.utf8Err) + t.Errorf("%d. Expected error for utf-8 validation %q but got none", i, c.utf8Err) } continue } @@ -105,7 +105,7 @@ func TestMatcherValidate(t *testing.T) { } if utf8Err != nil { if c.utf8Err == "" { - t.Errorf("%d. Expected no utf8 validation error but got %q", i, utf8Err) + t.Errorf("%d. Expected no utf-8 validation error but got %q", i, utf8Err) continue } if !strings.Contains(utf8Err.Error(), c.utf8Err) { diff --git a/sigv4/go.sum b/sigv4/go.sum index 6fe5c92f..974a22d3 100644 --- a/sigv4/go.sum +++ b/sigv4/go.sum @@ -13,7 +13,7 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=