diff --git a/promql/promqltest/testdata/histograms.test b/promql/promqltest/testdata/histograms.test index 84a467a314..436390ee41 100644 --- a/promql/promqltest/testdata/histograms.test +++ b/promql/promqltest/testdata/histograms.test @@ -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. diff --git a/promql/quantile.go b/promql/quantile.go index 1454974107..78df925c51 100644 --- a/promql/quantile.go +++ b/promql/quantile.go @@ -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) }