refactor(histogram): Converting to Absolute values and fixing the test

Signed-off-by: Naman-B-Parlecha <namanparlecha@gmail.com>
This commit is contained in:
Naman-B-Parlecha 2025-09-25 03:42:23 +05:30
parent 5eeba3638d
commit 73904b4c75
2 changed files with 98 additions and 74 deletions

View File

@ -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]))

View File

@ -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)
}
})
}