promql: fix histogram_fraction issue when lower falls within the first bucket (#17424)

Signed-off-by: Mohammad Alavi <m.alavi1986@gmail.com>
This commit is contained in:
Mohammad Alavi 2025-11-13 17:17:51 +07:00 committed by GitHub
parent 49254f45e9
commit 0f5f1955e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 410 additions and 1 deletions

View File

@ -158,6 +158,383 @@ eval instant at 50m histogram_fraction(0, 0.2, rate(testhistogram3_bucket[10m]))
{start="positive"} 0.6363636363636364
{start="negative"} 0
# Positive buckets, lower falls in the first bucket.
load_with_nhcb 5m
positive_buckets_lower_falls_in_the_first_bucket_bucket{le="1"} 1+0x10
positive_buckets_lower_falls_in_the_first_bucket_bucket{le="2"} 3+0x10
positive_buckets_lower_falls_in_the_first_bucket_bucket{le="3"} 6+0x10
positive_buckets_lower_falls_in_the_first_bucket_bucket{le="+Inf"} 100+0x10
# - Bucket [0, 1]: contributes 1.0 observation (full bucket).
# - Bucket [1, 2]: contributes (1.5-1)/(2-1) * (3-1) = 0.5 * 2 = 1.0 observations.
# Total: (1.0 + 1.0) / 100.0 = 0.02
eval instant at 50m histogram_fraction(0, 1.5, positive_buckets_lower_falls_in_the_first_bucket_bucket)
expect no_warn
{} 0.02
eval instant at 50m histogram_fraction(0, 1.5, positive_buckets_lower_falls_in_the_first_bucket)
expect no_warn
{} 0.02
# Negative buckets, lower falls in the first bucket.
load_with_nhcb 5m
negative_buckets_lower_falls_in_the_first_bucket_bucket{le="-3"} 10+0x10
negative_buckets_lower_falls_in_the_first_bucket_bucket{le="-2"} 12+0x10
negative_buckets_lower_falls_in_the_first_bucket_bucket{le="-1"} 15+0x10
negative_buckets_lower_falls_in_the_first_bucket_bucket{le="+Inf"} 100+0x10
# - Bucket [-Inf, -3]: contributes zero observations (no interpolation with infinite width bucket).
# - Bucket [-3, -2]: contributes 12-10 = 2.0 observations (full bucket).
# Total: 2.0 / 100.0 = 0.02
eval instant at 50m histogram_fraction(-4, -2, negative_buckets_lower_falls_in_the_first_bucket_bucket)
expect no_warn
{} 0.02
eval instant at 50m histogram_fraction(-4, -2, negative_buckets_lower_falls_in_the_first_bucket)
expect no_warn
{} 0.02
# Lower is -Inf.
load_with_nhcb 5m
lower_is_negative_Inf_bucket{le="-3"} 10+0x10
lower_is_negative_Inf_bucket{le="-2"} 12+0x10
lower_is_negative_Inf_bucket{le="-1"} 15+0x10
lower_is_negative_Inf_bucket{le="+Inf"} 100+0x10
# - Bucket [-Inf, -3]: contributes 10.0 observations (full bucket).
# - Bucket [-3, -2]: contributes 12-10 = 2.0 observations (full bucket).
# - Bucket [-2, -1]: contributes (-1.5-(-2))/(-1-(-2)) * (15-12) = 0.5 * 3 = 1.5 observations.
# Total: (10.0 + 2.0 + 1.5) / 100.0 = 0.135
eval instant at 50m histogram_fraction(-Inf, -1.5, lower_is_negative_Inf_bucket)
expect no_warn
{} 0.135
eval instant at 50m histogram_fraction(-Inf, -1.5, lower_is_negative_Inf)
expect no_warn
{} 0.135
# Lower is -Inf and upper is +Inf (positive buckets).
load_with_nhcb 5m
lower_is_negative_Inf_and_upper_is_positive_Inf__positive_buckets__bucket{le="1"} 1+0x10
lower_is_negative_Inf_and_upper_is_positive_Inf__positive_buckets__bucket{le="2"} 3+0x10
lower_is_negative_Inf_and_upper_is_positive_Inf__positive_buckets__bucket{le="3"} 6+0x10
lower_is_negative_Inf_and_upper_is_positive_Inf__positive_buckets__bucket{le="+Inf"} 100+0x10
# Range [-Inf, +Inf] captures all observations.
eval instant at 50m histogram_fraction(-Inf, +Inf, lower_is_negative_Inf_and_upper_is_positive_Inf__positive_buckets__bucket)
expect no_warn
{} 1.0
eval instant at 50m histogram_fraction(-Inf, +Inf, lower_is_negative_Inf_and_upper_is_positive_Inf__positive_buckets_)
expect no_warn
{} 1.0
# Lower is -Inf and upper is +Inf (negative buckets).
load_with_nhcb 5m
lower_is_negative_Inf_and_upper_is_positive_Inf__negative_buckets__bucket{le="-3"} 10+0x10
lower_is_negative_Inf_and_upper_is_positive_Inf__negative_buckets__bucket{le="-2"} 12+0x10
lower_is_negative_Inf_and_upper_is_positive_Inf__negative_buckets__bucket{le="-1"} 15+0x10
lower_is_negative_Inf_and_upper_is_positive_Inf__negative_buckets__bucket{le="+Inf"} 100+0x10
# Range [-Inf, +Inf] captures all observations.
eval instant at 50m histogram_fraction(-Inf, +Inf, lower_is_negative_Inf_and_upper_is_positive_Inf__negative_buckets__bucket)
expect no_warn
{} 1.0
eval instant at 50m histogram_fraction(-Inf, +Inf, lower_is_negative_Inf_and_upper_is_positive_Inf__negative_buckets_)
expect no_warn
{} 1.0
# Lower and upper fall in last bucket (positive buckets).
load_with_nhcb 5m
lower_and_upper_fall_in_last_bucket__positive_buckets__bucket{le="1"} 1+0x10
lower_and_upper_fall_in_last_bucket__positive_buckets__bucket{le="2"} 3+0x10
lower_and_upper_fall_in_last_bucket__positive_buckets__bucket{le="3"} 6+0x10
lower_and_upper_fall_in_last_bucket__positive_buckets__bucket{le="+Inf"} 100+0x10
# - Bucket [3, +Inf]: contributes zero observations (no interpolation with infinite width bucket).
# Total: 0.0 / 100.0 = 0.0
eval instant at 50m histogram_fraction(4, 5, lower_and_upper_fall_in_last_bucket__positive_buckets__bucket)
expect no_warn
{} 0.0
eval instant at 50m histogram_fraction(4, 5, lower_and_upper_fall_in_last_bucket__positive_buckets_)
expect no_warn
{} 0.0
# Lower and upper fall in last bucket (negative buckets).
load_with_nhcb 5m
lower_and_upper_fall_in_last_bucket__negative_buckets__bucket{le="-3"} 10+0x10
lower_and_upper_fall_in_last_bucket__negative_buckets__bucket{le="-2"} 12+0x10
lower_and_upper_fall_in_last_bucket__negative_buckets__bucket{le="-1"} 15+0x10
lower_and_upper_fall_in_last_bucket__negative_buckets__bucket{le="+Inf"} 100+0x10
# - Bucket [-1, +Inf]: contributes zero observations (no interpolation with infinite width bucket).
# Total: 0.0 / 100.0 = 0.0
eval instant at 50m histogram_fraction(0, 1, lower_and_upper_fall_in_last_bucket__negative_buckets__bucket)
expect no_warn
{} 0.0
eval instant at 50m histogram_fraction(0, 1, lower_and_upper_fall_in_last_bucket__negative_buckets_)
expect no_warn
{} 0.0
# Upper falls in last bucket.
load_with_nhcb 5m
upper_falls_in_last_bucket_bucket{le="1"} 1+0x10
upper_falls_in_last_bucket_bucket{le="2"} 3+0x10
upper_falls_in_last_bucket_bucket{le="3"} 6+0x10
upper_falls_in_last_bucket_bucket{le="+Inf"} 100+0x10
# - Bucket [2, 3]: 6-3 = 3.0 observations (full bucket).
# - Bucket [3, +Inf]: contributes zero observations (no interpolation with infinite width bucket).
# Total: 3.0 / 100.0 = 0.03
eval instant at 50m histogram_fraction(2, 5, upper_falls_in_last_bucket_bucket)
expect no_warn
{} 0.03
eval instant at 50m histogram_fraction(2, 5, upper_falls_in_last_bucket)
expect no_warn
{} 0.03
# Upper is +Inf.
load_with_nhcb 5m
upper_is_positive_Inf_bucket{le="1"} 1+0x10
upper_is_positive_Inf_bucket{le="2"} 3+0x10
upper_is_positive_Inf_bucket{le="3"} 6+0x10
upper_is_positive_Inf_bucket{le="+Inf"} 100+0x10
# All observations in +Inf bucket: 100-6 = 94.0 observations.
# Total: 94.0 / 100.0 = 0.94
eval instant at 50m histogram_fraction(400, +Inf, upper_is_positive_Inf_bucket)
expect no_warn
{} 0.94
eval instant at 50m histogram_fraction(400, +Inf, upper_is_positive_Inf)
expect no_warn
{} 0.94
# Lower equals upper.
load_with_nhcb 5m
lower_equals_upper_bucket{le="1"} 1+0x10
lower_equals_upper_bucket{le="2"} 3+0x10
lower_equals_upper_bucket{le="3"} 6+0x10
lower_equals_upper_bucket{le="+Inf"} 100+0x10
# No observations can be captured in a zero-width range.
eval instant at 50m histogram_fraction(2, 2, lower_equals_upper_bucket)
expect no_warn
{} 0.0
eval instant at 50m histogram_fraction(2, 2, lower_equals_upper)
expect no_warn
{} 0.0
# Lower greater than upper.
load_with_nhcb 5m
lower_greater_than_upper_bucket{le="1"} 1+0x10
lower_greater_than_upper_bucket{le="2"} 3+0x10
lower_greater_than_upper_bucket{le="3"} 6+0x10
lower_greater_than_upper_bucket{le="+Inf"} 100+0x10
eval instant at 50m histogram_fraction(3, 2, lower_greater_than_upper_bucket)
expect no_warn
{} 0.0
eval instant at 50m histogram_fraction(3, 2, lower_greater_than_upper)
expect no_warn
{} 0.0
# Single bucket.
load_with_nhcb 5m
single_bucket_bucket{le="+Inf"} 100+0x10
# - Bucket [0, +Inf]: contributes zero observations (no interpolation with infinite width bucket).
# Total: 0.0 / 100.0 = 0.0
eval instant at 50m histogram_fraction(0, 1, single_bucket_bucket)
expect no_warn
{} 0.0
eval instant at 50m histogram_fraction(0, 1, single_bucket)
expect no_warn
{} 0.0
# All zero counts.
load_with_nhcb 5m
all_zero_counts_bucket{le="1"} 0+0x10
all_zero_counts_bucket{le="2"} 0+0x10
all_zero_counts_bucket{le="3"} 0+0x10
all_zero_counts_bucket{le="+Inf"} 0+0x10
eval instant at 50m histogram_fraction(0, 5, all_zero_counts_bucket)
expect no_warn
{} NaN
eval instant at 50m histogram_fraction(0, 5, all_zero_counts)
expect no_warn
{} NaN
# Lower exactly on bucket boundary.
load_with_nhcb 5m
lower_exactly_on_bucket_boundary_bucket{le="1"} 1+0x10
lower_exactly_on_bucket_boundary_bucket{le="2"} 3+0x10
lower_exactly_on_bucket_boundary_bucket{le="3"} 6+0x10
lower_exactly_on_bucket_boundary_bucket{le="+Inf"} 100+0x10
# - Bucket [2, 3]: 6-3 = 3.0 observations (full bucket).
# - Bucket [3, +Inf]: contributes zero observations (no interpolation with infinite width bucket).
# Total: 3.0 / 100.0 = 0.03
eval instant at 50m histogram_fraction(2, 3.5, lower_exactly_on_bucket_boundary_bucket)
expect no_warn
{} 0.03
eval instant at 50m histogram_fraction(2, 3.5, lower_exactly_on_bucket_boundary)
expect no_warn
{} 0.03
# Upper exactly on bucket boundary.
load_with_nhcb 5m
upper_exactly_on_bucket_boundary_bucket{le="1"} 1+0x10
upper_exactly_on_bucket_boundary_bucket{le="2"} 3+0x10
upper_exactly_on_bucket_boundary_bucket{le="3"} 6+0x10
upper_exactly_on_bucket_boundary_bucket{le="+Inf"} 100+0x10
# - Bucket [0, 1]: (1.0-0.5)/(1.0-0.0) * 1.0 = 0.5 * 1.0 = 0.5 observations.
# - Bucket [1, 2]: 3-1 = 2.0 observations (full bucket).
# Total: (0.5 + 2.0) / 100.0 = 0.025
eval instant at 50m histogram_fraction(0.5, 2, upper_exactly_on_bucket_boundary_bucket)
expect no_warn
{} 0.025
eval instant at 50m histogram_fraction(0.5, 2, upper_exactly_on_bucket_boundary)
expect no_warn
{} 0.025
# Both bounds exactly on bucket boundaries.
load_with_nhcb 5m
both_bounds_exactly_on_bucket_boundaries_bucket{le="1"} 1+0x10
both_bounds_exactly_on_bucket_boundaries_bucket{le="2"} 3+0x10
both_bounds_exactly_on_bucket_boundaries_bucket{le="3"} 6+0x10
both_bounds_exactly_on_bucket_boundaries_bucket{le="+Inf"} 100+0x10
# - Bucket [1, 2]: 3-1 = 2.0 observations (full bucket).
# - Bucket [2, 3]: 6-3 = 3.0 observations (full bucket).
# Total: (2.0 + 3.0) / 100.0 = 0.05
eval instant at 50m histogram_fraction(1, 3, both_bounds_exactly_on_bucket_boundaries_bucket)
expect no_warn
{} 0.05
eval instant at 50m histogram_fraction(1, 3, both_bounds_exactly_on_bucket_boundaries)
expect no_warn
{} 0.05
# Fractional bucket bounds.
load_with_nhcb 5m
fractional_bucket_bounds_bucket{le="0.5"} 2.5+0x10
fractional_bucket_bounds_bucket{le="1"} 7.5+0x10
fractional_bucket_bounds_bucket{le="+Inf"} 100+0x10
# - Bucket [0, 0.5]: (0.5-0.1)/(0.5-0.0) * 2.5 = 0.8 * 2.5 = 2.0 observations.
# - Bucket [0.5, 1.0]: (0.75-0.5)/(1.0-0.5) * (7.5-2.5) = 0.5 * 5.0 = 2.5 observations.
# Total: (2.0 + 2.5) / 100.0 = 0.045
eval instant at 50m histogram_fraction(0.1, 0.75, fractional_bucket_bounds_bucket)
expect no_warn
{} 0.045
eval instant at 50m histogram_fraction(0.1, 0.75, fractional_bucket_bounds)
expect no_warn
{} 0.045
# Range crosses zero.
load_with_nhcb 5m
range_crosses_zero_bucket{le="-2"} 5+0x10
range_crosses_zero_bucket{le="-1"} 10+0x10
range_crosses_zero_bucket{le="0"} 15+0x10
range_crosses_zero_bucket{le="1"} 20+0x10
range_crosses_zero_bucket{le="+Inf"} 100+0x10
# - Bucket [-1, 0]: 15-10 = 5.0 observations (full bucket).
# - Bucket [0, 1]: 20-15 = 5.0 observations (full bucket).
# Total: (5.0 + 5.0) / 100.0 = 0.1
eval instant at 50m histogram_fraction(-1, 1, range_crosses_zero_bucket)
expect no_warn
{} 0.1
eval instant at 50m histogram_fraction(-1, 1, range_crosses_zero)
expect no_warn
{} 0.1
# Lower is NaN.
load_with_nhcb 5m
lower_is_NaN_bucket{le="1"} 1+0x10
lower_is_NaN_bucket{le="+Inf"} 100+0x10
eval instant at 50m histogram_fraction(NaN, 1, lower_is_NaN_bucket)
expect no_warn
{} NaN
eval instant at 50m histogram_fraction(NaN, 1, lower_is_NaN)
expect no_warn
{} NaN
# Upper is NaN.
load_with_nhcb 5m
upper_is_NaN_bucket{le="1"} 1+0x10
upper_is_NaN_bucket{le="+Inf"} 100+0x10
eval instant at 50m histogram_fraction(0, NaN, upper_is_NaN_bucket)
expect no_warn
{} NaN
eval instant at 50m histogram_fraction(0, NaN, upper_is_NaN)
expect no_warn
{} NaN
# Range entirely below all buckets.
load_with_nhcb 5m
range_entirely_below_all_buckets_bucket{le="1"} 1+0x10
range_entirely_below_all_buckets_bucket{le="2"} 3+0x10
range_entirely_below_all_buckets_bucket{le="+Inf"} 10+0x10
eval instant at 50m histogram_fraction(-10, -5, range_entirely_below_all_buckets_bucket)
expect no_warn
{} 0.0
eval instant at 50m histogram_fraction(-10, -5, range_entirely_below_all_buckets)
expect no_warn
{} 0.0
# Range entirely above all buckets.
load_with_nhcb 5m
range_entirely_above_all_buckets_bucket{le="1"} 1+0x10
range_entirely_above_all_buckets_bucket{le="2"} 3+0x10
range_entirely_above_all_buckets_bucket{le="+Inf"} 10+0x10
eval instant at 50m histogram_fraction(5, 10, range_entirely_above_all_buckets_bucket)
expect no_warn
{} 0.0
eval instant at 50m histogram_fraction(5, 10, range_entirely_above_all_buckets)
expect no_warn
{} 0.0
# In the classic histogram, we can access the corresponding bucket (if
# it exists) and divide by the count to get the same result.

View File

@ -406,6 +406,18 @@ func HistogramFraction(lower, upper float64, h *histogram.FloatHistogram, metric
// consistent with the linear interpolation known from classic
// histograms. It is also used for the zero bucket.
interpolateLinearly := func(v float64) float64 {
// Note: `v` is a finite value.
// For buckets with infinite bounds, we cannot interpolate meaningfully.
// For +Inf upper bound, interpolation returns the cumulative count of the previous bucket
// as the second term in the interpolation formula yields 0 (finite/Inf).
// In other words, no observations from the last bucket are considered in the fraction calculation.
// For -Inf lower bound, however, the second term would be (v-(-Inf))/(upperBound-(-Inf)) = Inf/Inf = NaN.
// To achieve the same effect of no contribution as the +Inf bucket, handle the -Inf case by returning
// the cumulative count at the first bucket (which equals the bucket's count).
// In both cases, we effectively skip interpolation within the infinite-width bucket.
if b.Lower == math.Inf(-1) {
return b.Count
}
return rank + b.Count*(v-b.Lower)/(b.Upper-b.Lower)
}
@ -531,14 +543,34 @@ func BucketFraction(lower, upper float64, buckets Buckets) float64 {
rank, lowerRank, upperRank float64
lowerSet, upperSet bool
)
// If the upper bound of the first bucket is greater than 0, we assume
// we are dealing with positive buckets only and lowerBound for the
// first bucket is set to 0; otherwise it is set to -Inf.
lowerBound := 0.0
if buckets[0].UpperBound <= 0 {
lowerBound = math.Inf(-1)
}
for i, b := range buckets {
lowerBound := math.Inf(-1)
if i > 0 {
lowerBound = buckets[i-1].UpperBound
}
upperBound := b.UpperBound
interpolateLinearly := func(v float64) float64 {
// Note: `v` is a finite value.
// For buckets with infinite bounds, we cannot interpolate meaningfully.
// For +Inf upper bound, interpolation returns the cumulative count of the previous bucket
// as the second term in the interpolation formula yields 0 (finite/Inf).
// In other words, no observations from the last bucket are considered in the fraction calculation.
// For -Inf lower bound, however, the second term would be (v-(-Inf))/(upperBound-(-Inf)) = Inf/Inf = NaN.
// To achieve the same effect of no contribution as the +Inf bucket, handle the -Inf case by returning
// the cumulative count at the first bucket.
// In both cases, we effectively skip interpolation within the infinite-width bucket.
if lowerBound == math.Inf(-1) {
return b.Count
}
return rank + (b.Count-rank)*(v-lowerBound)/(upperBound-lowerBound)
}