diff --git a/expfmt/benchmark_test.go b/expfmt/benchmark_test.go index a6937880..f7ed1800 100644 --- a/expfmt/benchmark_test.go +++ b/expfmt/benchmark_test.go @@ -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() + } + }) } } diff --git a/expfmt/encode.go b/expfmt/encode.go index 73c24dfb..ba55fba4 100644 --- a/expfmt/encode.go +++ b/expfmt/encode.go @@ -17,6 +17,7 @@ import ( "fmt" "io" "net/http" + "strings" "github.com/munnerz/goautoneg" dto "github.com/prometheus/client_model/go" @@ -57,10 +58,29 @@ func (ec encoderCloser) Close() error { // Negotiate returns the Content-Type based on the given Accept header. If no // 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. +// Prometheus text format). +// +// Deprecated: Use NegotiateAccept(h, FmtProtoDelim, FmtProtoText, FmtProtoCompact, FmtText) +// or specify only the formats supported by your server. func Negotiate(h http.Header) Format { + return NegotiateAccept(h, FmtProtoDelim, FmtProtoText, FmtProtoCompact, FmtText) +} + +// NegotiateIncludingOpenMetrics works like Negotiate but includes +// FmtOpenMetrics as an option for the result. +// +// Deprecated: Use NegotiateAccept(h, FmtOpenMetrics_1_0_0, FmtOpenMetrics_0_0_1, FmtProtoDelim, FmtProtoText, FmtProtoCompact, FmtText) +// 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) +} + +// NegotiateAccept returns the Content-Type based on the given Accept header +// and the list of accepted Formats provided by the caller, in order of preference. +// If no accepted format matches the Accept header, it falls back to the text +// format if present in the accepted list, or the first accepted format (or FmtText +// if accepted is empty). +func NegotiateAccept(h http.Header, accepted ...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 != "" { @@ -71,63 +91,68 @@ func Negotiate(h http.Header) Format { // 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 + + for _, f := range accepted { + if matchFormat(ac, f) { + return f + escapingScheme } } - if ac.Type == "text" && ac.SubType == "plain" && (ver == TextVersion || ver == "") { - return FmtText + escapingScheme + } + for _, f := range accepted { + if f.FormatType() == TypeTextPlain { + return f + escapingScheme } } + if len(accepted) > 0 { + return accepted[0] + escapingScheme + } return FmtText + escapingScheme } -// 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)) { - 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 - } +// 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 != "*" && ac.Type != target.Type { + return false + } + if ac.SubType != "*" && ac.SubType != target.SubType { + return false + } + + // If ac is */*, wildcard matches any target. + if ac.Type == "*" && ac.SubType == "*" { + return true + } + + // Default OpenMetrics version to OpenMetricsVersion_0_0_1. + acVersion := ac.Params["version"] + if acVersion == "" && ac.Type+"/"+ac.SubType == OpenMetricsType { + acVersion = OpenMetricsVersion_0_0_1 + } + if acVersion == "" && ac.Type == "text" && ac.SubType == "plain" { + acVersion = TextVersion + } + + // General param matching. + for k, v := range target.Params { + if k == "charset" { + continue } - if ac.Type == "text" && ac.SubType == "plain" && (ver == TextVersion || ver == "") { - return FmtText + escapingScheme + acVal := ac.Params[k] + if k == "version" { + acVal = acVersion } - 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 - } + if acVal != v { + return false } } - return FmtText + escapingScheme + + return true } // NewEncoder returns a new encoder based on content type negotiation. All @@ -181,6 +206,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...) diff --git a/expfmt/encode_test.go b/expfmt/encode_test.go index 04e94c71..c5c30cd4 100644 --- a/expfmt/encode_test.go +++ b/expfmt/encode_test.go @@ -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", @@ -200,6 +205,81 @@ func TestNegotiateIncludingOpenMetrics(t *testing.T) { } } +func TestNegotiateAccept(t *testing.T) { + tests := []struct { + name string + acceptHeaderValue string + acceptedFormats []Format + expectedFmt string + }{ + { + name: "requested OM 2.0, accepted OM 2.0", + acceptHeaderValue: "application/openmetrics-text;version=2.0.0", + acceptedFormats: []Format{fmtOpenMetrics_2_0_0, FmtText}, + expectedFmt: "application/openmetrics-text; version=2.0.0; charset=utf-8; escaping=values", + }, + { + name: "requested OM 2.0, not accepted, falls back to text", + acceptHeaderValue: "application/openmetrics-text;version=2.0.0", + acceptedFormats: []Format{FmtOpenMetrics_1_0_0, FmtText}, + expectedFmt: "text/plain; version=0.0.4; charset=utf-8; escaping=values", + }, + { + name: "requested OM 2.0, not accepted, falls back to first format when no text in accepted", + acceptHeaderValue: "application/openmetrics-text;version=2.0.0", + acceptedFormats: []Format{FmtProtoDelim}, + expectedFmt: "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=values", + }, + { + 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}, + expectedFmt: "application/openmetrics-text; version=2.0.0; charset=utf-8; escaping=values", + }, + { + name: "wildcard */* matches first accepted format", + acceptHeaderValue: "*/*", + acceptedFormats: []Format{fmtOpenMetrics_2_0_0, FmtProtoDelim, FmtText}, + expectedFmt: "application/openmetrics-text; version=2.0.0; charset=utf-8; escaping=values", + }, + { + name: "wildcard */* with text first in accepted", + acceptHeaderValue: "*/*", + acceptedFormats: []Format{FmtText, FmtProtoDelim}, + expectedFmt: "text/plain; version=0.0.4; charset=utf-8; escaping=values", + }, + { + name: "unversioned text/plain matches FmtText", + acceptHeaderValue: "text/plain", + acceptedFormats: []Format{FmtProtoDelim, FmtText}, + expectedFmt: "text/plain; version=0.0.4; charset=utf-8; escaping=values", + }, + { + name: "empty accepted list defaults to FmtText", + acceptHeaderValue: "application/unknown", + acceptedFormats: nil, + 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(NegotiateAccept(h, test.acceptedFormats...)) + if actualFmt != test.expectedFmt { + t.Errorf("case %d: expected NegotiateAccept 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"), @@ -268,6 +348,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 `, }, } diff --git a/expfmt/expfmt.go b/expfmt/expfmt.go index 10bf3570..a9092e6f 100644 --- a/expfmt/expfmt.go +++ b/expfmt/expfmt.go @@ -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. @@ -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` @@ -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") } diff --git a/expfmt/openmetrics_2_0_create.go b/expfmt/openmetrics_2_0_create.go new file mode 100644 index 00000000..9f35aced --- /dev/null +++ b/expfmt/openmetrics_2_0_create.go @@ -0,0 +1,424 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package expfmt + +import ( + "bufio" + "errors" + "fmt" + "io" + "math" + "strconv" + "strings" + + dto "github.com/prometheus/client_model/go" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// MetricFamilyToOpenMetrics20 converts a MetricFamily proto message into the +// OpenMetrics text format version 2.0.0 and writes the resulting lines to 'out'. +// It returns the number of bytes written and any error encountered. +// +// NOTE: This method implements OpenMetrics 2.0-rc.0 which is experimental. +// Breaking changes might happen in the future. This implementation is still a +// work-in-progress, and does not yet support all features of the format. +func MetricFamilyToOpenMetrics20(out io.Writer, in *dto.MetricFamily, options ...EncoderOption) (written int, err error) { + _ = options + name := in.GetName() + if name == "" { + return 0, fmt.Errorf("MetricFamily has no name: %s", in) + } + if containsRawNewline(name) { + return 0, fmt.Errorf("MetricFamily name %q contains raw newlines", name) + } + + // Try the interface upgrade. If it doesn't work, we'll use a + // bufio.Writer from the sync.Pool. + w, ok := out.(enhancedWriter) + if !ok { + b := bufPool.Get().(*bufio.Writer) + b.Reset(out) + w = b + defer func() { + bErr := b.Flush() + if err == nil { + err = bErr + } + bufPool.Put(b) + }() + } + + var ( + n int + metricType = in.GetType() + ) + + // Comments, first HELP, then TYPE. + if in.Help != nil { + n, err = w.WriteString("# HELP ") + written += n + if err != nil { + return written, err + } + n, err = writeName(w, name) + written += n + if err != nil { + return written, err + } + err = w.WriteByte(' ') + written++ + if err != nil { + return written, err + } + n, err = writeEscapedString(w, *in.Help, true) + written += n + if err != nil { + return written, err + } + err = w.WriteByte('\n') + written++ + if err != nil { + return written, err + } + } + n, err = w.WriteString("# TYPE ") + written += n + if err != nil { + return written, err + } + n, err = writeName(w, name) + written += n + if err != nil { + return written, err + } + switch metricType { + case dto.MetricType_COUNTER: + n, err = w.WriteString(" counter\n") + case dto.MetricType_GAUGE: + n, err = w.WriteString(" gauge\n") + case dto.MetricType_SUMMARY: + n, err = w.WriteString(" summary\n") + case dto.MetricType_UNTYPED: + n, err = w.WriteString(" unknown\n") + case dto.MetricType_HISTOGRAM: + n, err = w.WriteString(" histogram\n") + case dto.MetricType_GAUGE_HISTOGRAM: + n, err = w.WriteString(" gaugehistogram\n") + default: + // TODO: Support Info and StateSet once they are supported in the + // Prometheus protobuf format. + return written, fmt.Errorf("unknown metric type %s", metricType.String()) + } + written += n + if err != nil { + return written, err + } + if in.Unit != nil { + n, err = w.WriteString("# UNIT ") + written += n + if err != nil { + return written, err + } + n, err = writeName(w, name) + written += n + if err != nil { + return written, err + } + + err = w.WriteByte(' ') + written++ + if err != nil { + return written, err + } + n, err = writeEscapedString(w, *in.Unit, true) + written += n + if err != nil { + return written, err + } + err = w.WriteByte('\n') + written++ + if err != nil { + return written, err + } + } + + // Finally the samples, one line for each. + for _, metric := range in.Metric { + if metric == nil { + return written, fmt.Errorf("expected non-nil metric in MetricFamily %s", name) + } + switch metricType { + case dto.MetricType_COUNTER: + if metric.Counter == nil { + return written, fmt.Errorf("expected counter in metric %s %s", name, metric) + } + val := metric.Counter.GetValue() + if math.IsNaN(val) { + return written, fmt.Errorf("counter value cannot be NaN in metric %s", name) + } + if val < 0 { + return written, fmt.Errorf("counter value cannot be negative (%g) in metric %s", val, name) + } + n, err = writeOpenMetrics20Sample(w, name, metric, val, 0, false, metric.Counter.Exemplar) + case dto.MetricType_GAUGE: + if metric.Gauge == nil { + return written, fmt.Errorf("expected gauge in metric %s %s", name, metric) + } + n, err = writeOpenMetrics20Sample(w, name, metric, metric.Gauge.GetValue(), 0, false, nil) + case dto.MetricType_UNTYPED: + if metric.Untyped == nil { + return written, fmt.Errorf("expected untyped in metric %s %s", name, metric) + } + n, err = writeOpenMetrics20Sample(w, name, metric, metric.Untyped.GetValue(), 0, false, nil) + case dto.MetricType_SUMMARY: + if metric.Summary == nil { + return written, fmt.Errorf("expected summary in metric %s %s", name, metric) + } + n, err = writeCompositeSummary(w, name, metric) + case dto.MetricType_HISTOGRAM, dto.MetricType_GAUGE_HISTOGRAM: + if metric.Histogram == nil { + return written, fmt.Errorf("expected histogram in metric %s %s", name, metric) + } + n, err = writeCompositeHistogram(w, name, metric, metricType == dto.MetricType_GAUGE_HISTOGRAM) + default: + return written, fmt.Errorf("unexpected type in metric %s %s", name, metric) + } + written += n + if err != nil { + return written, err + } + } + return written, nil +} + +// writeOpenMetrics20Sample writes a single sample for simple types (Counter, Gauge, Untyped). +func writeOpenMetrics20Sample(w enhancedWriter, name string, metric *dto.Metric, floatValue float64, intValue uint64, useIntValue bool, exemplar *dto.Exemplar) (int, error) { + if err := validateLabels20(metric.Label); err != nil { + return 0, err + } + written := 0 + n, err := writeOpenMetricsNameAndLabelPairs(w, name, metric.Label, "", 0) + written += n + if err != nil { + return written, err + } + err = w.WriteByte(' ') + written++ + if err != nil { + return written, err + } + + if useIntValue { + n, err = writeUint(w, intValue) + } else { + n, err = writeFloat(w, floatValue) + } + written += n + if err != nil { + return written, err + } + + if metric.TimestampMs != nil { + err = w.WriteByte(' ') + written++ + if err != nil { + return written, err + } + n, err = writeOpenMetrics20Timestamp(w, float64(*metric.TimestampMs)/1000) + written += n + if err != nil { + return written, err + } + } + + // Start Timestamp for Counter + if metric.Counter != nil && metric.Counter.CreatedTimestamp != nil { + ts := metric.Counter.CreatedTimestamp + if err := ts.CheckValid(); err != nil { + return written, fmt.Errorf("invalid created timestamp in metric %s: %w", name, err) + } + n, err = w.WriteString(" st@") + written += n + if err != nil { + return written, err + } + n, err = writeProtoTimestamp(w, ts) + written += n + if err != nil { + return written, err + } + } + + if exemplar != nil && len(exemplar.Label) > 0 && exemplar.Timestamp != nil { + n, err = writeExemplar20(w, exemplar) + written += n + if err != nil { + return written, err + } + } + + err = w.WriteByte('\n') + written++ + if err != nil { + return written, err + } + return written, nil +} + +// writeExemplar20 writes the provided exemplar in OpenMetrics 2.0 format to w. +// In OpenMetrics 2.0, exemplars without a timestamp are dropped. +func writeExemplar20(w enhancedWriter, e *dto.Exemplar) (int, error) { + if e == nil || len(e.Label) == 0 || e.Timestamp == nil { + return 0, nil + } + if err := validateExemplar20(e); err != nil { + return 0, err + } + written := 0 + n, err := w.WriteString(" # ") + written += n + if err != nil { + return written, err + } + n, err = writeOpenMetricsNameAndLabelPairs(w, "", e.Label, "", 0) + written += n + if err != nil { + return written, err + } + err = w.WriteByte(' ') + written++ + if err != nil { + return written, err + } + n, err = writeFloat(w, e.GetValue()) + written += n + if err != nil { + return written, err + } + err = w.WriteByte(' ') + written++ + if err != nil { + return written, err + } + err = e.Timestamp.CheckValid() + if err != nil { + return written, err + } + ts := e.Timestamp + n, err = writeProtoTimestamp(w, ts) + written += n + if err != nil { + return written, err + } + return written, nil +} + +// writeOpenMetrics20Timestamp writes a float64 as a timestamp without scientific notation. +func writeOpenMetrics20Timestamp(w enhancedWriter, f float64) (int, error) { + switch { + case math.IsNaN(f): + return w.WriteString("NaN") + case math.IsInf(f, +1): + return w.WriteString("+Inf") + case math.IsInf(f, -1): + return w.WriteString("-Inf") + default: + bp := numBufPool.Get().(*[]byte) + *bp = strconv.AppendFloat((*bp)[:0], f, 'f', -1, 64) + written, err := w.Write(*bp) + numBufPool.Put(bp) + return written, err + } +} + +// Stubs for Summary and Histogram + +func writeCompositeSummary(w enhancedWriter, name string, metric *dto.Metric) (int, error) { + _ = w + _ = name + _ = metric + return 0, errors.New("summary not implemented yet") +} + +func writeCompositeHistogram(w enhancedWriter, name string, metric *dto.Metric, isGauge bool) (int, error) { + _ = w + _ = name + _ = metric + _ = isGauge + return 0, errors.New("histogram not implemented yet") +} + +func validateLabels20(labels []*dto.LabelPair) error { + for _, lp := range labels { + if lp == nil { + return errors.New("expected non-nil label pair") + } + lname := lp.GetName() + if lname == "" { + return errors.New("label name cannot be empty") + } + if containsRawNewline(lname) { + return fmt.Errorf("label name %q contains raw newlines", lname) + } + } + return nil +} + +func containsRawNewline(s string) bool { + return strings.IndexByte(s, '\n') >= 0 || strings.IndexByte(s, '\r') >= 0 +} + +func validateExemplar20(e *dto.Exemplar) error { + if err := e.Timestamp.CheckValid(); err != nil { + return err + } + return validateLabels20(e.Label) +} + +func writeProtoTimestamp(w enhancedWriter, ts *timestamppb.Timestamp) (int, error) { + if err := ts.CheckValid(); err != nil { + return 0, err + } + n, err := writeInt(w, ts.Seconds) + if err != nil { + return n, err + } + if ts.Nanos == 0 { + return n, nil + } + err = w.WriteByte('.') + n++ + if err != nil { + return n, err + } + bp := numBufPool.Get().(*[]byte) + *bp = strconv.AppendInt((*bp)[:0], int64(ts.Nanos), 10) + pad := 9 - len(*bp) + for range pad { + err = w.WriteByte('0') + n++ + if err != nil { + numBufPool.Put(bp) + return n, err + } + } + val := *bp + for len(val) > 0 && val[len(val)-1] == '0' { + val = val[:len(val)-1] + } + n2, err := w.Write(val) + n += n2 + numBufPool.Put(bp) + return n, err +} diff --git a/expfmt/openmetrics_2_0_create_test.go b/expfmt/openmetrics_2_0_create_test.go new file mode 100644 index 00000000..6de60358 --- /dev/null +++ b/expfmt/openmetrics_2_0_create_test.go @@ -0,0 +1,604 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package expfmt + +import ( + "bytes" + "io" + "math" + "strings" + "testing" + + dto "github.com/prometheus/client_model/go" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func TestCreateOpenMetrics20(t *testing.T) { + scenarios := []struct { + name string + in *dto.MetricFamily + out string + }{ + { + name: "Counter", + in: &dto.MetricFamily{ + Name: proto.String("http_requests_total"), + Help: proto.String("Total number of HTTP requests."), + Type: dto.MetricType_COUNTER.Enum(), + Metric: []*dto.Metric{ + { + Label: []*dto.LabelPair{ + {Name: proto.String("method"), Value: proto.String("GET")}, + {Name: proto.String("code"), Value: proto.String("200")}, + }, + Counter: &dto.Counter{ + Value: proto.Float64(1027), + CreatedTimestamp: ×tamppb.Timestamp{Seconds: 1234567890}, + }, + }, + }, + }, + out: `# HELP http_requests_total Total number of HTTP requests. +# TYPE http_requests_total counter +http_requests_total{method="GET",code="200"} 1027 st@1234567890 +`, + }, + { + name: "CounterWithSubsecondCreatedTimestamp", + in: &dto.MetricFamily{ + Name: proto.String("http_requests_total"), + Help: proto.String("Total number of HTTP requests."), + Type: dto.MetricType_COUNTER.Enum(), + Metric: []*dto.Metric{ + { + Label: []*dto.LabelPair{ + {Name: proto.String("method"), Value: proto.String("GET")}, + {Name: proto.String("code"), Value: proto.String("200")}, + }, + Counter: &dto.Counter{ + Value: proto.Float64(1027), + CreatedTimestamp: ×tamppb.Timestamp{Seconds: 1234567890, Nanos: 987654321}, + }, + }, + }, + }, + out: `# HELP http_requests_total Total number of HTTP requests. +# TYPE http_requests_total counter +http_requests_total{method="GET",code="200"} 1027 st@1234567890.987654321 +`, + }, + { + name: "Gauge", + in: &dto.MetricFamily{ + Name: proto.String("node_memory_active_bytes"), + Help: proto.String("Active memory in bytes."), + Type: dto.MetricType_GAUGE.Enum(), + Metric: []*dto.Metric{ + { + Gauge: &dto.Gauge{ + Value: proto.Float64(1.2345e+09), + }, + }, + }, + }, + out: `# HELP node_memory_active_bytes Active memory in bytes. +# TYPE node_memory_active_bytes gauge +node_memory_active_bytes 1.2345e+09 +`, + }, + { + name: "GaugeWithUnit", + in: &dto.MetricFamily{ + Name: proto.String("node_memory_active_bytes"), + Help: proto.String("Active memory in bytes."), + Type: dto.MetricType_GAUGE.Enum(), + Unit: proto.String("bytes"), + Metric: []*dto.Metric{ + { + Gauge: &dto.Gauge{ + Value: proto.Float64(1.2345e+09), + }, + }, + }, + }, + out: `# HELP node_memory_active_bytes Active memory in bytes. +# TYPE node_memory_active_bytes gauge +# UNIT node_memory_active_bytes bytes +node_memory_active_bytes 1.2345e+09 +`, + }, + { + name: "GaugeWithTimestamp", + in: &dto.MetricFamily{ + Name: proto.String("node_memory_active_bytes"), + Type: dto.MetricType_GAUGE.Enum(), + Metric: []*dto.Metric{ + { + Gauge: &dto.Gauge{ + Value: proto.Float64(1.2345e+09), + }, + TimestampMs: proto.Int64(1234567890000), + }, + }, + }, + out: `# TYPE node_memory_active_bytes gauge +node_memory_active_bytes 1.2345e+09 1234567890 +`, + }, + { + name: "CounterWithExemplar", + in: &dto.MetricFamily{ + Name: proto.String("http_requests_total"), + Type: dto.MetricType_COUNTER.Enum(), + Metric: []*dto.Metric{ + { + Counter: &dto.Counter{ + Value: proto.Float64(1027), + CreatedTimestamp: ×tamppb.Timestamp{Seconds: 1234567890}, + Exemplar: &dto.Exemplar{ + Label: []*dto.LabelPair{ + {Name: proto.String("trace_id"), Value: proto.String("1234")}, + }, + Value: proto.Float64(1), + Timestamp: ×tamppb.Timestamp{Seconds: 1234567890, Nanos: 500000000}, + }, + }, + TimestampMs: proto.Int64(1234567891000), + }, + }, + }, + out: `# TYPE http_requests_total counter +http_requests_total 1027 1234567891 st@1234567890 # {trace_id="1234"} 1 1234567890.5 +`, + }, + { + name: "CounterWithExemplarWithoutTimestamp", + in: &dto.MetricFamily{ + Name: proto.String("http_requests_total"), + Type: dto.MetricType_COUNTER.Enum(), + Metric: []*dto.Metric{ + { + Counter: &dto.Counter{ + Value: proto.Float64(1027), + Exemplar: &dto.Exemplar{ + Label: []*dto.LabelPair{ + {Name: proto.String("trace_id"), Value: proto.String("1234")}, + }, + Value: proto.Float64(1), + }, + }, + }, + }, + }, + out: `# TYPE http_requests_total counter +http_requests_total 1027 +`, + }, + { + name: "CounterWithNaNExemplar", + in: &dto.MetricFamily{ + Name: proto.String("http_requests_total"), + Type: dto.MetricType_COUNTER.Enum(), + Metric: []*dto.Metric{ + { + Counter: &dto.Counter{ + Value: proto.Float64(1027), + Exemplar: &dto.Exemplar{ + Label: []*dto.LabelPair{ + {Name: proto.String("trace_id"), Value: proto.String("1234")}, + }, + Value: proto.Float64(math.NaN()), + Timestamp: ×tamppb.Timestamp{Seconds: 1234567890}, + }, + }, + }, + }, + }, + out: `# TYPE http_requests_total counter +http_requests_total 1027 # {trace_id="1234"} NaN 1234567890 +`, + }, + { + name: "Untyped", + in: &dto.MetricFamily{ + Name: proto.String("test_metric"), + Type: dto.MetricType_UNTYPED.Enum(), + Metric: []*dto.Metric{ + { + Untyped: &dto.Untyped{ + Value: proto.Float64(1.23), + }, + }, + }, + }, + out: `# TYPE test_metric unknown +test_metric 1.23 +`, + }, + { + name: "CounterWithoutCreatedTimestamp", + in: &dto.MetricFamily{ + Name: proto.String("http_requests_total"), + Type: dto.MetricType_COUNTER.Enum(), + Metric: []*dto.Metric{ + { + Counter: &dto.Counter{ + Value: proto.Float64(1027), + }, + }, + }, + }, + out: `# TYPE http_requests_total counter +http_requests_total 1027 +`, + }, + { + name: "UTF8Support", + in: &dto.MetricFamily{ + Name: proto.String("你好_total"), + Type: dto.MetricType_COUNTER.Enum(), + Metric: []*dto.Metric{ + { + Label: []*dto.LabelPair{ + {Name: proto.String("🌎"), Value: proto.String("🌍")}, + }, + Counter: &dto.Counter{ + Value: proto.Float64(1027), + }, + }, + }, + }, + out: `# TYPE "你好_total" counter +{"你好_total","🌎"="🌍"} 1027 +`, + }, + } + + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + var buf bytes.Buffer + n, err := MetricFamilyToOpenMetrics20(&buf, scenario.in) + if err != nil { + t.Fatal(err) + } + if buf.String() != scenario.out { + t.Errorf("expected out:\n%s\ngot:\n%s", scenario.out, buf.String()) + } + if n != len(scenario.out) { + t.Errorf("expected %d bytes written, got %d", len(scenario.out), n) + } + }) + } +} + +func TestWriteOpenMetrics20Timestamp_SpecialValues(t *testing.T) { + tests := []struct { + name string + val float64 + out string + }{ + {"NaN", math.NaN(), "NaN"}, + {"+Inf", math.Inf(+1), "+Inf"}, + {"-Inf", math.Inf(-1), "-Inf"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var buf bytes.Buffer + w := enhancedWriter(&buf) + n, err := writeOpenMetrics20Timestamp(w, tc.val) + if err != nil { + t.Fatal(err) + } + if buf.String() != tc.out { + t.Errorf("expected %q, got %q", tc.out, buf.String()) + } + if n != len(tc.out) { + t.Errorf("expected %d bytes written, got %d", len(tc.out), n) + } + }) + } +} + +func TestCreateOpenMetrics20_Errors(t *testing.T) { + tests := []struct { + name string + in *dto.MetricFamily + expectedErr string + }{ + { + name: "NoName", + in: &dto.MetricFamily{ + Type: dto.MetricType_COUNTER.Enum(), + }, + expectedErr: "MetricFamily has no name", + }, + { + name: "UnknownType", + in: &dto.MetricFamily{ + Name: proto.String("test_metric"), + Type: dto.MetricType(100).Enum(), + }, + expectedErr: "unknown metric type", + }, + { + name: "MissingCounter", + in: &dto.MetricFamily{ + Name: proto.String("test_metric"), + Type: dto.MetricType_COUNTER.Enum(), + Metric: []*dto.Metric{ + {}, + }, + }, + expectedErr: "expected counter in metric", + }, + { + name: "MissingGauge", + in: &dto.MetricFamily{ + Name: proto.String("test_metric"), + Type: dto.MetricType_GAUGE.Enum(), + Metric: []*dto.Metric{ + {}, + }, + }, + expectedErr: "expected gauge in metric", + }, + { + name: "MissingUntyped", + in: &dto.MetricFamily{ + Name: proto.String("test_metric"), + Type: dto.MetricType_UNTYPED.Enum(), + Metric: []*dto.Metric{ + {}, + }, + }, + expectedErr: "expected untyped in metric", + }, + { + name: "MissingSummary", + in: &dto.MetricFamily{ + Name: proto.String("test_metric"), + Type: dto.MetricType_SUMMARY.Enum(), + Metric: []*dto.Metric{ + {}, + }, + }, + expectedErr: "expected summary in metric", + }, + { + name: "MissingHistogram", + in: &dto.MetricFamily{ + Name: proto.String("test_metric"), + Type: dto.MetricType_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + {}, + }, + }, + expectedErr: "expected histogram in metric", + }, + { + name: "SummaryNotImplemented", + in: &dto.MetricFamily{ + Name: proto.String("test_metric"), + Type: dto.MetricType_SUMMARY.Enum(), + Metric: []*dto.Metric{ + {Summary: &dto.Summary{}}, + }, + }, + expectedErr: "summary not implemented yet", + }, + { + name: "HistogramNotImplemented", + in: &dto.MetricFamily{ + Name: proto.String("test_metric"), + Type: dto.MetricType_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + {Histogram: &dto.Histogram{}}, + }, + }, + expectedErr: "histogram not implemented yet", + }, + { + name: "GaugeHistogramNotImplemented", + in: &dto.MetricFamily{ + Name: proto.String("test_metric"), + Type: dto.MetricType_GAUGE_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + {Histogram: &dto.Histogram{}}, + }, + }, + expectedErr: "histogram not implemented yet", + }, + { + name: "CounterValueNaN", + in: &dto.MetricFamily{ + Name: proto.String("test_counter_total"), + Type: dto.MetricType_COUNTER.Enum(), + Metric: []*dto.Metric{ + {Counter: &dto.Counter{Value: proto.Float64(math.NaN())}}, + }, + }, + expectedErr: "counter value cannot be NaN", + }, + { + name: "CounterValueNegative", + in: &dto.MetricFamily{ + Name: proto.String("test_counter_total"), + Type: dto.MetricType_COUNTER.Enum(), + Metric: []*dto.Metric{ + {Counter: &dto.Counter{Value: proto.Float64(-5.0)}}, + }, + }, + expectedErr: "counter value cannot be negative", + }, + { + name: "EmptyLabelName", + in: &dto.MetricFamily{ + Name: proto.String("test_counter_total"), + Type: dto.MetricType_COUNTER.Enum(), + Metric: []*dto.Metric{ + { + Label: []*dto.LabelPair{ + {Name: proto.String(""), Value: proto.String("bar")}, + }, + Counter: &dto.Counter{Value: proto.Float64(1.0)}, + }, + }, + }, + expectedErr: "label name cannot be empty", + }, + { + name: "NewlineInMetricName", + in: &dto.MetricFamily{ + Name: proto.String("test_counter\ntotal"), + Type: dto.MetricType_COUNTER.Enum(), + Metric: []*dto.Metric{ + {Counter: &dto.Counter{Value: proto.Float64(1.0)}}, + }, + }, + expectedErr: "contains raw newlines", + }, + { + name: "NilMetric", + in: &dto.MetricFamily{ + Name: proto.String("test_counter_total"), + Type: dto.MetricType_COUNTER.Enum(), + Metric: []*dto.Metric{nil}, + }, + expectedErr: "expected non-nil metric", + }, + { + name: "NilLabelPair", + in: &dto.MetricFamily{ + Name: proto.String("test_counter_total"), + Type: dto.MetricType_COUNTER.Enum(), + Metric: []*dto.Metric{ + { + Label: []*dto.LabelPair{nil}, + Counter: &dto.Counter{Value: proto.Float64(1.0)}, + }, + }, + }, + expectedErr: "expected non-nil label pair", + }, + { + name: "CounterInvalidCreatedTimestamp", + in: &dto.MetricFamily{ + Name: proto.String("test_counter_total"), + Type: dto.MetricType_COUNTER.Enum(), + Metric: []*dto.Metric{ + { + Counter: &dto.Counter{ + Value: proto.Float64(1.0), + CreatedTimestamp: ×tamppb.Timestamp{ + Nanos: -1, + }, + }, + }, + }, + }, + expectedErr: "invalid created timestamp in metric test_counter_total", + }, + { + name: "ExemplarInvalidTimestamp", + in: &dto.MetricFamily{ + Name: proto.String("test_counter_total"), + Type: dto.MetricType_COUNTER.Enum(), + Metric: []*dto.Metric{ + { + Counter: &dto.Counter{ + Value: proto.Float64(1.0), + Exemplar: &dto.Exemplar{ + Label: []*dto.LabelPair{ + {Name: proto.String("trace_id"), Value: proto.String("1234")}, + }, + Value: proto.Float64(1.0), + Timestamp: ×tamppb.Timestamp{ + Nanos: -1, + }, + }, + }, + }, + }, + }, + expectedErr: "has out-of-range nanos", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var buf bytes.Buffer + _, err := MetricFamilyToOpenMetrics20(&buf, tc.in) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), tc.expectedErr) { + t.Fatalf("expected error containing %q, got %q", tc.expectedErr, err.Error()) + } + }) + } +} + +func TestWriteOpenMetrics20Sample_UseIntValue(t *testing.T) { + var buf bytes.Buffer + w := enhancedWriter(&buf) + metric := &dto.Metric{} + n, err := writeOpenMetrics20Sample(w, "test_metric", metric, 0, 123, true, nil) + if err != nil { + t.Fatal(err) + } + expected := "test_metric 123\n" + if buf.String() != expected { + t.Errorf("expected %q, got %q", expected, buf.String()) + } + if n != len(expected) { + t.Errorf("expected %d bytes written, got %d", len(expected), n) + } +} + +func TestCreateOpenMetrics20_SimpleWriter(t *testing.T) { + in := &dto.MetricFamily{ + Name: proto.String("http_requests_total"), + Type: dto.MetricType_COUNTER.Enum(), + Metric: []*dto.Metric{ + { + Counter: &dto.Counter{ + Value: proto.Float64(1027), + }, + }, + }, + } + + var buf bytes.Buffer + // Wrap bytes.Buffer in a struct that only implements io.Writer + sw := struct { + io.Writer + }{&buf} + + n, err := MetricFamilyToOpenMetrics20(sw, in) + if err != nil { + t.Fatal(err) + } + + expected := `# TYPE http_requests_total counter +http_requests_total 1027 +` + if buf.String() != expected { + t.Errorf("expected %q, got %q", expected, buf.String()) + } + if n != len(expected) { + t.Errorf("expected %d bytes written, got %d", len(expected), n) + } +}