PromQL: Add histogram_quantiles variadic function

Signed-off-by: Linas Medziunas <linas.medziunas@gmail.com>
This commit is contained in:
Linas Medziunas 2025-10-07 16:23:49 +03:00
parent f4b8840f51
commit dd4783e6c2
5 changed files with 226 additions and 4 deletions

View file

@ -1159,6 +1159,9 @@ type EvalNodeHelper struct {
// funcHistogramQuantile and funcHistogramFraction for classic histograms.
signatureToMetricWithBuckets map[string]*metricWithBuckets
nativeHistogramSamples []Sample
// funcHistogramQuantiles for histograms.
quantileStrs map[float64]string
signatureToLabelsWithQuantile map[string]map[float64]labels.Labels
lb *labels.Builder
lblBuf []byte
@ -1246,6 +1249,35 @@ func (enh *EvalNodeHelper) resetHistograms(inVec Vector, arg parser.Expr) annota
return annos
}
func (enh *EvalNodeHelper) getOrCreateLblsWithQuantile(lbls labels.Labels, quantileLabel string, q float64) labels.Labels {
if enh.signatureToLabelsWithQuantile == nil {
enh.signatureToLabelsWithQuantile = make(map[string]map[float64]labels.Labels)
}
enh.lblBuf = lbls.Bytes(enh.lblBuf)
cachedLbls, ok := enh.signatureToLabelsWithQuantile[string(enh.lblBuf)]
if !ok {
cachedLbls = make(map[float64]labels.Labels, len(enh.quantileStrs))
enh.signatureToLabelsWithQuantile[string(enh.lblBuf)] = cachedLbls
}
cachedLblsWithQuantile, ok := cachedLbls[q]
if !ok {
quantileStr := "NaN"
if !math.IsNaN(q) {
// Cannot do map lookup by NaN key.
quantileStr = enh.quantileStrs[q]
}
cachedLblsWithQuantile = labels.NewBuilder(lbls).
Set(quantileLabel, quantileStr).
Labels()
cachedLbls[q] = cachedLblsWithQuantile
}
return cachedLblsWithQuantile
}
// rangeEval evaluates the given expressions, and then for each step calls
// the given funcCall with the values computed for each expression at that
// step. The return value is the combination into time series of all the
@ -4072,7 +4104,7 @@ func detectHistogramStatsDecoding(expr parser.Expr) {
// further up (the latter wouldn't make sense,
// but no harm in detecting it).
n.SkipHistogramBuckets = true
case "histogram_quantile", "histogram_fraction":
case "histogram_quantile", "histogram_quantiles", "histogram_fraction":
// If we ever see a function that needs the
// whole histogram, we will not skip the
// buckets.

View file

@ -1612,8 +1612,8 @@ func funcHistogramQuantile(vectorVals []Vector, _ Matrix, args parser.Expression
inVec := vectorVals[1]
var annos annotations.Annotations
if math.IsNaN(q) || q < 0 || q > 1 {
annos.Add(annotations.NewInvalidQuantileWarning(q, args[0].PositionRange()))
if err := validateQuantile(q, args[0]); err != nil {
annos.Add(err)
}
annos.Merge(enh.resetHistograms(inVec, args[1]))
@ -1662,6 +1662,89 @@ func funcHistogramQuantile(vectorVals []Vector, _ Matrix, args parser.Expression
return enh.Out, annos
}
func validateQuantile(q float64, arg parser.Expr) error {
if math.IsNaN(q) || q < 0 || q > 1 {
return annotations.NewInvalidQuantileWarning(q, arg.PositionRange())
}
return nil
}
// === histogram_quantiles(Vector parser.ValueTypeVector, label parser.ValueTypeString, q0 parser.ValueTypeScalar, qs parser.ValueTypeScalar...) (Vector, Annotations) ===
func funcHistogramQuantiles(vectorVals []Vector, _ Matrix, args parser.Expressions, enh *EvalNodeHelper) (Vector, annotations.Annotations) {
var (
inVec = vectorVals[0]
quantileLabel = args[1].(*parser.StringLiteral).Val
numQuantiles = len(vectorVals[2:])
qs = make([]float64, 0, numQuantiles)
annos annotations.Annotations
)
if enh.quantileStrs == nil {
enh.quantileStrs = make(map[float64]string, numQuantiles)
}
for i := 2; i < len(vectorVals); i++ {
q := vectorVals[i][0].F
if err := validateQuantile(q, args[i]); err != nil {
annos.Add(err)
}
if _, ok := enh.quantileStrs[q]; !ok {
enh.quantileStrs[q] = fmt.Sprintf("%g", q)
}
qs = append(qs, q)
}
annos.Merge(enh.resetHistograms(inVec, args[0]))
for _, q := range qs {
// Deal with the native histograms.
for _, sample := range enh.nativeHistogramSamples {
if sample.H == nil {
// Native histogram conflicts with classic histogram at the same timestamp, ignore.
continue
}
if !enh.enableDelayedNameRemoval {
sample.Metric = sample.Metric.DropReserved(schema.IsMetadataLabel)
}
hq, hqAnnos := HistogramQuantile(q, sample.H, sample.Metric.Get(model.MetricNameLabel), args[0].PositionRange())
annos.Merge(hqAnnos)
enh.Out = append(enh.Out, Sample{
Metric: enh.getOrCreateLblsWithQuantile(sample.Metric, quantileLabel, q),
F: hq,
DropName: true,
})
}
// Deal with classic histograms that have already been filtered for conflicting native histograms.
for _, mb := range enh.signatureToMetricWithBuckets {
if len(mb.buckets) > 0 {
hq, forcedMonotonicity, _ := BucketQuantile(q, mb.buckets)
if forcedMonotonicity {
if enh.enableDelayedNameRemoval {
annos.Add(annotations.NewHistogramQuantileForcedMonotonicityInfo(mb.metric.Get(labels.MetricName), args[0].PositionRange()))
} else {
annos.Add(annotations.NewHistogramQuantileForcedMonotonicityInfo("", args[0].PositionRange()))
}
}
if !enh.enableDelayedNameRemoval {
mb.metric = mb.metric.DropReserved(schema.IsMetadataLabel)
}
enh.Out = append(enh.Out, Sample{
Metric: enh.getOrCreateLblsWithQuantile(mb.metric, quantileLabel, q),
F: hq,
DropName: true,
})
}
}
}
return enh.Out, annos
}
// pickFirstSampleIndex returns the index of the last sample before
// or at the range start, or 0 if none exist before the range start.
// If the vector selector is not anchored, it always returns 0.
@ -1982,6 +2065,7 @@ var FunctionCalls = map[string]FunctionCall{
"histogram_count": funcHistogramCount,
"histogram_fraction": funcHistogramFraction,
"histogram_quantile": funcHistogramQuantile,
"histogram_quantiles": funcHistogramQuantiles,
"histogram_sum": funcHistogramSum,
"histogram_stddev": funcHistogramStdDev,
"histogram_stdvar": funcHistogramStdVar,

View file

@ -208,6 +208,13 @@ var Functions = map[string]*Function{
ArgTypes: []ValueType{ValueTypeScalar, ValueTypeVector},
ReturnType: ValueTypeVector,
},
"histogram_quantiles": {
Name: "histogram_quantiles",
ArgTypes: []ValueType{ValueTypeVector, ValueTypeString, ValueTypeScalar, ValueTypeScalar},
Variadic: 10,
ReturnType: ValueTypeVector,
Experimental: true,
},
"double_exponential_smoothing": {
Name: "double_exponential_smoothing",
ArgTypes: []ValueType{ValueTypeMatrix, ValueTypeScalar, ValueTypeScalar},

View file

@ -221,6 +221,40 @@ eval instant at 50m histogram_quantile(1, testhistogram3_bucket)
{start="positive"} 1
{start="negative"} -0.1
eval instant at 50m histogram_quantiles(testhistogram3, "q", 0, 0.25, 0.5, 0.75, 1)
expect no_warn
{q="0", start="positive"} 0
{q="0", start="negative"} -0.25
{q="0.25", start="positive"} 0.055
{q="0.25", start="negative"} -0.225
{q="0.5", start="positive"} 0.125
{q="0.5", start="negative"} -0.2
{q="0.75", start="positive"} 0.45
{q="0.75", start="negative"} -0.15
{q="1", start="positive"} 1
{q="1", start="negative"} -0.1
eval instant at 50m histogram_quantiles(testhistogram3_bucket, "q", 0, 0.25, 0.5, 0.75, 1)
expect no_warn
{q="0", start="positive"} 0
{q="0", start="negative"} -0.25
{q="0.25", start="positive"} 0.055
{q="0.25", start="negative"} -0.225
{q="0.5", start="positive"} 0.125
{q="0.5", start="negative"} -0.2
{q="0.75", start="positive"} 0.45
{q="0.75", start="negative"} -0.15
{q="1", start="positive"} 1
{q="1", start="negative"} -0.1
# Break label set uniqueness.
eval instant at 50m histogram_quantiles(testhistogram3, "start", 0, 0.25, 0.5, 0.75, 1)
expect fail
eval instant at 50m histogram_quantiles(testhistogram3_bucket, "start", 0, 0.25, 0.5, 0.75, 1)
expect fail
# Quantile too low.
eval instant at 50m histogram_quantile(-0.1, testhistogram)
@ -233,6 +267,16 @@ eval instant at 50m histogram_quantile(-0.1, testhistogram_bucket)
{start="positive"} -Inf
{start="negative"} -Inf
eval instant at 50m histogram_quantiles(testhistogram, "q", -0.1)
expect warn
{q="-0.1", start="positive"} -Inf
{q="-0.1", start="negative"} -Inf
eval instant at 50m histogram_quantiles(testhistogram_bucket, "q", -0.1)
expect warn
{q="-0.1", start="positive"} -Inf
{q="-0.1", start="negative"} -Inf
# Quantile too high.
eval instant at 50m histogram_quantile(1.01, testhistogram)
@ -245,6 +289,16 @@ eval instant at 50m histogram_quantile(1.01, testhistogram_bucket)
{start="positive"} +Inf
{start="negative"} +Inf
eval instant at 50m histogram_quantiles(testhistogram, "q", 1.01)
expect warn
{q="1.01", start="positive"} +Inf
{q="1.01", start="negative"} +Inf
eval instant at 50m histogram_quantiles(testhistogram_bucket, "q", 1.01)
expect warn
{q="1.01", start="positive"} +Inf
{q="1.01", start="negative"} +Inf
# Quantile invalid.
eval instant at 50m histogram_quantile(NaN, testhistogram)
@ -257,9 +311,22 @@ eval instant at 50m histogram_quantile(NaN, testhistogram_bucket)
{start="positive"} NaN
{start="negative"} NaN
eval instant at 50m histogram_quantiles(testhistogram, "q", NaN)
expect warn
{q="NaN", start="positive"} NaN
{q="NaN", start="negative"} NaN
eval instant at 50m histogram_quantiles(testhistogram_bucket, "q", NaN)
expect warn
{q="NaN", start="positive"} NaN
{q="NaN", start="negative"} NaN
eval instant at 50m histogram_quantile(NaN, non_existent)
expect warn msg: PromQL warning: quantile value should be between 0 and 1, got NaN
eval instant at 50m histogram_quantiles(non_existent, "q", NaN)
expect warn msg: PromQL warning: quantile value should be between 0 and 1, got NaN
# Quantile value in lowest bucket.
eval instant at 50m histogram_quantile(0, testhistogram)
@ -590,6 +657,12 @@ eval instant at 50m histogram_quantile(0.99, nonmonotonic_bucket)
expect info
{} 979.75
eval instant at 50m histogram_quantiles(nonmonotonic_bucket, "q", 0.01, 0.5, 0.99)
expect info
{q="0.01"} 0.0045
{q="0.5"} 8.5
{q="0.99"} 979.75
# Buckets with different representations of the same upper bound.
eval instant at 50m histogram_quantile(0.5, rate(mixed_bucket[10m]))
{instance="ins1", job="job1"} 0.15
@ -625,9 +698,15 @@ load_with_nhcb 5m
eval instant at 50m histogram_quantile(0.99, {__name__=~"request_duration_seconds\\d*_bucket"})
expect fail
eval instant at 50m histogram_quantiles({__name__=~"request_duration_seconds\\d*_bucket"}, "q", 0.99)
expect fail
eval instant at 50m histogram_quantile(0.99, {__name__=~"request_duration_seconds\\d*"})
expect fail
eval instant at 50m histogram_quantiles({__name__=~"request_duration_seconds\\d*"}, "q", 0.99)
expect fail
# Histogram with constant buckets.
load_with_nhcb 1m
const_histogram_bucket{le="0.0"} 1 1 1 1 1
@ -689,7 +768,7 @@ eval instant at 10m histogram_sum(increase(histogram_with_reset[15m]))
clear
# Test histogram_quantile and histogram_fraction with conflicting classic and native histograms.
# Test histogram_quantile(s) and histogram_fraction with conflicting classic and native histograms.
load 1m
series{host="a"} {{schema:0 sum:5 count:4 buckets:[9 2 1]}}
series{host="a", le="0.1"} 2
@ -704,6 +783,11 @@ eval instant at 0 histogram_quantile(0.8, series)
expect warn msg: PromQL warning: vector contains a mix of classic and native histograms for metric name "series"
# Should return no results.
eval instant at 0 histogram_quantiles(series, "q", 0.1, 0.2)
expect no_info
expect warn msg: PromQL warning: vector contains a mix of classic and native histograms for metric name "series"
# Should return no results.
eval instant at 0 histogram_fraction(-Inf, 1, series)
expect no_info
expect warn msg: PromQL warning: vector contains a mix of classic and native histograms for metric name "series"

View file

@ -55,6 +55,10 @@ eval instant at 1m histogram_quantile(0.5, single_histogram)
expect no_info
{} 1.414213562373095
eval instant at 1m histogram_quantiles(single_histogram, "q", 0.5)
expect no_info
{q="0.5"} 1.414213562373095
clear
# Repeat the same histogram 10 times.
@ -1463,6 +1467,11 @@ eval instant at 1m histogram_quantile(0.81, histogram_nan)
{case="100% NaNs"} NaN
{case="20% NaNs"} NaN
eval instant at 1m histogram_quantiles(histogram_nan, "q", 0.81)
expect info msg: PromQL info: input to histogram_quantile has NaN observations, result is NaN for metric name "histogram_nan"
{case="100% NaNs", q="0.81"} NaN
{case="20% NaNs", q="0.81"} NaN
eval instant at 1m histogram_quantile(0.8, histogram_nan{case="100% NaNs"})
expect info msg: PromQL info: input to histogram_quantile has NaN observations, result is NaN for metric name "histogram_nan"
{case="100% NaNs"} NaN
@ -1608,6 +1617,9 @@ eval instant at 1m histogram_quantile(0.5, myHistogram2)
eval instant at 1m histogram_quantile(0.5, mixedHistogram)
expect warn msg: PromQL warning: vector contains a mix of classic and native histograms for metric name "mixedHistogram"
eval instant at 1m histogram_quantiles(mixedHistogram, "q", 0.5)
expect warn msg: PromQL warning: vector contains a mix of classic and native histograms for metric name "mixedHistogram"
clear
# A counter reset only in a bucket. Sub-queries still need to detect
@ -1677,6 +1689,9 @@ eval instant at 1m histogram_count(histogram unless histogram_quantile(0.5, hist
eval instant at 1m histogram_quantile(0.5, histogram unless histogram_count(histogram) == 0)
{} 3.1748021039363987
eval instant at 1m histogram_quantiles(histogram unless histogram_count(histogram) == 0, "q", 0.5)
{q="0.5"} 3.1748021039363987
clear
# Regression test for: