diff --git a/expfmt/encode.go b/expfmt/encode.go index 73c24dfb..21ce3acc 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" @@ -118,8 +119,10 @@ func NegotiateIncludingOpenMetrics(h http.Header) Format { if ac.Type == "text" && ac.SubType == "plain" && (ver == TextVersion || ver == "") { return FmtText + escapingScheme } - if ac.Type+"/"+ac.SubType == OpenMetricsType && (ver == OpenMetricsVersion_0_0_1 || ver == OpenMetricsVersion_1_0_0 || ver == "") { + if ac.Type+"/"+ac.SubType == OpenMetricsType && (ver == OpenMetricsVersion_0_0_1 || ver == OpenMetricsVersion_1_0_0 || ver == OpenMetricsVersion_2_0_0 || ver == "") { switch ver { + case OpenMetricsVersion_2_0_0: + return FmtOpenMetrics_2_0_0 + escapingScheme case OpenMetricsVersion_1_0_0: return FmtOpenMetrics_1_0_0 + escapingScheme default: @@ -181,6 +184,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..87573612 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: "application/openmetrics-text; version=2.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", @@ -268,6 +273,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 4e4c13e7..ad54fb4c 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..539f7b81 --- /dev/null +++ b/expfmt/openmetrics_2_0_create.go @@ -0,0 +1,291 @@ +// 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" + + dto "github.com/prometheus/client_model/go" +) + +// 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) + } + + // 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 { + switch metricType { + case dto.MetricType_COUNTER: + if metric.Counter == nil { + return written, fmt.Errorf("expected counter in metric %s %s", name, metric) + } + n, err = writeOpenMetrics20Sample(w, name, metric, metric.Counter.GetValue(), 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) { + 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 { + n, err = w.WriteString(" st@") + written += n + if err != nil { + return written, err + } + ts := metric.Counter.CreatedTimestamp + n, err = writeOpenMetrics20Timestamp(w, float64(ts.GetSeconds())+float64(ts.GetNanos())/1e9) + written += n + if err != nil { + return written, err + } + } + + if exemplar != nil && len(exemplar.Label) > 0 { + n, err = writeExemplar(w, exemplar) + written += n + if err != nil { + return written, err + } + } + + err = w.WriteByte('\n') + written++ + 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") +} diff --git a/expfmt/openmetrics_2_0_create_test.go b/expfmt/openmetrics_2_0_create_test.go new file mode 100644 index 00000000..7fe5af56 --- /dev/null +++ b/expfmt/openmetrics_2_0_create_test.go @@ -0,0 +1,389 @@ +// 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" + "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: "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), + 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 # {trace_id="1234"} 1.0 +`, + }, + { + 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 + _, 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()) + } + }) + } +} + +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) + _, 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()) + } + }) + } +} + +func TestCreateOpenMetrics20_Errors(t *testing.T) { + tests := []struct { + name string + in *dto.MetricFamily + }{ + { + name: "NoName", + in: &dto.MetricFamily{ + Type: dto.MetricType_COUNTER.Enum(), + }, + }, + { + name: "UnknownType", + in: &dto.MetricFamily{ + Name: proto.String("test_metric"), + Type: dto.MetricType(100).Enum(), + }, + }, + { + name: "MissingCounter", + in: &dto.MetricFamily{ + Name: proto.String("test_metric"), + Type: dto.MetricType_COUNTER.Enum(), + Metric: []*dto.Metric{ + {}, + }, + }, + }, + { + name: "MissingGauge", + in: &dto.MetricFamily{ + Name: proto.String("test_metric"), + Type: dto.MetricType_GAUGE.Enum(), + Metric: []*dto.Metric{ + {}, + }, + }, + }, + { + name: "MissingUntyped", + in: &dto.MetricFamily{ + Name: proto.String("test_metric"), + Type: dto.MetricType_UNTYPED.Enum(), + Metric: []*dto.Metric{ + {}, + }, + }, + }, + { + name: "MissingSummary", + in: &dto.MetricFamily{ + Name: proto.String("test_metric"), + Type: dto.MetricType_SUMMARY.Enum(), + Metric: []*dto.Metric{ + {}, + }, + }, + }, + { + name: "MissingHistogram", + in: &dto.MetricFamily{ + Name: proto.String("test_metric"), + Type: dto.MetricType_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + {}, + }, + }, + }, + { + name: "SummaryNotImplemented", + in: &dto.MetricFamily{ + Name: proto.String("test_metric"), + Type: dto.MetricType_SUMMARY.Enum(), + Metric: []*dto.Metric{ + {Summary: &dto.Summary{}}, + }, + }, + }, + { + name: "HistogramNotImplemented", + in: &dto.MetricFamily{ + Name: proto.String("test_metric"), + Type: dto.MetricType_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + {Histogram: &dto.Histogram{}}, + }, + }, + }, + { + name: "GaugeHistogramNotImplemented", + in: &dto.MetricFamily{ + Name: proto.String("test_metric"), + Type: dto.MetricType_GAUGE_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + {Histogram: &dto.Histogram{}}, + }, + }, + }, + } + + 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") + } + }) + } +} + +func TestWriteOpenMetrics20Sample_UseIntValue(t *testing.T) { + var buf bytes.Buffer + w := enhancedWriter(&buf) + metric := &dto.Metric{} + _, 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()) + } +} + +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} + + _, 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()) + } +}