From 2111a5ae1f5aff2738b232ddd041c4e59ba019f2 Mon Sep 17 00:00:00 2001 From: David Ashpole Date: Fri, 21 Aug 2026 15:10:33 +0000 Subject: [PATCH 1/3] feat: implement histogram and gauge histogram support for OpenMetrics 2.0 Implement serialization and strict validation for classic, native, dual, and gauge histograms in OpenMetrics 2.0 format according to the specification and ABNF grammar. Signed-off-by: David Ashpole --- expfmt/openmetrics_2_0_create.go | 534 +++++++++++++++- expfmt/openmetrics_2_0_create_test.go | 887 +++++++++++++++++++++++++- 2 files changed, 1404 insertions(+), 17 deletions(-) diff --git a/expfmt/openmetrics_2_0_create.go b/expfmt/openmetrics_2_0_create.go index 9f35aced..03b83b32 100644 --- a/expfmt/openmetrics_2_0_create.go +++ b/expfmt/openmetrics_2_0_create.go @@ -260,7 +260,7 @@ func writeOpenMetrics20Sample(w enhancedWriter, name string, metric *dto.Metric, } } - if exemplar != nil && len(exemplar.Label) > 0 && exemplar.Timestamp != nil { + if exemplar != nil && exemplar.Timestamp != nil { n, err = writeExemplar20(w, exemplar) written += n if err != nil { @@ -279,7 +279,7 @@ func writeOpenMetrics20Sample(w enhancedWriter, name string, metric *dto.Metric, // 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 { + if e == nil || e.Timestamp == nil { return 0, nil } if err := validateExemplar20(e); err != nil { @@ -291,7 +291,11 @@ func writeExemplar20(w enhancedWriter, e *dto.Exemplar) (int, error) { if err != nil { return written, err } - n, err = writeOpenMetricsNameAndLabelPairs(w, "", e.Label, "", 0) + if len(e.Label) == 0 { + n, err = w.WriteString("{}") + } else { + n, err = writeOpenMetricsNameAndLabelPairs(w, "", e.Label, "", 0) + } written += n if err != nil { return written, err @@ -342,7 +346,7 @@ func writeOpenMetrics20Timestamp(w enhancedWriter, f float64) (int, error) { } } -// Stubs for Summary and Histogram +// Stubs for Summary func writeCompositeSummary(w enhancedWriter, name string, metric *dto.Metric) (int, error) { _ = w @@ -352,11 +356,523 @@ func writeCompositeSummary(w enhancedWriter, name string, metric *dto.Metric) (i } 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") + h := metric.Histogram + if h == nil { + return 0, fmt.Errorf("expected histogram in metric %s", name) + } + + isNative := h.Schema != nil + hasClassicBuckets := len(h.Bucket) > 0 || !isNative + + if err := validateLabels20(metric.Label); err != nil { + return 0, err + } + if hasClassicBuckets { + for _, lp := range metric.Label { + if lp.GetName() == "le" { + return 0, fmt.Errorf("metric %s has classic buckets but label set contains %q label", name, "le") + } + } + } + + var isFloatCount bool + var sampleCountFloat float64 + var sampleCountUint uint64 + switch { + case h.SampleCountFloat != nil && (*h.SampleCountFloat > 0 || h.SampleCount == nil || isGauge): + isFloatCount = true + sampleCountFloat = *h.SampleCountFloat + if math.IsNaN(sampleCountFloat) { + if isGauge { + return 0, fmt.Errorf("gaugehistogram count cannot be NaN in metric %s", name) + } + return 0, fmt.Errorf("histogram count cannot be NaN in metric %s", name) + } + if !isGauge && sampleCountFloat < 0 { + return 0, fmt.Errorf("histogram count cannot be negative (%g) in metric %s", sampleCountFloat, name) + } + case h.SampleCount != nil: + sampleCountUint = *h.SampleCount + sampleCountFloat = float64(sampleCountUint) + } + + written := 0 + n, err := writeOpenMetricsNameAndLabelPairs(w, name, metric.Label, "", 0) + written += n + if err != nil { + return written, err + } + + n, err = w.WriteString(" {") + written += n + if err != nil { + return written, err + } + + if isGauge { + n, err = w.WriteString("gcount:") + } else { + n, err = w.WriteString("count:") + } + written += n + if err != nil { + return written, err + } + + if isFloatCount { + n, err = writeFloat(w, sampleCountFloat) + } else { + n, err = writeUint(w, sampleCountUint) + } + written += n + if err != nil { + return written, err + } + + if isGauge { + n, err = w.WriteString(",gsum:") + } else { + n, err = w.WriteString(",sum:") + } + written += n + if err != nil { + return written, err + } + n, err = writeFloat(w, h.GetSampleSum()) + written += n + if err != nil { + return written, err + } + + if isNative { + n, err = writeNativeBuckets(w, name, h, isGauge) + written += n + if err != nil { + return written, err + } + } + + var classicExemplars []*dto.Exemplar + if hasClassicBuckets { + n, err = writeClassicBuckets(w, name, h, sampleCountFloat, isFloatCount, isGauge, &classicExemplars) + written += n + if err != nil { + return written, err + } + } + + err = w.WriteByte('}') + written++ + 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 !isGauge && h.CreatedTimestamp != nil { + ts := h.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 + } + } + + var exemplarsToEmit []*dto.Exemplar + if len(classicExemplars) > 0 { + exemplarsToEmit = classicExemplars + } else if len(h.Exemplars) > 0 { + exemplarsToEmit = h.Exemplars + } + + for _, e := range exemplarsToEmit { + if e == nil || e.Timestamp == nil { + continue + } + n, err = writeExemplar20(w, e) + written += n + if err != nil { + return written, err + } + } + + err = w.WriteByte('\n') + written++ + if err != nil { + return written, err + } + + return written, nil +} + +func writeNativeBuckets(w enhancedWriter, name string, h *dto.Histogram, isGauge bool) (int, error) { + schema := *h.Schema + if schema < -4 || schema > 8 { + return 0, fmt.Errorf("native histogram schema %d is out of range [-4, 8] in metric %s", schema, name) + } + + zeroThreshold := h.GetZeroThreshold() + if math.IsNaN(zeroThreshold) || math.IsInf(zeroThreshold, 0) || zeroThreshold < 0 { + return 0, fmt.Errorf("native histogram zero_threshold %g must be a non-negative, finite number in metric %s", zeroThreshold, name) + } + + var isFloatZeroCount bool + var zeroCountFloat float64 + var zeroCountUint uint64 + switch { + case h.ZeroCountFloat != nil && (*h.ZeroCountFloat > 0 || h.ZeroCount == nil || isGauge): + isFloatZeroCount = true + zeroCountFloat = *h.ZeroCountFloat + if math.IsNaN(zeroCountFloat) { + return 0, fmt.Errorf("native histogram zero_count cannot be NaN in metric %s", name) + } + if !isGauge && zeroCountFloat < 0 { + return 0, fmt.Errorf("native histogram zero_count cannot be negative (%g) in metric %s", zeroCountFloat, name) + } + case h.ZeroCount != nil: + zeroCountUint = *h.ZeroCount + zeroCountFloat = float64(zeroCountUint) + } + + written := 0 + n, err := w.WriteString(",schema:") + written += n + if err != nil { + return written, err + } + n, err = writeInt(w, int64(schema)) + written += n + if err != nil { + return written, err + } + + n, err = w.WriteString(",zero_threshold:") + written += n + if err != nil { + return written, err + } + n, err = writeFloat(w, zeroThreshold) + written += n + if err != nil { + return written, err + } + + n, err = w.WriteString(",zero_count:") + written += n + if err != nil { + return written, err + } + if isFloatZeroCount { + n, err = writeFloat(w, zeroCountFloat) + } else { + n, err = writeUint(w, zeroCountUint) + } + written += n + if err != nil { + return written, err + } + + n, err = writeSpansAndBuckets(w, name, "negative", h.NegativeSpan, h.NegativeDelta, h.NegativeCount, isGauge) + written += n + if err != nil { + return written, err + } + + n, err = writeSpansAndBuckets(w, name, "positive", h.PositiveSpan, h.PositiveDelta, h.PositiveCount, isGauge) + written += n + if err != nil { + return written, err + } + + return written, nil +} + +func writeSpansAndBuckets( + w enhancedWriter, + name string, + spanName string, + spans []*dto.BucketSpan, + deltas []int64, + floatCounts []float64, + isGauge bool, +) (int, error) { + isFloatBuckets := len(floatCounts) > 0 + var numBuckets int + if isFloatBuckets { + numBuckets = len(floatCounts) + } else { + numBuckets = len(deltas) + } + + var totalLength uint64 + for i, span := range spans { + if span == nil { + return 0, errors.New("expected non-nil bucket span") + } + if i > 0 && span.GetOffset() < 0 { + return 0, fmt.Errorf("subsequent %s span offset cannot be negative: %d in metric %s", spanName, span.GetOffset(), name) + } + totalLength += uint64(span.GetLength()) + } + + if numBuckets == 0 { + if totalLength == 0 { + return 0, nil + } + return 0, fmt.Errorf("sum of %s span lengths (%d) does not match bucket count (0) in metric %s", spanName, totalLength, name) + } + + if totalLength != uint64(numBuckets) { + return 0, fmt.Errorf("sum of %s span lengths (%d) does not match bucket count (%d) in metric %s", spanName, totalLength, numBuckets, name) + } + + if isFloatBuckets { + for _, v := range floatCounts { + if math.IsNaN(v) { + return 0, fmt.Errorf("%s bucket count cannot be NaN in metric %s", spanName, name) + } + if !isGauge && v < 0 { + return 0, fmt.Errorf("%s bucket count cannot be negative (%g) in metric %s", spanName, v, name) + } + } + } else { + var current int64 + for _, d := range deltas { + current += d + if !isGauge && current < 0 { + return 0, fmt.Errorf("%s bucket count cannot be negative (%d) in metric %s", spanName, current, name) + } + } + } + + written := 0 + n, err := w.WriteString("," + spanName + "_spans:[") + written += n + if err != nil { + return written, err + } + for i, span := range spans { + if i > 0 { + err = w.WriteByte(',') + written++ + if err != nil { + return written, err + } + } + n, err = writeInt(w, int64(span.GetOffset())) + written += n + if err != nil { + return written, err + } + err = w.WriteByte(':') + written++ + if err != nil { + return written, err + } + n, err = writeUint(w, uint64(span.GetLength())) + written += n + if err != nil { + return written, err + } + } + err = w.WriteByte(']') + written++ + if err != nil { + return written, err + } + + n, err = w.WriteString("," + spanName + "_buckets:[") + written += n + if err != nil { + return written, err + } + if isFloatBuckets { + for i, v := range floatCounts { + if i > 0 { + err = w.WriteByte(',') + written++ + if err != nil { + return written, err + } + } + n, err = writeFloat(w, v) + written += n + if err != nil { + return written, err + } + } + } else { + var current int64 + for i, d := range deltas { + if i > 0 { + err = w.WriteByte(',') + written++ + if err != nil { + return written, err + } + } + current += d + n, err = writeInt(w, current) + written += n + if err != nil { + return written, err + } + } + } + err = w.WriteByte(']') + written++ + if err != nil { + return written, err + } + + return written, nil +} + +func writeClassicBuckets( + w enhancedWriter, + name string, + h *dto.Histogram, + sampleCount float64, + isFloatCount bool, + isGauge bool, + collectedExemplars *[]*dto.Exemplar, +) (int, error) { + var infSeen bool + var prevBound float64 + for i, b := range h.Bucket { + if b == nil { + return 0, errors.New("expected non-nil bucket") + } + ub := b.GetUpperBound() + if math.IsNaN(ub) { + return 0, fmt.Errorf("classic bucket upper bound cannot be NaN in metric %s", name) + } + if i > 0 && ub <= prevBound { + return 0, fmt.Errorf("classic bucket upper bounds must be strictly increasing: %g <= %g in metric %s", ub, prevBound, name) + } + prevBound = ub + + var bCount float64 + switch { + case b.CumulativeCountFloat != nil && (*b.CumulativeCountFloat > 0 || b.CumulativeCount == nil || isGauge): + bCount = *b.CumulativeCountFloat + case b.CumulativeCount != nil: + bCount = float64(*b.CumulativeCount) + } + + if math.IsInf(ub, +1) { + if i != len(h.Bucket)-1 { + return 0, fmt.Errorf("+Inf bucket must be the last bucket in metric %s", name) + } + infSeen = true + if bCount != sampleCount { + return 0, fmt.Errorf("classic bucket +Inf count (%g) does not match sample count (%g) in metric %s", bCount, sampleCount, name) + } + } + + if b.CumulativeCountFloat != nil { + c := *b.CumulativeCountFloat + if math.IsNaN(c) { + return 0, fmt.Errorf("classic bucket count cannot be NaN in metric %s", name) + } + if !isGauge && c < 0 { + return 0, fmt.Errorf("classic bucket count cannot be negative (%g) in metric %s", c, name) + } + } + } + + written := 0 + n, err := w.WriteString(",bucket:[") + written += n + if err != nil { + return written, err + } + + for i, b := range h.Bucket { + if i > 0 { + err = w.WriteByte(',') + written++ + if err != nil { + return written, err + } + } + n, err = writeFloat(w, b.GetUpperBound()) + written += n + if err != nil { + return written, err + } + err = w.WriteByte(':') + written++ + if err != nil { + return written, err + } + switch { + case b.CumulativeCountFloat != nil && (*b.CumulativeCountFloat > 0 || b.CumulativeCount == nil || isGauge): + n, err = writeFloat(w, *b.CumulativeCountFloat) + case b.CumulativeCount != nil: + n, err = writeUint(w, *b.CumulativeCount) + default: + n, err = writeUint(w, 0) + } + written += n + if err != nil { + return written, err + } + if b.Exemplar != nil && b.Exemplar.Timestamp != nil { + *collectedExemplars = append(*collectedExemplars, b.Exemplar) + } + } + + if !infSeen { + if len(h.Bucket) > 0 { + err = w.WriteByte(',') + written++ + if err != nil { + return written, err + } + } + n, err = w.WriteString("+Inf:") + written += n + if err != nil { + return written, err + } + if isFloatCount { + n, err = writeFloat(w, sampleCount) + } else { + n, err = writeUint(w, uint64(sampleCount)) + } + written += n + if err != nil { + return written, err + } + } + + err = w.WriteByte(']') + written++ + if err != nil { + return written, err + } + + return written, nil } func validateLabels20(labels []*dto.LabelPair) error { diff --git a/expfmt/openmetrics_2_0_create_test.go b/expfmt/openmetrics_2_0_create_test.go index 6de60358..2b6cb9f1 100644 --- a/expfmt/openmetrics_2_0_create_test.go +++ b/expfmt/openmetrics_2_0_create_test.go @@ -262,6 +262,439 @@ http_requests_total 1027 }, out: `# TYPE "你好_total" counter {"你好_total","🌎"="🌍"} 1027 +`, + }, + { + name: "ClassicHistogram", + in: &dto.MetricFamily{ + Name: proto.String("request_duration_seconds"), + Help: proto.String("Request duration histogram."), + Type: dto.MetricType_HISTOGRAM.Enum(), + Unit: proto.String("seconds"), + Metric: []*dto.Metric{ + { + Label: []*dto.LabelPair{ + {Name: proto.String("handler"), Value: proto.String("query")}, + }, + Histogram: &dto.Histogram{ + SampleCount: proto.Uint64(3), + SampleSum: proto.Float64(6.0), + Bucket: []*dto.Bucket{ + {UpperBound: proto.Float64(0.1), CumulativeCount: proto.Uint64(1)}, + {UpperBound: proto.Float64(1.0), CumulativeCount: proto.Uint64(2)}, + {UpperBound: proto.Float64(math.Inf(+1)), CumulativeCount: proto.Uint64(3)}, + }, + }, + }, + }, + }, + out: `# HELP request_duration_seconds Request duration histogram. +# TYPE request_duration_seconds histogram +# UNIT request_duration_seconds seconds +request_duration_seconds{handler="query"} {count:3,sum:6,bucket:[0.1:1,1:2,+Inf:3]} +`, + }, + { + name: "ClassicHistogram_ImplicitPosInf", + in: &dto.MetricFamily{ + Name: proto.String("request_duration_seconds"), + Type: dto.MetricType_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + { + Histogram: &dto.Histogram{ + SampleCount: proto.Uint64(2), + SampleSum: proto.Float64(1.5), + Bucket: []*dto.Bucket{ + {UpperBound: proto.Float64(0.1), CumulativeCount: proto.Uint64(1)}, + {UpperBound: proto.Float64(1.0), CumulativeCount: proto.Uint64(2)}, + }, + }, + }, + }, + }, + out: `# TYPE request_duration_seconds histogram +request_duration_seconds {count:2,sum:1.5,bucket:[0.1:1,1:2,+Inf:2]} +`, + }, + { + name: "ClassicHistogram_NegativeThresholds", + in: &dto.MetricFamily{ + Name: proto.String("temperature_deviation"), + Type: dto.MetricType_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + { + Histogram: &dto.Histogram{ + SampleCount: proto.Uint64(10), + SampleSum: proto.Float64(15.0), + Bucket: []*dto.Bucket{ + {UpperBound: proto.Float64(math.Inf(-1)), CumulativeCount: proto.Uint64(0)}, + {UpperBound: proto.Float64(-1.0), CumulativeCount: proto.Uint64(2)}, + {UpperBound: proto.Float64(0.5), CumulativeCount: proto.Uint64(5)}, + {UpperBound: proto.Float64(math.Inf(+1)), CumulativeCount: proto.Uint64(10)}, + }, + }, + }, + }, + }, + out: `# TYPE temperature_deviation histogram +temperature_deviation {count:10,sum:15,bucket:[-Inf:0,-1:2,0.5:5,+Inf:10]} +`, + }, + { + name: "ClassicHistogram_FloatCounts", + in: &dto.MetricFamily{ + Name: proto.String("request_duration_seconds"), + Type: dto.MetricType_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + { + Histogram: &dto.Histogram{ + SampleCountFloat: proto.Float64(5.5), + SampleSum: proto.Float64(12.1), + Bucket: []*dto.Bucket{ + {UpperBound: proto.Float64(0.1), CumulativeCountFloat: proto.Float64(2.5)}, + {UpperBound: proto.Float64(1.0), CumulativeCountFloat: proto.Float64(5.5)}, + {UpperBound: proto.Float64(math.Inf(+1)), CumulativeCountFloat: proto.Float64(5.5)}, + }, + }, + }, + }, + }, + out: `# TYPE request_duration_seconds histogram +request_duration_seconds {count:5.5,sum:12.1,bucket:[0.1:2.5,1:5.5,+Inf:5.5]} +`, + }, + { + name: "ClassicHistogram_WithTimestampsAndExemplars", + in: &dto.MetricFamily{ + Name: proto.String("request_duration_seconds"), + Type: dto.MetricType_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + { + Histogram: &dto.Histogram{ + SampleCount: proto.Uint64(17), + SampleSum: proto.Float64(324789.3), + CreatedTimestamp: ×tamppb.Timestamp{Seconds: 1520879607, Nanos: 789000000}, + Bucket: []*dto.Bucket{ + {UpperBound: proto.Float64(0.01), CumulativeCount: proto.Uint64(0)}, + { + UpperBound: proto.Float64(0.1), + CumulativeCount: proto.Uint64(8), + Exemplar: &dto.Exemplar{ + Value: proto.Float64(0.054), + Timestamp: ×tamppb.Timestamp{Seconds: 1520879607, Nanos: 700000000}, + }, + }, + { + UpperBound: proto.Float64(1.0), + CumulativeCount: proto.Uint64(11), + Exemplar: &dto.Exemplar{ + Label: []*dto.LabelPair{ + {Name: proto.String("trace_id"), Value: proto.String("KOO5S4vxi0o")}, + }, + Value: proto.Float64(1.67), + Timestamp: ×tamppb.Timestamp{Seconds: 1520879602, Nanos: 890000000}, + }, + }, + { + UpperBound: proto.Float64(10.0), + CumulativeCount: proto.Uint64(17), + Exemplar: &dto.Exemplar{ + Label: []*dto.LabelPair{ + {Name: proto.String("trace_id"), Value: proto.String("oHg5SJYRHA0")}, + }, + Value: proto.Float64(9.8), + Timestamp: ×tamppb.Timestamp{Seconds: 1520879607, Nanos: 789000000}, + }, + }, + {UpperBound: proto.Float64(math.Inf(+1)), CumulativeCount: proto.Uint64(17)}, + }, + }, + TimestampMs: proto.Int64(1520879610000), + }, + }, + }, + out: `# TYPE request_duration_seconds histogram +request_duration_seconds {count:17,sum:324789.3,bucket:[0.01:0,0.1:8,1:11,10:17,+Inf:17]} 1520879610 st@1520879607.789 # {} 0.054 1520879607.7 # {trace_id="KOO5S4vxi0o"} 1.67 1520879602.89 # {trace_id="oHg5SJYRHA0"} 9.8 1520879607.789 +`, + }, + { + name: "NativeHistogram_PositiveSpans", + in: &dto.MetricFamily{ + Name: proto.String("latency_seconds"), + Help: proto.String("Service latency (native histogram)."), + Type: dto.MetricType_HISTOGRAM.Enum(), + Unit: proto.String("seconds"), + Metric: []*dto.Metric{ + { + Histogram: &dto.Histogram{ + SampleCount: proto.Uint64(5), + SampleSum: proto.Float64(12.1), + Schema: proto.Int32(0), + ZeroThreshold: proto.Float64(0.001), + ZeroCount: proto.Uint64(2), + PositiveSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(0), Length: proto.Uint32(3)}, + }, + PositiveDelta: []int64{1, 0, 0}, + }, + }, + }, + }, + out: `# HELP latency_seconds Service latency (native histogram). +# TYPE latency_seconds histogram +# UNIT latency_seconds seconds +latency_seconds {count:5,sum:12.1,schema:0,zero_threshold:0.001,zero_count:2,positive_spans:[0:3],positive_buckets:[1,1,1]} +`, + }, + { + name: "NativeHistogram_NegativeAndPositiveSpans_WithExemplars", + in: &dto.MetricFamily{ + Name: proto.String("acme_http_request_seconds"), + Type: dto.MetricType_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + { + Label: []*dto.LabelPair{ + {Name: proto.String("path"), Value: proto.String("/api/v1")}, + {Name: proto.String("method"), Value: proto.String("GET")}, + }, + Histogram: &dto.Histogram{ + SampleCount: proto.Uint64(59), + SampleSum: proto.Float64(120.0), + Schema: proto.Int32(7), + ZeroThreshold: proto.Float64(1e-4), + ZeroCount: proto.Uint64(0), + NegativeSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(1), Length: proto.Uint32(2)}, + }, + NegativeDelta: []int64{5, 2}, + PositiveSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(-1), Length: proto.Uint32(2)}, + {Offset: proto.Int32(3), Length: proto.Uint32(4)}, + }, + PositiveDelta: []int64{5, 2, 3, -1, -1, 0}, + Exemplars: []*dto.Exemplar{ + { + Label: []*dto.LabelPair{ + {Name: proto.String("trace_id"), Value: proto.String("shaZ8oxi")}, + }, + Value: proto.Float64(0.67), + Timestamp: ×tamppb.Timestamp{Seconds: 1520879607, Nanos: 789000000}, + }, + }, + }, + }, + }, + }, + out: `# TYPE acme_http_request_seconds histogram +acme_http_request_seconds{path="/api/v1",method="GET"} {count:59,sum:120,schema:7,zero_threshold:0.0001,zero_count:0,negative_spans:[1:2],negative_buckets:[5,7],positive_spans:[-1:2,3:4],positive_buckets:[5,7,10,9,8,8]} # {trace_id="shaZ8oxi"} 0.67 1520879607.789 +`, + }, + { + name: "NativeHistogram_ZeroObservations", + in: &dto.MetricFamily{ + Name: proto.String("acme_http_request_seconds"), + Type: dto.MetricType_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + { + Label: []*dto.LabelPair{ + {Name: proto.String("path"), Value: proto.String("/api/v1")}, + {Name: proto.String("method"), Value: proto.String("GET")}, + }, + Histogram: &dto.Histogram{ + SampleCount: proto.Uint64(0), + SampleSum: proto.Float64(0), + Schema: proto.Int32(3), + ZeroThreshold: proto.Float64(1e-4), + ZeroCount: proto.Uint64(0), + }, + }, + }, + }, + out: `# TYPE acme_http_request_seconds histogram +acme_http_request_seconds{path="/api/v1",method="GET"} {count:0,sum:0,schema:3,zero_threshold:0.0001,zero_count:0} +`, + }, + { + name: "NativeFloatHistogram", + in: &dto.MetricFamily{ + Name: proto.String("payload_size"), + Help: proto.String("Payload size (float native histogram)."), + Type: dto.MetricType_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + { + Histogram: &dto.Histogram{ + SampleCountFloat: proto.Float64(5.5), + SampleSum: proto.Float64(12.1), + Schema: proto.Int32(0), + ZeroThreshold: proto.Float64(0.001), + ZeroCountFloat: proto.Float64(2.5), + PositiveSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(0), Length: proto.Uint32(2)}, + }, + PositiveCount: []float64{2.0, 1.0}, + }, + }, + }, + }, + out: `# HELP payload_size Payload size (float native histogram). +# TYPE payload_size histogram +payload_size {count:5.5,sum:12.1,schema:0,zero_threshold:0.001,zero_count:2.5,positive_spans:[0:2],positive_buckets:[2,1]} +`, + }, + { + name: "DualHistogram_NativeAndClassic", + in: &dto.MetricFamily{ + Name: proto.String("acme_http_request_seconds"), + Help: proto.String("Latency histogram of all of ACME's HTTP requests."), + Type: dto.MetricType_HISTOGRAM.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")}, + }, + Histogram: &dto.Histogram{ + SampleCount: proto.Uint64(2), + SampleSum: proto.Float64(120.0), + Schema: proto.Int32(0), + ZeroThreshold: proto.Float64(1e-4), + ZeroCount: proto.Uint64(0), + PositiveSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(1), Length: proto.Uint32(2)}, + }, + PositiveDelta: []int64{1, 0}, + Bucket: []*dto.Bucket{ + {UpperBound: proto.Float64(0.5), CumulativeCount: proto.Uint64(1)}, + {UpperBound: proto.Float64(1.0), CumulativeCount: proto.Uint64(2)}, + {UpperBound: proto.Float64(math.Inf(+1)), CumulativeCount: proto.Uint64(2)}, + }, + }, + }, + }, + }, + out: `# HELP acme_http_request_seconds Latency histogram of all of ACME's HTTP requests. +# TYPE acme_http_request_seconds histogram +# UNIT acme_http_request_seconds seconds +acme_http_request_seconds{path="/api/v1",method="GET"} {count:2,sum:120,schema:0,zero_threshold:0.0001,zero_count:0,positive_spans:[1:2],positive_buckets:[1,1],bucket:[0.5:1,1:2,+Inf:2]} +`, + }, + { + name: "GaugeHistogram_Classic", + in: &dto.MetricFamily{ + Name: proto.String("foo"), + Type: dto.MetricType_GAUGE_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + { + Histogram: &dto.Histogram{ + SampleCount: proto.Uint64(42), + SampleSum: proto.Float64(3289.3), + Bucket: []*dto.Bucket{ + {UpperBound: proto.Float64(0.01), CumulativeCount: proto.Uint64(20)}, + {UpperBound: proto.Float64(0.1), CumulativeCount: proto.Uint64(25)}, + {UpperBound: proto.Float64(1.0), CumulativeCount: proto.Uint64(34)}, + {UpperBound: proto.Float64(math.Inf(+1)), CumulativeCount: proto.Uint64(42)}, + }, + }, + }, + }, + }, + out: `# TYPE foo gaugehistogram +foo {gcount:42,gsum:3289.3,bucket:[0.01:20,0.1:25,1:34,+Inf:42]} +`, + }, + { + name: "GaugeHistogram_NativeFloat", + in: &dto.MetricFamily{ + Name: proto.String("acme_http_request_seconds:rate5m"), + Type: dto.MetricType_GAUGE_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + { + Label: []*dto.LabelPair{ + {Name: proto.String("path"), Value: proto.String("/api/v1")}, + {Name: proto.String("method"), Value: proto.String("GET")}, + }, + Histogram: &dto.Histogram{ + SampleCountFloat: proto.Float64(0.01), + SampleSum: proto.Float64(2.0), + Schema: proto.Int32(0), + ZeroThreshold: proto.Float64(1e-4), + ZeroCountFloat: proto.Float64(0.0), + PositiveSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(1), Length: proto.Uint32(2)}, + }, + PositiveCount: []float64{0.005, 0.005}, + }, + }, + }, + }, + out: `# TYPE acme_http_request_seconds:rate5m gaugehistogram +acme_http_request_seconds:rate5m{path="/api/v1",method="GET"} {gcount:0.01,gsum:2,schema:0,zero_threshold:0.0001,zero_count:0,positive_spans:[1:2],positive_buckets:[0.005,0.005]} +`, + }, + { + name: "GaugeHistogram_NegativeValues", + in: &dto.MetricFamily{ + Name: proto.String("net_flow_rate"), + Type: dto.MetricType_GAUGE_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + { + Histogram: &dto.Histogram{ + SampleCountFloat: proto.Float64(-1.5), + SampleSum: proto.Float64(-3.2), + Bucket: []*dto.Bucket{ + {UpperBound: proto.Float64(-0.5), CumulativeCountFloat: proto.Float64(-2.0)}, + {UpperBound: proto.Float64(math.Inf(+1)), CumulativeCountFloat: proto.Float64(-1.5)}, + }, + }, + }, + }, + }, + out: `# TYPE net_flow_rate gaugehistogram +net_flow_rate {gcount:-1.5,gsum:-3.2,bucket:[-0.5:-2,+Inf:-1.5]} +`, + }, + { + name: "ClassicHistogram_EmptyBuckets", + in: &dto.MetricFamily{ + Name: proto.String("empty_histogram"), + Type: dto.MetricType_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + { + Histogram: &dto.Histogram{ + SampleCount: proto.Uint64(0), + SampleSum: proto.Float64(0), + }, + }, + }, + }, + out: `# TYPE empty_histogram histogram +empty_histogram {count:0,sum:0,bucket:[+Inf:0]} +`, + }, + { + name: "Histogram_UTF8", + in: &dto.MetricFamily{ + Name: proto.String("http.latency"), + Type: dto.MetricType_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + { + Label: []*dto.LabelPair{ + {Name: proto.String("service.name"), Value: proto.String("my-service")}, + }, + Histogram: &dto.Histogram{ + SampleCount: proto.Uint64(1), + SampleSum: proto.Float64(0.05), + Bucket: []*dto.Bucket{ + {UpperBound: proto.Float64(0.1), CumulativeCount: proto.Uint64(1)}, + {UpperBound: proto.Float64(math.Inf(+1)), CumulativeCount: proto.Uint64(1)}, + }, + }, + }, + }, + }, + out: `# TYPE "http.latency" histogram +{"http.latency","service.name"="my-service"} {count:1,sum:0.05,bucket:[0.1:1,+Inf:1]} `, }, } @@ -400,26 +833,464 @@ func TestCreateOpenMetrics20_Errors(t *testing.T) { expectedErr: "summary not implemented yet", }, { - name: "HistogramNotImplemented", + name: "HistogramCountNegative", in: &dto.MetricFamily{ - Name: proto.String("test_metric"), + Name: proto.String("test_histogram"), Type: dto.MetricType_HISTOGRAM.Enum(), Metric: []*dto.Metric{ - {Histogram: &dto.Histogram{}}, + { + Histogram: &dto.Histogram{ + SampleCountFloat: proto.Float64(-1.0), + SampleSum: proto.Float64(0.0), + }, + }, }, }, - expectedErr: "histogram not implemented yet", + expectedErr: "histogram count cannot be negative", }, { - name: "GaugeHistogramNotImplemented", + name: "HistogramCountNaN", in: &dto.MetricFamily{ - Name: proto.String("test_metric"), + Name: proto.String("test_histogram"), + Type: dto.MetricType_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + { + Histogram: &dto.Histogram{ + SampleCountFloat: proto.Float64(math.NaN()), + SampleSum: proto.Float64(0.0), + }, + }, + }, + }, + expectedErr: "histogram count cannot be NaN", + }, + { + name: "GaugeHistogramCountNaN", + in: &dto.MetricFamily{ + Name: proto.String("test_gauge_histogram"), + Type: dto.MetricType_GAUGE_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + { + Histogram: &dto.Histogram{ + SampleCountFloat: proto.Float64(math.NaN()), + SampleSum: proto.Float64(0.0), + }, + }, + }, + }, + expectedErr: "gaugehistogram count cannot be NaN", + }, + { + name: "MetricHasLeLabel_ClassicHistogram", + in: &dto.MetricFamily{ + Name: proto.String("test_histogram"), + Type: dto.MetricType_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + { + Label: []*dto.LabelPair{ + {Name: proto.String("le"), Value: proto.String("0.1")}, + }, + Histogram: &dto.Histogram{ + SampleCount: proto.Uint64(1), + SampleSum: proto.Float64(0.1), + Bucket: []*dto.Bucket{ + {UpperBound: proto.Float64(0.1), CumulativeCount: proto.Uint64(1)}, + {UpperBound: proto.Float64(math.Inf(+1)), CumulativeCount: proto.Uint64(1)}, + }, + }, + }, + }, + }, + expectedErr: "has classic buckets but label set contains \"le\" label", + }, + { + name: "MetricHasLeLabel_ClassicGaugeHistogram", + in: &dto.MetricFamily{ + Name: proto.String("test_gauge_histogram"), Type: dto.MetricType_GAUGE_HISTOGRAM.Enum(), Metric: []*dto.Metric{ - {Histogram: &dto.Histogram{}}, + { + Label: []*dto.LabelPair{ + {Name: proto.String("le"), Value: proto.String("0.1")}, + }, + Histogram: &dto.Histogram{ + SampleCount: proto.Uint64(1), + SampleSum: proto.Float64(0.1), + Bucket: []*dto.Bucket{ + {UpperBound: proto.Float64(0.1), CumulativeCount: proto.Uint64(1)}, + {UpperBound: proto.Float64(math.Inf(+1)), CumulativeCount: proto.Uint64(1)}, + }, + }, + }, + }, + }, + expectedErr: "has classic buckets but label set contains \"le\" label", + }, + { + name: "ClassicBucketThresholdNaN", + in: &dto.MetricFamily{ + Name: proto.String("test_histogram"), + Type: dto.MetricType_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + { + Histogram: &dto.Histogram{ + SampleCount: proto.Uint64(1), + SampleSum: proto.Float64(0.1), + Bucket: []*dto.Bucket{ + {UpperBound: proto.Float64(math.NaN()), CumulativeCount: proto.Uint64(1)}, + }, + }, + }, + }, + }, + expectedErr: "classic bucket upper bound cannot be NaN", + }, + { + name: "ClassicBucketThresholdsUnsorted", + in: &dto.MetricFamily{ + Name: proto.String("test_histogram"), + Type: dto.MetricType_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + { + Histogram: &dto.Histogram{ + SampleCount: proto.Uint64(2), + SampleSum: proto.Float64(0.3), + Bucket: []*dto.Bucket{ + {UpperBound: proto.Float64(1.0), CumulativeCount: proto.Uint64(1)}, + {UpperBound: proto.Float64(0.5), CumulativeCount: proto.Uint64(2)}, + {UpperBound: proto.Float64(math.Inf(+1)), CumulativeCount: proto.Uint64(2)}, + }, + }, + }, + }, + }, + expectedErr: "classic bucket upper bounds must be strictly increasing", + }, + { + name: "ClassicBucketThresholdsDuplicate", + in: &dto.MetricFamily{ + Name: proto.String("test_histogram"), + Type: dto.MetricType_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + { + Histogram: &dto.Histogram{ + SampleCount: proto.Uint64(2), + SampleSum: proto.Float64(0.3), + Bucket: []*dto.Bucket{ + {UpperBound: proto.Float64(1.0), CumulativeCount: proto.Uint64(1)}, + {UpperBound: proto.Float64(1.0), CumulativeCount: proto.Uint64(2)}, + {UpperBound: proto.Float64(math.Inf(+1)), CumulativeCount: proto.Uint64(2)}, + }, + }, + }, + }, + }, + expectedErr: "classic bucket upper bounds must be strictly increasing", + }, + { + name: "ClassicBucketPosInfMismatch", + in: &dto.MetricFamily{ + Name: proto.String("test_histogram"), + Type: dto.MetricType_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + { + Histogram: &dto.Histogram{ + SampleCount: proto.Uint64(5), + SampleSum: proto.Float64(10.0), + Bucket: []*dto.Bucket{ + {UpperBound: proto.Float64(1.0), CumulativeCount: proto.Uint64(2)}, + {UpperBound: proto.Float64(math.Inf(+1)), CumulativeCount: proto.Uint64(4)}, + }, + }, + }, + }, + }, + expectedErr: "classic bucket +Inf count (4) does not match sample count (5)", + }, + { + name: "ClassicBucketCountNaN", + in: &dto.MetricFamily{ + Name: proto.String("test_histogram"), + Type: dto.MetricType_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + { + Histogram: &dto.Histogram{ + SampleCountFloat: proto.Float64(1.0), + SampleSum: proto.Float64(0.1), + Bucket: []*dto.Bucket{ + {UpperBound: proto.Float64(0.1), CumulativeCountFloat: proto.Float64(math.NaN())}, + {UpperBound: proto.Float64(math.Inf(+1)), CumulativeCountFloat: proto.Float64(1.0)}, + }, + }, + }, + }, + }, + expectedErr: "classic bucket count cannot be NaN", + }, + { + name: "ClassicBucketCountNegative", + in: &dto.MetricFamily{ + Name: proto.String("test_histogram"), + Type: dto.MetricType_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + { + Histogram: &dto.Histogram{ + SampleCountFloat: proto.Float64(1.0), + SampleSum: proto.Float64(0.1), + Bucket: []*dto.Bucket{ + {UpperBound: proto.Float64(0.1), CumulativeCountFloat: proto.Float64(-1.0)}, + {UpperBound: proto.Float64(math.Inf(+1)), CumulativeCountFloat: proto.Float64(1.0)}, + }, + }, + }, + }, + }, + expectedErr: "classic bucket count cannot be negative", + }, + { + name: "NativeHistogramSchemaOutOfRange_Low", + in: &dto.MetricFamily{ + Name: proto.String("test_histogram"), + Type: dto.MetricType_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + { + Histogram: &dto.Histogram{ + SampleCount: proto.Uint64(0), + SampleSum: proto.Float64(0), + Schema: proto.Int32(-5), + ZeroThreshold: proto.Float64(0.001), + ZeroCount: proto.Uint64(0), + }, + }, + }, + }, + expectedErr: "native histogram schema -5 is out of range [-4, 8]", + }, + { + name: "NativeHistogramSchemaOutOfRange_High", + in: &dto.MetricFamily{ + Name: proto.String("test_histogram"), + Type: dto.MetricType_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + { + Histogram: &dto.Histogram{ + SampleCount: proto.Uint64(0), + SampleSum: proto.Float64(0), + Schema: proto.Int32(9), + ZeroThreshold: proto.Float64(0.001), + ZeroCount: proto.Uint64(0), + }, + }, + }, + }, + expectedErr: "native histogram schema 9 is out of range [-4, 8]", + }, + { + name: "NativeHistogramZeroThresholdNegative", + in: &dto.MetricFamily{ + Name: proto.String("test_histogram"), + Type: dto.MetricType_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + { + Histogram: &dto.Histogram{ + SampleCount: proto.Uint64(0), + SampleSum: proto.Float64(0), + Schema: proto.Int32(0), + ZeroThreshold: proto.Float64(-0.001), + ZeroCount: proto.Uint64(0), + }, + }, + }, + }, + expectedErr: "native histogram zero_threshold -0.001 must be a non-negative, finite number", + }, + { + name: "NativeHistogramZeroThresholdNaN", + in: &dto.MetricFamily{ + Name: proto.String("test_histogram"), + Type: dto.MetricType_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + { + Histogram: &dto.Histogram{ + SampleCount: proto.Uint64(0), + SampleSum: proto.Float64(0), + Schema: proto.Int32(0), + ZeroThreshold: proto.Float64(math.NaN()), + ZeroCount: proto.Uint64(0), + }, + }, + }, + }, + expectedErr: "native histogram zero_threshold NaN must be a non-negative, finite number", + }, + { + name: "NativeHistogramZeroThresholdInf", + in: &dto.MetricFamily{ + Name: proto.String("test_histogram"), + Type: dto.MetricType_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + { + Histogram: &dto.Histogram{ + SampleCount: proto.Uint64(0), + SampleSum: proto.Float64(0), + Schema: proto.Int32(0), + ZeroThreshold: proto.Float64(math.Inf(+1)), + ZeroCount: proto.Uint64(0), + }, + }, + }, + }, + expectedErr: "native histogram zero_threshold +Inf must be a non-negative, finite number", + }, + { + name: "NativeHistogramZeroCountNaN", + in: &dto.MetricFamily{ + Name: proto.String("test_histogram"), + Type: dto.MetricType_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + { + Histogram: &dto.Histogram{ + SampleCountFloat: proto.Float64(0), + SampleSum: proto.Float64(0), + Schema: proto.Int32(0), + ZeroThreshold: proto.Float64(0.001), + ZeroCountFloat: proto.Float64(math.NaN()), + }, + }, + }, + }, + expectedErr: "native histogram zero_count cannot be NaN", + }, + { + name: "NativeHistogramZeroCountNegative", + in: &dto.MetricFamily{ + Name: proto.String("test_histogram"), + Type: dto.MetricType_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + { + Histogram: &dto.Histogram{ + SampleCountFloat: proto.Float64(0), + SampleSum: proto.Float64(0), + Schema: proto.Int32(0), + ZeroThreshold: proto.Float64(0.001), + ZeroCountFloat: proto.Float64(-1.0), + }, + }, + }, + }, + expectedErr: "native histogram zero_count cannot be negative", + }, + { + name: "NativeHistogramSubsequentSpanNegativeOffset", + in: &dto.MetricFamily{ + Name: proto.String("test_histogram"), + Type: dto.MetricType_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + { + Histogram: &dto.Histogram{ + SampleCount: proto.Uint64(2), + SampleSum: proto.Float64(1.0), + Schema: proto.Int32(0), + ZeroThreshold: proto.Float64(0.001), + ZeroCount: proto.Uint64(0), + PositiveSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(0), Length: proto.Uint32(1)}, + {Offset: proto.Int32(-1), Length: proto.Uint32(1)}, + }, + PositiveDelta: []int64{1, 0}, + }, + }, + }, + }, + expectedErr: "subsequent positive span offset cannot be negative: -1", + }, + { + name: "NativeHistogramSpanLengthMismatch", + in: &dto.MetricFamily{ + Name: proto.String("test_histogram"), + Type: dto.MetricType_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + { + Histogram: &dto.Histogram{ + SampleCount: proto.Uint64(2), + SampleSum: proto.Float64(1.0), + Schema: proto.Int32(0), + ZeroThreshold: proto.Float64(0.001), + ZeroCount: proto.Uint64(0), + PositiveSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(0), Length: proto.Uint32(3)}, + }, + PositiveDelta: []int64{1, 0}, + }, + }, + }, + }, + expectedErr: "sum of positive span lengths (3) does not match bucket count (2)", + }, + { + name: "NativeHistogramBucketCountNaN", + in: &dto.MetricFamily{ + Name: proto.String("test_histogram"), + Type: dto.MetricType_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + { + Histogram: &dto.Histogram{ + SampleCountFloat: proto.Float64(1.0), + SampleSum: proto.Float64(1.0), + Schema: proto.Int32(0), + ZeroThreshold: proto.Float64(0.001), + ZeroCountFloat: proto.Float64(0), + PositiveSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(0), Length: proto.Uint32(1)}, + }, + PositiveCount: []float64{math.NaN()}, + }, + }, + }, + }, + expectedErr: "positive bucket count cannot be NaN", + }, + { + name: "NativeHistogramBucketCountNegative", + in: &dto.MetricFamily{ + Name: proto.String("test_histogram"), + Type: dto.MetricType_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + { + Histogram: &dto.Histogram{ + SampleCount: proto.Uint64(1), + SampleSum: proto.Float64(1.0), + Schema: proto.Int32(0), + ZeroThreshold: proto.Float64(0.001), + ZeroCount: proto.Uint64(0), + PositiveSpan: []*dto.BucketSpan{ + {Offset: proto.Int32(0), Length: proto.Uint32(1)}, + }, + PositiveDelta: []int64{-1}, + }, + }, + }, + }, + expectedErr: "positive bucket count cannot be negative (-1)", + }, + { + name: "HistogramInvalidCreatedTimestamp", + in: &dto.MetricFamily{ + Name: proto.String("test_histogram"), + Type: dto.MetricType_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + { + Histogram: &dto.Histogram{ + SampleCount: proto.Uint64(0), + SampleSum: proto.Float64(0), + CreatedTimestamp: ×tamppb.Timestamp{ + Nanos: -1, + }, + }, + }, }, }, - expectedErr: "histogram not implemented yet", + expectedErr: "invalid created timestamp in metric test_histogram", }, { name: "CounterValueNaN", From 8516cb0839e1302fd74586226a3ea51d9db04200 Mon Sep 17 00:00:00 2001 From: David Ashpole Date: Fri, 21 Aug 2026 18:14:29 +0000 Subject: [PATCH 2/3] expfmt: preserve uint64 precision for implicit +Inf bucket in OM 2.0 Signed-off-by: David Ashpole --- expfmt/openmetrics_2_0_create.go | 17 +++++++++++++---- expfmt/openmetrics_2_0_create_test.go | 21 +++++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/expfmt/openmetrics_2_0_create.go b/expfmt/openmetrics_2_0_create.go index 03b83b32..fda30b83 100644 --- a/expfmt/openmetrics_2_0_create.go +++ b/expfmt/openmetrics_2_0_create.go @@ -454,7 +454,7 @@ func writeCompositeHistogram(w enhancedWriter, name string, metric *dto.Metric, var classicExemplars []*dto.Exemplar if hasClassicBuckets { - n, err = writeClassicBuckets(w, name, h, sampleCountFloat, isFloatCount, isGauge, &classicExemplars) + n, err = writeClassicBuckets(w, name, h, sampleCountFloat, sampleCountUint, isFloatCount, isGauge, &classicExemplars) written += n if err != nil { return written, err @@ -752,6 +752,7 @@ func writeClassicBuckets( name string, h *dto.Histogram, sampleCount float64, + sampleCountUint uint64, isFloatCount bool, isGauge bool, collectedExemplars *[]*dto.Exemplar, @@ -784,8 +785,16 @@ func writeClassicBuckets( return 0, fmt.Errorf("+Inf bucket must be the last bucket in metric %s", name) } infSeen = true - if bCount != sampleCount { - return 0, fmt.Errorf("classic bucket +Inf count (%g) does not match sample count (%g) in metric %s", bCount, sampleCount, name) + if isFloatCount { + if bCount != sampleCount { + return 0, fmt.Errorf("classic bucket +Inf count (%g) does not match sample count (%g) in metric %s", bCount, sampleCount, name) + } + } else { + if b.CumulativeCount != nil && *b.CumulativeCount != sampleCountUint { + return 0, fmt.Errorf("classic bucket +Inf count (%d) does not match sample count (%d) in metric %s", *b.CumulativeCount, sampleCountUint, name) + } else if b.CumulativeCount == nil && bCount != sampleCount { + return 0, fmt.Errorf("classic bucket +Inf count (%g) does not match sample count (%g) in metric %s", bCount, sampleCount, name) + } } } @@ -858,7 +867,7 @@ func writeClassicBuckets( if isFloatCount { n, err = writeFloat(w, sampleCount) } else { - n, err = writeUint(w, uint64(sampleCount)) + n, err = writeUint(w, sampleCountUint) } written += n if err != nil { diff --git a/expfmt/openmetrics_2_0_create_test.go b/expfmt/openmetrics_2_0_create_test.go index 2b6cb9f1..eda874a2 100644 --- a/expfmt/openmetrics_2_0_create_test.go +++ b/expfmt/openmetrics_2_0_create_test.go @@ -314,6 +314,27 @@ request_duration_seconds{handler="query"} {count:3,sum:6,bucket:[0.1:1,1:2,+Inf: }, out: `# TYPE request_duration_seconds histogram request_duration_seconds {count:2,sum:1.5,bucket:[0.1:1,1:2,+Inf:2]} +`, + }, + { + name: "ClassicHistogram_LargeCount_ImplicitPosInf", + in: &dto.MetricFamily{ + Name: proto.String("request_duration_seconds"), + Type: dto.MetricType_HISTOGRAM.Enum(), + Metric: []*dto.Metric{ + { + Histogram: &dto.Histogram{ + SampleCount: proto.Uint64(9007199254740993), + SampleSum: proto.Float64(1.5), + Bucket: []*dto.Bucket{ + {UpperBound: proto.Float64(0.1), CumulativeCount: proto.Uint64(1)}, + }, + }, + }, + }, + }, + out: `# TYPE request_duration_seconds histogram +request_duration_seconds {count:9007199254740993,sum:1.5,bucket:[0.1:1,+Inf:9007199254740993]} `, }, { From 3266aa8638fb4ff074cdeb1202fab8bae4d969bf Mon Sep 17 00:00:00 2001 From: David Ashpole Date: Fri, 21 Aug 2026 18:30:51 +0000 Subject: [PATCH 3/3] expfmt: add test for counter exemplar with empty label set in OM 2.0 Signed-off-by: David Ashpole --- expfmt/openmetrics_2_0_create_test.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/expfmt/openmetrics_2_0_create_test.go b/expfmt/openmetrics_2_0_create_test.go index eda874a2..9df96e2c 100644 --- a/expfmt/openmetrics_2_0_create_test.go +++ b/expfmt/openmetrics_2_0_create_test.go @@ -161,6 +161,27 @@ node_memory_active_bytes 1.2345e+09 1234567890 }, out: `# TYPE http_requests_total counter http_requests_total 1027 1234567891 st@1234567890 # {trace_id="1234"} 1 1234567890.5 +`, + }, + { + name: "CounterWithExemplarWithoutLabels", + 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{ + Value: proto.Float64(1), + Timestamp: ×tamppb.Timestamp{Seconds: 1234567890, Nanos: 500000000}, + }, + }, + }, + }, + }, + out: `# TYPE http_requests_total counter +http_requests_total 1027 # {} 1 1234567890.5 `, }, {