diff --git a/model/histogram/convert.go b/model/histogram/convert.go index 6eb50a1de7..847baea494 100644 --- a/model/histogram/convert.go +++ b/model/histogram/convert.go @@ -21,12 +21,14 @@ import ( "github.com/prometheus/prometheus/model/labels" ) +// BucketEmitter is a callback function type for emitting histogram bucket series. +// Used in remote write to append converted bucket time series. type BucketEmitter func(labels labels.Labels, value float64) error // ConvertNHCBToClassicHistogram converts Native Histogram Custom Buckets (NHCB) to classic histogram series. // This conversion is needed in various scenarios where users need to get NHCB back to classic histogram format, // such as Remote Write v1 for external system compatibility and migration use cases. -func ConvertNHCBToClassicHistogram(nhcb interface{}, labels labels.Labels, lblBuilder *labels.Builder, bucketSeries BucketEmitter) error { +func ConvertNHCBToClassicHistogram(nhcb any, labels labels.Labels, lblBuilder *labels.Builder, bucketSeries BucketEmitter) error { baseName := labels.Get("__name__") if baseName == "" { return errors.New("metric name label '__name__' is missing") @@ -46,7 +48,11 @@ func ConvertNHCBToClassicHistogram(nhcb interface{}, labels labels.Labels, lblBu customValues = h.CustomValues positiveBuckets = make([]float64, len(h.PositiveBuckets)) for i, v := range h.PositiveBuckets { - positiveBuckets[i] = float64(v) + if i == 0 { + positiveBuckets[i] = float64(v) + } else { + positiveBuckets[i] = float64(v) + positiveBuckets[i-1] + } } count = float64(h.Count) sum = h.Sum @@ -59,13 +65,16 @@ func ConvertNHCBToClassicHistogram(nhcb interface{}, labels labels.Labels, lblBu return errors.New("unsupported histogram type") } + // Each customValue corresponds to a positive bucket (aligned with the "le" label). + // The lengths of customValues and positiveBuckets must match to avoid inconsistencies + // while mapping bucket counts to their upper bounds. if len(customValues) != len(positiveBuckets) { return errors.New("mismatched lengths of custom values and positive buckets") } currCount := float64(0) for i := range customValues { - currCount += positiveBuckets[i] + currCount = positiveBuckets[i] lblBuilder.Reset(labels) lblBuilder.Set("__name__", baseName+"_bucket") lblBuilder.Set("le", fmt.Sprintf("%g", customValues[i])) diff --git a/model/histogram/convert_test.go b/model/histogram/convert_test.go index 4c7787c884..bbfc6ad193 100644 --- a/model/histogram/convert_test.go +++ b/model/histogram/convert_test.go @@ -14,57 +14,71 @@ package histogram import ( + "errors" "testing" "github.com/prometheus/prometheus/model/labels" + "github.com/stretchr/testify/require" ) +type BucketExpectation struct { + le string + val float64 +} + +type ExpectedHistogram struct { + buckets []BucketExpectation + count float64 + sum float64 +} + func TestConvertNHCBToClassicHistogram(t *testing.T) { tests := []struct { - name string - nhcb interface{} - labels labels.Labels - expectErr bool - expectedLabels []labels.Labels - expectedValues []float64 + name string + nhcb any + labels labels.Labels + expectErr bool + expected ExpectedHistogram }{ { name: "Valid Histogram", nhcb: &Histogram{ CustomValues: []float64{1, 2, 3}, - PositiveBuckets: []int64{10, 20, 30}, + PositiveBuckets: []int64{10, 20, 30}, // Delta format: {10, 20, 30} -> Absolute: {10, 30, 60} Count: 60, Sum: 100.0, }, labels: labels.FromStrings("__name__", "test_metric"), - expectedLabels: []labels.Labels{ - labels.FromStrings("__name__", "test_metric_bucket", "le", "1"), - labels.FromStrings("__name__", "test_metric_bucket", "le", "2"), - labels.FromStrings("__name__", "test_metric_bucket", "le", "3"), - labels.FromStrings("__name__", "test_metric_bucket", "le", "+Inf"), - labels.FromStrings("__name__", "test_metric_count"), - labels.FromStrings("__name__", "test_metric_sum"), + expected: ExpectedHistogram{ + buckets: []BucketExpectation{ + {le: "1", val: 10}, + {le: "2", val: 30}, + {le: "3", val: 60}, + {le: "+Inf", val: 60}, + }, + count: 60, + sum: 100, }, - expectedValues: []float64{10, 30, 60, 60, 60, 100}, }, { name: "Valid FloatHistogram", nhcb: &FloatHistogram{ CustomValues: []float64{1, 2, 3}, PositiveBuckets: []float64{20.0, 40.0, 60.0}, - Count: 120.0, + Count: 60.0, Sum: 100.0, }, labels: labels.FromStrings("__name__", "test_metric"), - expectedLabels: []labels.Labels{ - labels.FromStrings("__name__", "test_metric_bucket", "le", "1"), - labels.FromStrings("__name__", "test_metric_bucket", "le", "2"), - labels.FromStrings("__name__", "test_metric_bucket", "le", "3"), - labels.FromStrings("__name__", "test_metric_bucket", "le", "+Inf"), - labels.FromStrings("__name__", "test_metric_count"), - labels.FromStrings("__name__", "test_metric_sum"), + expected: ExpectedHistogram{ + buckets: []BucketExpectation{ + {le: "1", val: 20}, + {le: "2", val: 40}, + {le: "3", val: 60}, + {le: "+Inf", val: 60}, + }, + count: 60, + sum: 100, }, - expectedValues: []float64{20, 60, 120, 120, 120, 100}, }, { name: "Empty Histogram", @@ -75,18 +89,19 @@ func TestConvertNHCBToClassicHistogram(t *testing.T) { Sum: 0.0, }, labels: labels.FromStrings("__name__", "test_metric"), - expectedLabels: []labels.Labels{ - labels.FromStrings("__name__", "test_metric_bucket", "le", "+Inf"), - labels.FromStrings("__name__", "test_metric_count"), - labels.FromStrings("__name__", "test_metric_sum"), + expected: ExpectedHistogram{ + buckets: []BucketExpectation{ + {le: "+Inf", val: 0}, + }, + count: 0, + sum: 0, }, - expectedValues: []float64{0, 0, 0}, }, { name: "Missing __name__ label", nhcb: &Histogram{ CustomValues: []float64{1, 2, 3}, - PositiveBuckets: []int64{10, 20, 30}, + PositiveBuckets: []int64{10, 20, 30}, // Delta format: {10, 20, 30} -> Absolute: {10, 30, 60} Count: 60, Sum: 100.0, }, @@ -103,26 +118,27 @@ func TestConvertNHCBToClassicHistogram(t *testing.T) { name: "Histogram with zero bucket counts", nhcb: &Histogram{ CustomValues: []float64{1, 2, 3}, - PositiveBuckets: []int64{0, 10, 0}, + PositiveBuckets: []int64{0, 10, 0}, // Delta format: {0, 10, 0} -> Absolute: {0, 10, 10} Count: 10, Sum: 50.0, }, labels: labels.FromStrings("__name__", "test_metric"), - expectedLabels: []labels.Labels{ - labels.FromStrings("__name__", "test_metric_bucket", "le", "1"), - labels.FromStrings("__name__", "test_metric_bucket", "le", "2"), - labels.FromStrings("__name__", "test_metric_bucket", "le", "3"), - labels.FromStrings("__name__", "test_metric_bucket", "le", "+Inf"), - labels.FromStrings("__name__", "test_metric_count"), - labels.FromStrings("__name__", "test_metric_sum"), + expected: ExpectedHistogram{ + buckets: []BucketExpectation{ + {le: "1", val: 0}, + {le: "2", val: 10}, + {le: "3", val: 10}, + {le: "+Inf", val: 10}, + }, + count: 10, + sum: 50, }, - expectedValues: []float64{0, 10, 10, 10, 10, 50}, }, { name: "Mismatched bucket lengths", nhcb: &Histogram{ CustomValues: []float64{1, 2}, - PositiveBuckets: []int64{10, 20, 30}, + PositiveBuckets: []int64{10, 20, 30}, // Mismatched lengths: 2 vs 3 Count: 60, Sum: 100.0, }, @@ -133,51 +149,50 @@ func TestConvertNHCBToClassicHistogram(t *testing.T) { name: "single series Histogram", nhcb: &Histogram{ CustomValues: []float64{1}, - PositiveBuckets: []int64{10}, + PositiveBuckets: []int64{10}, // Delta format: {10} -> Absolute: {10} Count: 10, Sum: 20.0, }, labels: labels.FromStrings("__name__", "test_metric"), - expectedLabels: []labels.Labels{ - labels.FromStrings("__name__", "test_metric_bucket", "le", "1"), - labels.FromStrings("__name__", "test_metric_bucket", "le", "+Inf"), - labels.FromStrings("__name__", "test_metric_count"), - labels.FromStrings("__name__", "test_metric_sum"), + expected: ExpectedHistogram{ + buckets: []BucketExpectation{ + {le: "1", val: 10}, + {le: "+Inf", val: 10}, + }, + count: 10, + sum: 20, }, - expectedValues: []float64{10, 10, 10, 20}, }, } + labelBuilder := labels.NewBuilder(labels.EmptyLabels()) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - var actualLabels []labels.Labels - var actualValues []float64 - - err := ConvertNHCBToClassicHistogram(tt.nhcb, tt.labels, &labels.Builder{}, func(lbls labels.Labels, value float64) error { - actualLabels = append(actualLabels, lbls) - actualValues = append(actualValues, value) + var got ExpectedHistogram + err := ConvertNHCBToClassicHistogram(tt.nhcb, tt.labels, labelBuilder, func(lbls labels.Labels, val float64) error { + switch lbls.Get("__name__") { + case tt.labels.Get("__name__") + "_bucket": + got.buckets = append(got.buckets, BucketExpectation{ + le: lbls.Get("le"), + val: val, + }) + case tt.labels.Get("__name__") + "_count": + got.count = val + case tt.labels.Get("__name__") + "_sum": + got.sum = val + default: + return errors.New("unexpected metric name") + } return nil }) - - if (err != nil) != tt.expectErr { - t.Errorf("ConvertNHCBToClassicHistogram() error = %v, expectErr %v", err, tt.expectErr) - return - } - + require.Equal(t, tt.expectErr, err != nil, "unexpected error: %v", err) if !tt.expectErr { - if len(actualLabels) != len(tt.expectedLabels) { - t.Errorf("Expected %d emissions, got %d", len(tt.expectedLabels), len(actualLabels)) - return - } - - for i, expectedLabel := range tt.expectedLabels { - if !labels.Equal(actualLabels[i], expectedLabel) { - t.Errorf("Expected label[%d] = %v, got %v", i, expectedLabel, actualLabels[i]) - } - if actualValues[i] != tt.expectedValues[i] { - t.Errorf("Expected value[%d] = %f, got %f", i, tt.expectedValues[i], actualValues[i]) - } + require.Equal(t, len(tt.expected.buckets), len(got.buckets)) + for i, expBucket := range tt.expected.buckets { + require.Equal(t, expBucket, got.buckets[i]) } + require.Equal(t, tt.expected.count, got.count) + require.Equal(t, tt.expected.sum, got.sum) } }) }