diff --git a/expfmt/openmetrics_2_0_create.go b/expfmt/openmetrics_2_0_create.go index 7ab6a212..d68504e7 100644 --- a/expfmt/openmetrics_2_0_create.go +++ b/expfmt/openmetrics_2_0_create.go @@ -31,11 +31,16 @@ import ( // It returns the number of bytes written and any error encountered. // // NOTE: This method targets OpenMetrics 2.0.0 (currently aligned with 2.0-rc.0) which is experimental and -// encode-only (currently supporting counter, gauge, and untyped metric types). +// encode-only (currently supporting counter, gauge, summary, untyped, histogram, and gaugehistogram metric types). // 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. // EncoderOptions are accepted for signature compatibility with // MetricFamilyToOpenMetrics and are currently ignored. +// +// OpenMetrics 2.0 enforces stricter validation rules defined in the specification +// than Prometheus text or OpenMetrics 1.0 formats (such as requiring non-negative +// count and sum, and non-negative quantile values for summaries). Consequently, +// MetricFamilyToOpenMetrics20 may reject metric families that the other encoders accept. func MetricFamilyToOpenMetrics20(out io.Writer, in *dto.MetricFamily, options ...EncoderOption) (written int, err error) { // Options are accepted for signature compatibility and ignored. _ = options @@ -340,13 +345,159 @@ func writeOpenMetrics20Timestamp(w enhancedWriter, f float64) (int, error) { return written, err } -// Stubs for Summary - func writeCompositeSummary(w enhancedWriter, name string, metric *dto.Metric) (int, error) { - _ = w - _ = name - _ = metric - return 0, errors.New("summary not implemented yet") + s := metric.Summary + if s == nil { + return 0, fmt.Errorf("expected summary in metric %s", name) + } + + if err := validateLabels20(metric.Label); err != nil { + return 0, err + } + for _, lp := range metric.Label { + if lp.GetName() == "quantile" { + return 0, fmt.Errorf("metric %s is a summary but label set contains %q label", name, "quantile") + } + } + + sum := s.GetSampleSum() + if math.IsNaN(sum) { + return 0, fmt.Errorf("summary sum cannot be NaN in metric %s", name) + } + if sum < 0 { + return 0, fmt.Errorf("summary sum cannot be negative (%g) in metric %s", sum, name) + } + + var prevQuantile float64 + for i, q := range s.Quantile { + if q == nil { + return 0, fmt.Errorf("expected non-nil quantile in metric %s", name) + } + qv := q.GetQuantile() + if math.IsNaN(qv) { + return 0, fmt.Errorf("summary quantile cannot be NaN in metric %s", name) + } + if math.IsInf(qv, 0) || qv < 0 || qv > 1 { + return 0, fmt.Errorf("summary quantile %g must be between 0 and 1 in metric %s", qv, name) + } + if i > 0 && qv <= prevQuantile { + return 0, fmt.Errorf("summary quantiles must be strictly increasing: %g <= %g in metric %s", qv, prevQuantile, name) + } + prevQuantile = qv + + v := q.GetValue() + if !math.IsNaN(v) && v < 0 { + return 0, fmt.Errorf("summary quantile value cannot be negative (%g) in metric %s", v, name) + } + } + + if s.CreatedTimestamp != nil { + if err := s.CreatedTimestamp.CheckValid(); err != nil { + return 0, fmt.Errorf("invalid created timestamp in metric %s: %w", name, err) + } + } + + written := 0 + n, err := writeOpenMetricsNameAndLabelPairs(w, name, metric.Label, "", 0) + written += n + if err != nil { + return written, err + } + + n, err = w.WriteString(" {count:") + written += n + if err != nil { + return written, err + } + + n, err = writeUint(w, s.GetSampleCount()) + written += n + if err != nil { + return written, err + } + + n, err = w.WriteString(",sum:") + written += n + if err != nil { + return written, err + } + + n, err = writeFloat(w, sum) + written += n + if err != nil { + return written, err + } + + n, err = w.WriteString(",quantile:[") + written += n + if err != nil { + return written, err + } + + for i, q := range s.Quantile { + if i > 0 { + err = w.WriteByte(',') + written++ + if err != nil { + return written, err + } + } + n, err = writeFloat(w, q.GetQuantile()) + written += n + if err != nil { + return written, err + } + err = w.WriteByte(':') + written++ + if err != nil { + return written, err + } + n, err = writeFloat(w, q.GetValue()) + written += n + if err != nil { + return written, err + } + } + + n, err = w.WriteString("]}") + 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 + } + } + + if s.CreatedTimestamp != nil { + n, err = w.WriteString(" st@") + written += n + if err != nil { + return written, err + } + n, err = writeProtoTimestamp(w, s.CreatedTimestamp) + written += n + if err != nil { + return written, err + } + } + + err = w.WriteByte('\n') + written++ + if err != nil { + return written, err + } + + return written, nil } func writeCompositeHistogram(w enhancedWriter, name string, metric *dto.Metric, isGauge bool) (int, error) { diff --git a/expfmt/openmetrics_2_0_create_test.go b/expfmt/openmetrics_2_0_create_test.go index 167406c7..b99a03e5 100644 --- a/expfmt/openmetrics_2_0_create_test.go +++ b/expfmt/openmetrics_2_0_create_test.go @@ -923,6 +923,244 @@ empty_histogram {count:0,sum:0,bucket:[+Inf:0]} }, out: `# TYPE test_histogram histogram test_histogram {count:1,sum:0.1,bucket:[0.1:1,+Inf:1]} st@-0.5 +`, + }, + { + name: "Summary", + in: &dto.MetricFamily{ + Name: proto.String("rpc_duration_seconds"), + Help: proto.String("RPC latency in seconds."), + Type: dto.MetricType_SUMMARY.Enum(), + Unit: proto.String("seconds"), + Metric: []*dto.Metric{ + { + Label: []*dto.LabelPair{ + {Name: proto.String("service"), Value: proto.String("auth")}, + }, + Summary: &dto.Summary{ + SampleCount: proto.Uint64(100), + SampleSum: proto.Float64(25.5), + Quantile: []*dto.Quantile{ + {Quantile: proto.Float64(0.5), Value: proto.Float64(0.12)}, + {Quantile: proto.Float64(0.9), Value: proto.Float64(0.45)}, + {Quantile: proto.Float64(0.99), Value: proto.Float64(0.89)}, + }, + }, + }, + }, + }, + out: `# HELP rpc_duration_seconds RPC latency in seconds. +# TYPE rpc_duration_seconds summary +# UNIT rpc_duration_seconds seconds +rpc_duration_seconds{service="auth"} {count:100,sum:25.5,quantile:[0.5:0.12,0.9:0.45,0.99:0.89]} +`, + }, + { + name: "Summary_EmptyQuantiles", + in: &dto.MetricFamily{ + Name: proto.String("foo"), + Type: dto.MetricType_SUMMARY.Enum(), + Metric: []*dto.Metric{ + { + Summary: &dto.Summary{ + SampleCount: proto.Uint64(17), + SampleSum: proto.Float64(324789.3), + }, + }, + }, + }, + out: `# TYPE foo summary +foo {count:17,sum:324789.3,quantile:[]} +`, + }, + { + name: "Summary_ZeroCountAndSum", + in: &dto.MetricFamily{ + Name: proto.String("foo"), + Type: dto.MetricType_SUMMARY.Enum(), + Metric: []*dto.Metric{ + { + Summary: &dto.Summary{ + SampleCount: proto.Uint64(0), + SampleSum: proto.Float64(0), + Quantile: []*dto.Quantile{ + {Quantile: proto.Float64(0.95), Value: proto.Float64(123.7)}, + {Quantile: proto.Float64(0.99), Value: proto.Float64(150)}, + }, + }, + }, + }, + }, + out: `# TYPE foo summary +foo {count:0,sum:0,quantile:[0.95:123.7,0.99:150]} +`, + }, + { + name: "Summary_WithCreatedTimestamp", + in: &dto.MetricFamily{ + Name: proto.String("foo"), + Type: dto.MetricType_SUMMARY.Enum(), + Metric: []*dto.Metric{ + { + Summary: &dto.Summary{ + SampleCount: proto.Uint64(17), + SampleSum: proto.Float64(324789.3), + CreatedTimestamp: ×tamppb.Timestamp{Seconds: 1234567890}, + }, + }, + }, + }, + out: `# TYPE foo summary +foo {count:17,sum:324789.3,quantile:[]} st@1234567890 +`, + }, + { + name: "Summary_WithSubsecondCreatedTimestamp", + in: &dto.MetricFamily{ + Name: proto.String("foo"), + Type: dto.MetricType_SUMMARY.Enum(), + Metric: []*dto.Metric{ + { + Summary: &dto.Summary{ + SampleCount: proto.Uint64(17), + SampleSum: proto.Float64(324789.3), + CreatedTimestamp: ×tamppb.Timestamp{Seconds: 1234567890, Nanos: 987654321}, + }, + }, + }, + }, + out: `# TYPE foo summary +foo {count:17,sum:324789.3,quantile:[]} st@1234567890.987654321 +`, + }, + { + name: "Summary_WithTimestamp", + in: &dto.MetricFamily{ + Name: proto.String("foo"), + Type: dto.MetricType_SUMMARY.Enum(), + Metric: []*dto.Metric{ + { + Summary: &dto.Summary{ + SampleCount: proto.Uint64(17), + SampleSum: proto.Float64(324789.3), + }, + TimestampMs: proto.Int64(1234567891000), + }, + }, + }, + out: `# TYPE foo summary +foo {count:17,sum:324789.3,quantile:[]} 1234567891 +`, + }, + { + name: "Summary_WithTimestampAndCreatedTimestamp", + in: &dto.MetricFamily{ + Name: proto.String("foo"), + Type: dto.MetricType_SUMMARY.Enum(), + Metric: []*dto.Metric{ + { + Summary: &dto.Summary{ + SampleCount: proto.Uint64(17), + SampleSum: proto.Float64(324789.3), + CreatedTimestamp: ×tamppb.Timestamp{Seconds: 1234567890}, + }, + TimestampMs: proto.Int64(1234567891000), + }, + }, + }, + out: `# TYPE foo summary +foo {count:17,sum:324789.3,quantile:[]} 1234567891 st@1234567890 +`, + }, + { + name: "Summary_NaNAndInfQuantileValues", + in: &dto.MetricFamily{ + Name: proto.String("foo"), + Type: dto.MetricType_SUMMARY.Enum(), + Metric: []*dto.Metric{ + { + Summary: &dto.Summary{ + SampleCount: proto.Uint64(5), + SampleSum: proto.Float64(10), + Quantile: []*dto.Quantile{ + {Quantile: proto.Float64(0.5), Value: proto.Float64(math.NaN())}, + {Quantile: proto.Float64(0.99), Value: proto.Float64(math.Inf(+1))}, + }, + }, + }, + }, + }, + out: `# TYPE foo summary +foo {count:5,sum:10,quantile:[0.5:NaN,0.99:+Inf]} +`, + }, + { + name: "Summary_UTF8", + in: &dto.MetricFamily{ + Name: proto.String("my.app/duration"), + Type: dto.MetricType_SUMMARY.Enum(), + Metric: []*dto.Metric{ + { + Label: []*dto.LabelPair{ + {Name: proto.String("service.name"), Value: proto.String("my_service")}, + }, + Summary: &dto.Summary{ + SampleCount: proto.Uint64(2), + SampleSum: proto.Float64(3.4), + Quantile: []*dto.Quantile{ + {Quantile: proto.Float64(0.95), Value: proto.Float64(1.7)}, + }, + }, + }, + }, + }, + out: `# TYPE "my.app/duration" summary +{"my.app/duration","service.name"="my_service"} {count:2,sum:3.4,quantile:[0.95:1.7]} +`, + }, + { + name: "Summary_MultipleMetrics", + in: &dto.MetricFamily{ + Name: proto.String("acme_http_router_request_seconds"), + Help: proto.String("Latency though all of ACME's HTTP request router."), + Type: dto.MetricType_SUMMARY.Enum(), + Unit: proto.String("seconds"), + Metric: []*dto.Metric{ + { + Label: []*dto.LabelPair{ + {Name: proto.String("path"), Value: proto.String("/api/v1")}, + {Name: proto.String("method"), Value: proto.String("GET")}, + }, + Summary: &dto.Summary{ + SampleCount: proto.Uint64(807283), + SampleSum: proto.Float64(9036.32), + Quantile: []*dto.Quantile{ + {Quantile: proto.Float64(0.95), Value: proto.Float64(2)}, + {Quantile: proto.Float64(0.99), Value: proto.Float64(20)}, + }, + }, + }, + { + Label: []*dto.LabelPair{ + {Name: proto.String("path"), Value: proto.String("/api/v2")}, + {Name: proto.String("method"), Value: proto.String("GET")}, + }, + Summary: &dto.Summary{ + SampleCount: proto.Uint64(34), + SampleSum: proto.Float64(479.3), + Quantile: []*dto.Quantile{ + {Quantile: proto.Float64(0.95), Value: proto.Float64(2.5)}, + {Quantile: proto.Float64(0.99), Value: proto.Float64(2.9)}, + }, + }, + }, + }, + }, + out: `# HELP acme_http_router_request_seconds Latency though all of ACME's HTTP request router. +# TYPE acme_http_router_request_seconds summary +# UNIT acme_http_router_request_seconds seconds +acme_http_router_request_seconds{path="/api/v1",method="GET"} {count:807283,sum:9036.32,quantile:[0.95:2,0.99:20]} +acme_http_router_request_seconds{path="/api/v2",method="GET"} {count:34,sum:479.3,quantile:[0.95:2.5,0.99:2.9]} `, }, } @@ -1053,15 +1291,201 @@ func TestCreateOpenMetrics20_Errors(t *testing.T) { expectedErr: "expected histogram in metric", }, { - name: "SummaryNotImplemented", + name: "Summary_LabelContainsQuantile", in: &dto.MetricFamily{ - Name: proto.String("test_metric"), + Name: proto.String("test_summary"), + Type: dto.MetricType_SUMMARY.Enum(), + Metric: []*dto.Metric{ + { + Label: []*dto.LabelPair{ + {Name: proto.String("quantile"), Value: proto.String("0.9")}, + }, + Summary: &dto.Summary{}, + }, + }, + }, + expectedErr: `metric test_summary is a summary but label set contains "quantile" label`, + }, + { + name: "Summary_SumNaN", + in: &dto.MetricFamily{ + Name: proto.String("test_summary"), + Type: dto.MetricType_SUMMARY.Enum(), + Metric: []*dto.Metric{ + { + Summary: &dto.Summary{ + SampleSum: proto.Float64(math.NaN()), + }, + }, + }, + }, + expectedErr: "summary sum cannot be NaN", + }, + { + name: "Summary_SumNegative", + in: &dto.MetricFamily{ + Name: proto.String("test_summary"), + Type: dto.MetricType_SUMMARY.Enum(), + Metric: []*dto.Metric{ + { + Summary: &dto.Summary{ + SampleSum: proto.Float64(-1.0), + }, + }, + }, + }, + expectedErr: "summary sum cannot be negative", + }, + { + name: "Summary_NilQuantile", + in: &dto.MetricFamily{ + Name: proto.String("test_summary"), Type: dto.MetricType_SUMMARY.Enum(), Metric: []*dto.Metric{ - {Summary: &dto.Summary{}}, + { + Summary: &dto.Summary{ + Quantile: []*dto.Quantile{nil}, + }, + }, + }, + }, + expectedErr: "expected non-nil quantile", + }, + { + name: "Summary_QuantileNaN", + in: &dto.MetricFamily{ + Name: proto.String("test_summary"), + Type: dto.MetricType_SUMMARY.Enum(), + Metric: []*dto.Metric{ + { + Summary: &dto.Summary{ + Quantile: []*dto.Quantile{ + {Quantile: proto.Float64(math.NaN()), Value: proto.Float64(1.0)}, + }, + }, + }, + }, + }, + expectedErr: "summary quantile cannot be NaN", + }, + { + name: "Summary_QuantileNegative", + in: &dto.MetricFamily{ + Name: proto.String("test_summary"), + Type: dto.MetricType_SUMMARY.Enum(), + Metric: []*dto.Metric{ + { + Summary: &dto.Summary{ + Quantile: []*dto.Quantile{ + {Quantile: proto.Float64(-0.1), Value: proto.Float64(1.0)}, + }, + }, + }, + }, + }, + expectedErr: "must be between 0 and 1", + }, + { + name: "Summary_QuantileGreaterThanOne", + in: &dto.MetricFamily{ + Name: proto.String("test_summary"), + Type: dto.MetricType_SUMMARY.Enum(), + Metric: []*dto.Metric{ + { + Summary: &dto.Summary{ + Quantile: []*dto.Quantile{ + {Quantile: proto.Float64(1.1), Value: proto.Float64(1.0)}, + }, + }, + }, }, }, - expectedErr: "summary not implemented yet", + expectedErr: "must be between 0 and 1", + }, + { + name: "Summary_QuantilePosInf", + in: &dto.MetricFamily{ + Name: proto.String("test_summary"), + Type: dto.MetricType_SUMMARY.Enum(), + Metric: []*dto.Metric{ + { + Summary: &dto.Summary{ + Quantile: []*dto.Quantile{ + {Quantile: proto.Float64(math.Inf(+1)), Value: proto.Float64(1.0)}, + }, + }, + }, + }, + }, + expectedErr: "must be between 0 and 1", + }, + { + name: "Summary_QuantilesUnsorted", + in: &dto.MetricFamily{ + Name: proto.String("test_summary"), + Type: dto.MetricType_SUMMARY.Enum(), + Metric: []*dto.Metric{ + { + Summary: &dto.Summary{ + Quantile: []*dto.Quantile{ + {Quantile: proto.Float64(0.9), Value: proto.Float64(1.0)}, + {Quantile: proto.Float64(0.5), Value: proto.Float64(0.5)}, + }, + }, + }, + }, + }, + expectedErr: "summary quantiles must be strictly increasing", + }, + { + name: "Summary_QuantilesDuplicate", + in: &dto.MetricFamily{ + Name: proto.String("test_summary"), + Type: dto.MetricType_SUMMARY.Enum(), + Metric: []*dto.Metric{ + { + Summary: &dto.Summary{ + Quantile: []*dto.Quantile{ + {Quantile: proto.Float64(0.5), Value: proto.Float64(1.0)}, + {Quantile: proto.Float64(0.5), Value: proto.Float64(2.0)}, + }, + }, + }, + }, + }, + expectedErr: "summary quantiles must be strictly increasing", + }, + { + name: "Summary_QuantileValueNegative", + in: &dto.MetricFamily{ + Name: proto.String("test_summary"), + Type: dto.MetricType_SUMMARY.Enum(), + Metric: []*dto.Metric{ + { + Summary: &dto.Summary{ + Quantile: []*dto.Quantile{ + {Quantile: proto.Float64(0.5), Value: proto.Float64(-1.0)}, + }, + }, + }, + }, + }, + expectedErr: "summary quantile value cannot be negative", + }, + { + name: "Summary_InvalidCreatedTimestamp", + in: &dto.MetricFamily{ + Name: proto.String("test_summary"), + Type: dto.MetricType_SUMMARY.Enum(), + Metric: []*dto.Metric{ + { + Summary: &dto.Summary{ + CreatedTimestamp: ×tamppb.Timestamp{Nanos: -1}, + }, + }, + }, + }, + expectedErr: "invalid created timestamp in metric test_summary", }, { name: "HistogramCountNegative", @@ -1909,3 +2333,28 @@ func TestCreateOpenMetrics20_HistogramError_NoPartialBytes(t *testing.T) { t.Fatalf("expected 0 bytes written on validation error, got %q", buf.String()) } } + +func TestCreateOpenMetrics20_SummaryError_NoPartialBytes(t *testing.T) { + in := &dto.MetricFamily{ + Name: proto.String("test_summary"), + Type: dto.MetricType_SUMMARY.Enum(), + Metric: []*dto.Metric{ + { + Summary: &dto.Summary{ + SampleCount: proto.Uint64(1), + SampleSum: proto.Float64(-1.0), // invalid sum + }, + }, + }, + } + + var buf bytes.Buffer + w := enhancedWriter(&buf) + _, err := writeCompositeSummary(w, in.GetName(), in.Metric[0]) + if err == nil { + t.Fatal("expected error, got nil") + } + if buf.Len() != 0 { + t.Fatalf("expected 0 bytes written on validation error, got %q", buf.String()) + } +}