PromQL: Add start() end() range() and step() functions

Signed-off-by: Julien Pivotto <291750+roidelapluie@users.noreply.github.com>
This commit is contained in:
Julien Pivotto
2026-01-16 17:48:49 +01:00
parent eb220862e5
commit ae9e52c868
12 changed files with 1064 additions and 591 deletions

View File

@@ -73,6 +73,7 @@
"delta": true,
"deriv": true,
"double_exponential_smoothing": false,
"end": false,
"exp": true,
"first_over_time": false,
"floor": true,
@@ -105,6 +106,7 @@
"present_over_time": true,
"quantile_over_time": true,
"rad": true,
"range": false,
"rate": true,
"resets": true,
"round": true,
@@ -117,8 +119,10 @@
"sort_by_label_desc": false,
"sort_desc": true,
"sqrt": true,
"start": false,
"stddev_over_time": true,
"stdvar_over_time": true,
"step": false,
"sum_over_time": true,
"tan": true,
"tanh": true,

View File

@@ -173,6 +173,16 @@ entirely. For elements that contain a mix of float and histogram samples, only
the float samples are used as input, which is flagged by an info-level
annotation.
## `end()`
**This function has to be enabled via the [feature
flag](../feature_flags.md#experimental-promql-functions)
`--enable-feature=promql-experimental-functions`.**
`end()` returns the end timestamp of the current query range evaluation as the
number of seconds since January 1, 1970 UTC. For instant queries, this is equal
to the evaluation timestamp.
## `double_exponential_smoothing()`
**This function has to be enabled via the [feature
@@ -729,6 +739,15 @@ ignored entirely. For elements that contain a mix of float and histogram
samples, only the float samples are used as input, which is flagged by an
info-level annotation.
## `range()`
**This function has to be enabled via the [feature
flag](../feature_flags.md#experimental-promql-functions)
`--enable-feature=promql-experimental-functions`.**
`range()` returns the range duration of the current query range evaluation in
seconds and is equivalent to `end() - start()`. For instant queries, this returns `0`.
## `rate()`
`rate(v range-vector)` calculates the per-second average rate of increase of the
@@ -841,6 +860,25 @@ Same as `sort_by_label`, but sorts in descending order.
`sqrt(v instant-vector)` calculates the square root of all float samples in
`v`. Histogram samples in the input vector are ignored silently.
## `start()`
**This function has to be enabled via the [feature
flag](../feature_flags.md#experimental-promql-functions)
`--enable-feature=promql-experimental-functions`.**
`start()` returns the start timestamp of the current query range evaluation as the
number of seconds since January 1, 1970 UTC. For instant queries, this is equal
to the evaluation timestamp.
## `step()`
**This function has to be enabled via the [feature
flag](../feature_flags.md#experimental-promql-functions)
`--enable-feature=promql-experimental-functions`.**
`step()` returns the query resolution step as the number of seconds. For instant
queries, this returns `0`.
## `time()`
`time()` returns the number of seconds since January 1, 1970 UTC. Note that

View File

@@ -4229,12 +4229,61 @@ func unwrapParenExpr(e *parser.Expr) {
}
}
// foldQueryContextFunctions rewrites calls to start(), end(), range(), and step() into
// NumberLiteral nodes, since their values are constant for a given query execution.
// This allows parent expressions to be correctly recognized as step-invariant by preprocessExprHelper.
func foldQueryContextFunctions(expr parser.Expr, start, end time.Time, step time.Duration) parser.Expr {
if call, ok := expr.(*parser.Call); ok {
switch call.Func.Name {
case "start":
return &parser.NumberLiteral{Val: float64(timestamp.FromTime(start)) / 1000, PosRange: call.PosRange}
case "end":
return &parser.NumberLiteral{Val: float64(timestamp.FromTime(end)) / 1000, PosRange: call.PosRange}
case "range":
return &parser.NumberLiteral{Val: end.Sub(start).Seconds(), PosRange: call.PosRange}
case "step":
var val float64
if !start.Equal(end) {
val = step.Seconds()
}
return &parser.NumberLiteral{Val: val, PosRange: call.PosRange}
}
}
switch n := expr.(type) {
case *parser.BinaryExpr:
n.LHS = foldQueryContextFunctions(n.LHS, start, end, step)
n.RHS = foldQueryContextFunctions(n.RHS, start, end, step)
case *parser.Call:
for i := range n.Args {
n.Args[i] = foldQueryContextFunctions(n.Args[i], start, end, step)
}
case *parser.AggregateExpr:
n.Expr = foldQueryContextFunctions(n.Expr, start, end, step)
if n.Param != nil {
n.Param = foldQueryContextFunctions(n.Param, start, end, step)
}
case *parser.UnaryExpr:
n.Expr = foldQueryContextFunctions(n.Expr, start, end, step)
case *parser.ParenExpr:
n.Expr = foldQueryContextFunctions(n.Expr, start, end, step)
case *parser.SubqueryExpr:
n.Expr = foldQueryContextFunctions(n.Expr, start, end, step)
case *parser.MatrixSelector, *parser.VectorSelector, *parser.NumberLiteral, *parser.StringLiteral:
// Leaf nodes or nodes without foldable sub-expressions.
default:
panic(fmt.Sprintf("foldQueryContextFunctions: unhandled node type %T", expr))
}
return expr
}
// PreprocessExpr wraps all possible step invariant parts of the given expression with
// StepInvariantExpr. It also resolves the preprocessors, evaluates duration expressions
// into their numeric values and removes superfluous parenthesis on parameters to functions and aggregations.
func PreprocessExpr(expr parser.Expr, start, end time.Time, step time.Duration) (parser.Expr, error) {
detectHistogramStatsDecoding(expr)
expr = foldQueryContextFunctions(expr, start, end, step)
if err := parser.Walk(&durationVisitor{step: step, queryRange: end.Sub(start)}, expr, nil); err != nil {
return nil, err
}

View File

@@ -59,6 +59,13 @@ import (
// Scalar results should be returned as the value of a sample in a Vector.
type FunctionCall func(vectorVals []Vector, matrixVals Matrix, args parser.Expressions, enh *EvalNodeHelper) (Vector, annotations.Annotations)
// funcQueryContext is a placeholder for start(), end(), range(), and step() functions.
// These are folded into NumberLiteral nodes by foldQueryContextFunctions during query
// preprocessing and must never reach the evaluator.
func funcQueryContext(_ []Vector, _ Matrix, _ parser.Expressions, _ *EvalNodeHelper) (Vector, annotations.Annotations) {
panic("query context functions must be folded during preprocessing and must never be evaluated")
}
// === time() float64 ===
func funcTime(_ []Vector, _ Matrix, _ parser.Expressions, enh *EvalNodeHelper) (Vector, annotations.Annotations) {
return Vector{Sample{
@@ -2174,6 +2181,7 @@ var FunctionCalls = map[string]FunctionCall{
"day_of_week": funcDayOfWeek,
"day_of_year": funcDayOfYear,
"deg": funcDeg,
"end": funcQueryContext,
"delta": funcDelta,
"deriv": funcDeriv,
"exp": funcExp,
@@ -2213,6 +2221,7 @@ var FunctionCalls = map[string]FunctionCall{
"present_over_time": funcPresentOverTime,
"quantile_over_time": funcQuantileOverTime,
"rad": funcRad,
"range": funcQueryContext,
"rate": funcRate,
"resets": funcResets,
"round": funcRound,
@@ -2224,6 +2233,8 @@ var FunctionCalls = map[string]FunctionCall{
"sort_desc": funcSortDesc,
"sort_by_label": funcSortByLabel,
"sort_by_label_desc": funcSortByLabelDesc,
"start": funcQueryContext,
"step": funcQueryContext,
"sqrt": funcSqrt,
"stddev_over_time": funcStddevOverTime,
"stdvar_over_time": funcStdvarOverTime,
@@ -2244,8 +2255,8 @@ var FunctionCalls = map[string]FunctionCall{
var AtModifierUnsafeFunctions = map[string]struct{}{
// Step invariant functions.
"days_in_month": {}, "day_of_month": {}, "day_of_week": {}, "day_of_year": {},
"hour": {}, "minute": {}, "month": {}, "year": {},
"predict_linear": {}, "time": {},
"end": {}, "hour": {}, "minute": {}, "month": {}, "year": {},
"predict_linear": {}, "range": {}, "start": {}, "step": {}, "time": {},
// Uses timestamp of the argument for the result,
// hence unsafe to use with @ modifier.
"timestamp": {},

View File

@@ -144,6 +144,12 @@ var Functions = map[string]*Function{
ArgTypes: []ValueType{ValueTypeVector},
ReturnType: ValueTypeVector,
},
"end": {
Name: "end",
ArgTypes: []ValueType{},
ReturnType: ValueTypeScalar,
Experimental: true,
},
"delta": {
Name: "delta",
ArgTypes: []ValueType{ValueTypeMatrix},
@@ -354,6 +360,12 @@ var Functions = map[string]*Function{
ArgTypes: []ValueType{ValueTypeVector},
ReturnType: ValueTypeVector,
},
"range": {
Name: "range",
ArgTypes: []ValueType{},
ReturnType: ValueTypeScalar,
Experimental: true,
},
"rate": {
Name: "rate",
ArgTypes: []ValueType{ValueTypeMatrix},
@@ -419,6 +431,18 @@ var Functions = map[string]*Function{
ArgTypes: []ValueType{ValueTypeVector},
ReturnType: ValueTypeVector,
},
"start": {
Name: "start",
ArgTypes: []ValueType{},
ReturnType: ValueTypeScalar,
Experimental: true,
},
"step": {
Name: "step",
ArgTypes: []ValueType{},
ReturnType: ValueTypeScalar,
Experimental: true,
},
"stddev_over_time": {
Name: "stddev_over_time",
ArgTypes: []ValueType{ValueTypeMatrix},

View File

@@ -472,6 +472,60 @@ function_call : IDENTIFIER function_call_body
},
}
}
| at_modifier_preprocessors function_call_body
{
fn, exist := getFunction($1.Val, yylex.(*parser).functions)
if !exist{
yylex.(*parser).addParseErrf($1.PositionRange(),"unknown function with name %q", $1.Val)
}
if fn != nil && fn.Experimental && !yylex.(*parser).options.EnableExperimentalFunctions {
yylex.(*parser).addParseErrf($1.PositionRange(),"function %q is not enabled", $1.Val)
}
$$ = &Call{
Func: fn,
Args: $2.(Expressions),
PosRange: posrange.PositionRange{
Start: $1.PositionRange().Start,
End: yylex.(*parser).lastClosing,
},
}
}
| STEP function_call_body
{
fn, exist := getFunction($1.Val, yylex.(*parser).functions)
if !exist{
yylex.(*parser).addParseErrf($1.PositionRange(),"unknown function with name %q", $1.Val)
}
if fn != nil && fn.Experimental && !yylex.(*parser).options.EnableExperimentalFunctions {
yylex.(*parser).addParseErrf($1.PositionRange(),"function %q is not enabled", $1.Val)
}
$$ = &Call{
Func: fn,
Args: $2.(Expressions),
PosRange: posrange.PositionRange{
Start: $1.PositionRange().Start,
End: yylex.(*parser).lastClosing,
},
}
}
| RANGE function_call_body
{
fn, exist := getFunction($1.Val, yylex.(*parser).functions)
if !exist{
yylex.(*parser).addParseErrf($1.PositionRange(),"unknown function with name %q", $1.Val)
}
if fn != nil && fn.Experimental && !yylex.(*parser).options.EnableExperimentalFunctions {
yylex.(*parser).addParseErrf($1.PositionRange(),"function %q is not enabled", $1.Val)
}
$$ = &Call{
Func: fn,
Args: $2.(Expressions),
PosRange: posrange.PositionRange{
Start: $1.PositionRange().Start,
End: yylex.(*parser).lastClosing,
},
}
}
;
function_call_body: LEFT_PAREN function_call_args RIGHT_PAREN

File diff suppressed because it is too large Load Diff

View File

@@ -4212,23 +4212,23 @@ var testExpr = []struct {
},
{
input: `start()`,
fail: true,
errors: ParseErrors{
ParseErr{
PositionRange: posrange.PositionRange{Start: 5, End: 6},
Err: errors.New(`unexpected "("`),
Query: `start()`,
expected: &Call{
Func: MustGetFunction("start"),
Args: Expressions{},
PosRange: posrange.PositionRange{
Start: 0,
End: 7,
},
},
},
{
input: `end()`,
fail: true,
errors: ParseErrors{
ParseErr{
PositionRange: posrange.PositionRange{Start: 3, End: 4},
Err: errors.New(`unexpected "("`),
Query: `end()`,
expected: &Call{
Func: MustGetFunction("end"),
Args: Expressions{},
PosRange: posrange.PositionRange{
Start: 0,
End: 5,
},
},
},

View File

@@ -1579,10 +1579,14 @@ type atModifierTestCase struct {
evalTime time.Time
}
// parserForBuiltinTests is the parser used when parsing expressions in the
// built-in test framework (e.g. atModifierTestCases). It must match the Parser
// used by NewTestEngine so that expressions parse consistently.
var parserForBuiltinTests = parser.NewParser(TestParserOpts)
var (
// parserForBuiltinTests is the parser used when parsing expressions in the
// built-in test framework (e.g. atModifierTestCases). It must match the Parser
// used by NewTestEngine so that expressions parse consistently.
parserForBuiltinTests = parser.NewParser(TestParserOpts)
// reQueryContextFuncs matches start(), end(), range(), and step() calls, which depend on query context.
reQueryContextFuncs = regexp.MustCompile(`(start|end|range|step)\(\)`)
)
func atModifierTestCases(exprStr string, evalTime time.Time) ([]atModifierTestCase, error) {
expr, err := parserForBuiltinTests.ParseExpr(exprStr)
@@ -1794,8 +1798,8 @@ func (t *test) runInstantQuery(iq atModifierTestCase, cmd *evalCmd, engine promq
// Check query returns same result in range mode,
// by checking against the middle step.
// Skip this check for queries containing range() since it would resolve differently.
if strings.Contains(iq.expr, "range()") {
// Skip this check for queries containing range(), step(), start(), or end() since they would resolve differently.
if reQueryContextFuncs.MatchString(iq.expr) {
return nil
}
q, err = engine.NewRangeQuery(t.context, t.storage, nil, iq.expr, iq.evalTime.Add(-time.Minute), iq.evalTime.Add(time.Minute), time.Minute)

View File

@@ -2049,3 +2049,141 @@ eval_fail instant at 0 label_replace(overlap, "idx", "same", "idx", ".*")
# Test label_join failure with overlapping timestamps (same labelset at same time).
eval_fail instant at 0 label_join(overlap, "idx", ",", "label", "label")
# Tests for step() and range() functions.
clear
# Test step() function in range queries.
eval range from 0 to 10s step 5s step()
{} 5 5 5
eval range from 0 to 20s step 10s step()
{} 10 10 10
eval range from 0 to 30s step 15s step()
{} 15 15 15
# Test range() function in range queries.
eval range from 0 to 30s step 10s range()
{} 30 30 30 30
eval range from 0s to 60s step 20s range()
{} 60 60 60 60
eval range from 10s to 50s step 10s range()
{} 40 40 40 40 40
# Test step() function in instant queries.
eval instant at 0s step()
0
# Test range() function in instant queries.
eval instant at 0s range()
0
eval instant at 100s range()
0
# Test step() and range() in expressions.
eval range from 0 to 10s step 5s vector(step())
{} 5 5 5
eval range from 0 to 20s step 10s vector(range())
{} 20 20 20
# Test step() and range() with arithmetic.
load 1s
metric 1 2 3 4 5
eval range from 0s to 4s step 1s metric * step()
{} 1 2 3 4 5
eval range from 0s to 2s step 1s metric + range()
{} 3 4 5
# Test that step() returns the step value, not evaluation timestamp.
eval range from 100s to 110s step 5s step()
{} 5 5 5
# Test that range() returns the query range, not evaluation timestamp.
eval range from 100s to 130s step 10s range()
{} 30 30 30 30
# Tests for start() and end() functions.
# Test start() function in range queries.
eval range from 0 to 30s step 10s start()
{} 0 0 0 0
eval range from 100s to 130s step 10s start()
{} 100 100 100 100
eval range from 50s to 60s step 5s start()
{} 50 50 50
# Test end() function in range queries.
eval range from 0 to 30s step 10s end()
{} 30 30 30 30
eval range from 100s to 140s step 10s end()
{} 140 140 140 140 140
eval range from 20s to 50s step 10s end()
{} 50 50 50 50
# Test start() function in instant queries (start == end).
eval instant at 0s start()
0
eval instant at 100s start()
100
# Test end() function in instant queries (start == end).
eval instant at 0s end()
0
eval instant at 50s end()
50
# Test start() and end() in expressions.
eval range from 0 to 20s step 10s vector(start())
{} 0 0 0
eval range from 10s to 30s step 10s vector(end())
{} 30 30 30
# Test that start() and end() return query boundaries, not evaluation timestamps.
eval range from 100s to 130s step 10s start()
{} 100 100 100 100
eval range from 100s to 130s step 10s end()
{} 130 130 130 130
# Test start() and end() with arithmetic.
eval range from 10s to 20s step 5s end() - start()
{} 10 10 10
eval range from 0s to 30s step 10s (end() + start()) / 2
{} 15 15 15 15
# Test combination with step() and range().
eval range from 0s to 20s step 10s start() + range()
{} 20 20 20
eval range from 10s to 50s step 10s end() - range()
{} 10 10 10 10 10
# Test start() and end() with @ modifier in selector context.
# Range queries with @ start() and @ end() work correctly.
load 1s
metric_for_at 1 2 3 4 5 6 7 8 9 10
# For range query from 0s to 9s: start()=0, end()=9
# metric_for_at @ 0 = 1, metric_for_at @ 9 = 10
eval range from 0s to 9s step 3s metric_for_at @ start()
{__name__="metric_for_at"} 1 1 1 1
eval range from 1s to 9s step 2s metric_for_at @ end()
{__name__="metric_for_at"} 10 10 10 10 10

View File

@@ -1102,6 +1102,22 @@ const funcDocs: Record<string, React.ReactNode> = {
</p>
</>
),
end: (
<>
<p>
<strong>
This function has to be enabled via the{" "}
<a href="../feature_flags.md#experimental-promql-functions">feature flag</a>
<code>--enable-feature=promql-experimental-functions</code>.
</strong>
</p>
<p>
<code>end()</code> returns the end timestamp of the current query range evaluation as the number of seconds
since January 1, 1970 UTC. For instant queries, this is equal to the evaluation timestamp.
</p>
</>
),
exp: (
<>
<p>
@@ -2818,6 +2834,22 @@ const funcDocs: Record<string, React.ReactNode> = {
</ul>
</>
),
range: (
<>
<p>
<strong>
This function has to be enabled via the{" "}
<a href="../feature_flags.md#experimental-promql-functions">feature flag</a>
<code>--enable-feature=promql-experimental-functions</code>.
</strong>
</p>
<p>
<code>range()</code> returns the range duration of the current query range evaluation in seconds and is
equivalent to <code>end() - start()</code>. For instant queries, this returns <code>0</code>.
</p>
</>
),
rate: (
<>
<p>
@@ -3120,6 +3152,22 @@ const funcDocs: Record<string, React.ReactNode> = {
</p>
</>
),
start: (
<>
<p>
<strong>
This function has to be enabled via the{" "}
<a href="../feature_flags.md#experimental-promql-functions">feature flag</a>
<code>--enable-feature=promql-experimental-functions</code>.
</strong>
</p>
<p>
<code>start()</code> returns the start timestamp of the current query range evaluation as the number of seconds
since January 1, 1970 UTC. For instant queries, this is equal to the evaluation timestamp.
</p>
</>
),
stddev_over_time: (
<>
<p>
@@ -3340,6 +3388,22 @@ const funcDocs: Record<string, React.ReactNode> = {
</p>
</>
),
step: (
<>
<p>
<strong>
This function has to be enabled via the{" "}
<a href="../feature_flags.md#experimental-promql-functions">feature flag</a>
<code>--enable-feature=promql-experimental-functions</code>.
</strong>
</p>
<p>
<code>step()</code> returns the query resolution step as the number of seconds. For instant queries, this
returns <code>0</code>.
</p>
</>
),
sum_over_time: (
<>
<p>

View File

@@ -52,6 +52,7 @@ export const functionSignatures: Record<string, Func> = {
variadic: 0,
returnType: valueType.vector,
},
end: { name: "end", argTypes: [], variadic: 0, returnType: valueType.scalar },
exp: { name: "exp", argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector },
first_over_time: { name: "first_over_time", argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector },
floor: { name: "floor", argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector },
@@ -134,6 +135,7 @@ export const functionSignatures: Record<string, Func> = {
returnType: valueType.vector,
},
rad: { name: "rad", argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector },
range: { name: "range", argTypes: [], variadic: 0, returnType: valueType.scalar },
rate: { name: "rate", argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector },
resets: { name: "resets", argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector },
round: { name: "round", argTypes: [valueType.vector, valueType.scalar], variadic: 1, returnType: valueType.vector },
@@ -156,6 +158,7 @@ export const functionSignatures: Record<string, Func> = {
},
sort_desc: { name: "sort_desc", argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector },
sqrt: { name: "sqrt", argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector },
start: { name: "start", argTypes: [], variadic: 0, returnType: valueType.scalar },
stddev_over_time: {
name: "stddev_over_time",
argTypes: [valueType.matrix],
@@ -168,6 +171,7 @@ export const functionSignatures: Record<string, Func> = {
variadic: 0,
returnType: valueType.vector,
},
step: { name: "step", argTypes: [], variadic: 0, returnType: valueType.scalar },
sum_over_time: { name: "sum_over_time", argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector },
tan: { name: "tan", argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector },
tanh: { name: "tanh", argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector },