Files
ismail simsek 7ae2eed876 Prometheus: Make sure "Min Step" has precedence (#115941)
* set minStep value as final step value when set explicitly.

* enhance it with tests

* improve function readability

* a bit more improvement for readability
2026-01-15 00:50:10 +01:00

501 lines
18 KiB
Go

package models
import (
"context"
"embed"
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
"time"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/gtime"
sdkapi "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
scope "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1"
glog "github.com/grafana/grafana-plugin-sdk-go/backend/log"
"github.com/grafana/grafana/pkg/promlib/intervalv2"
)
// PromQueryFormat defines model for PromQueryFormat.
// +enum
type PromQueryFormat string
const (
PromQueryFormatTimeSeries PromQueryFormat = "time_series"
PromQueryFormatTable PromQueryFormat = "table"
PromQueryFormatHeatmap PromQueryFormat = "heatmap"
)
// QueryEditorMode defines model for QueryEditorMode.
// +enum
type QueryEditorMode string
const (
QueryEditorModeBuilder QueryEditorMode = "builder"
QueryEditorModeCode QueryEditorMode = "code"
)
// PrometheusQueryProperties defines the specific properties used for prometheus
type PrometheusQueryProperties struct {
// The response format
Format PromQueryFormat `json:"format,omitempty"`
// The actual expression/query that will be evaluated by Prometheus
Expr string `json:"expr"`
// Returns a Range vector, comprised of a set of time series containing a range of data points over time for each time series
Range bool `json:"range,omitempty"`
// Returns only the latest value that Prometheus has scraped for the requested time series
Instant bool `json:"instant,omitempty"`
// Execute an additional query to identify interesting raw samples relevant for the given expr
Exemplar bool `json:"exemplar,omitempty"`
// what we should show in the editor
EditorMode QueryEditorMode `json:"editorMode,omitempty"`
// Used to specify how many times to divide max data points by. We use max data points under query options
// See https://github.com/grafana/grafana/issues/48081
// Deprecated: use interval
IntervalFactor int64 `json:"intervalFactor,omitempty"`
// Series name override or template. Ex. {{hostname}} will be replaced with label value for hostname
LegendFormat string `json:"legendFormat,omitempty"`
// A set of filters applied to apply to the query
Scopes []scope.ScopeSpec `json:"scopes,omitempty"`
// Additional Ad-hoc filters that take precedence over Scope on conflict.
AdhocFilters []scope.ScopeFilter `json:"adhocFilters,omitempty"`
// Group By parameters to apply to aggregate expressions in the query
GroupByKeys []string `json:"groupByKeys,omitempty"`
}
// Internal interval and range variables
const (
varInterval = "$__interval"
varIntervalMs = "$__interval_ms"
varRange = "$__range"
varRangeS = "$__range_s"
varRangeMs = "$__range_ms"
varRateInterval = "$__rate_interval"
varRateIntervalMs = "$__rate_interval_ms"
)
// Internal interval and range variables with {} syntax
const (
varIntervalAlt = "${__interval}"
varIntervalMsAlt = "${__interval_ms}"
varRangeAlt = "${__range}"
varRangeSAlt = "${__range_s}"
varRangeMsAlt = "${__range_ms}"
varRateIntervalAlt = "${__rate_interval}"
varRateIntervalMsAlt = "${__rate_interval_ms}"
)
type TimeSeriesQueryType string
const (
RangeQueryType TimeSeriesQueryType = "range"
InstantQueryType TimeSeriesQueryType = "instant"
ExemplarQueryType TimeSeriesQueryType = "exemplar"
UnknownQueryType TimeSeriesQueryType = "unknown"
)
// safeResolution is the maximum number of data points to prevent excessive resolution.
// This ensures queries don't exceed reasonable data point limits, improving performance
// and preventing potential memory issues. The value of 11000 provides a good balance
// between resolution and performance for most use cases.
var safeResolution = 11000
// rateIntervalMultiplier is the minimum multiplier for rate interval calculation.
// Rate intervals should be at least 4x the scrape interval to ensure accurate rate calculations.
const rateIntervalMultiplier = 4
// QueryModel includes both the common and specific values
// NOTE: this struct may have issues when decoding JSON that requires the special handling
// registered in https://github.com/grafana/grafana-plugin-sdk-go/blob/v0.228.0/experimental/apis/data/v0alpha1/query.go#L298
type QueryModel struct {
PrometheusQueryProperties `json:",inline"`
sdkapi.CommonQueryProperties `json:",inline"`
// The following properties may be part of the request payload, however they are not saved in panel JSON
// Timezone offset to align start & end time on backend
UtcOffsetSec int64 `json:"utcOffsetSec,omitempty"`
Interval string `json:"interval,omitempty"`
}
type TimeRange struct {
Start time.Time
End time.Time
Step time.Duration
}
// The internal query object
type Query struct {
Expr string
Step time.Duration
LegendFormat string
Start time.Time
End time.Time
RefId string
InstantQuery bool
RangeQuery bool
ExemplarQuery bool
UtcOffsetSec int64
Scopes []scope.ScopeSpec
}
// This internal query struct is just like QueryModel, except it does not include:
// sdkapi.CommonQueryProperties -- this avoids errors where the unused "datasource" property
// may be either a string or DataSourceRef
type internalQueryModel struct {
PrometheusQueryProperties `json:",inline"`
// sdkapi.CommonQueryProperties `json:",inline"`
IntervalMS float64 `json:"intervalMs,omitempty"`
// The following properties may be part of the request payload, however they are not saved in panel JSON
// Timezone offset to align start & end time on backend
UtcOffsetSec int64 `json:"utcOffsetSec,omitempty"`
Interval string `json:"interval,omitempty"`
}
func Parse(ctx context.Context, log glog.Logger, span trace.Span, query backend.DataQuery, dsScrapeInterval string, intervalCalculator intervalv2.Calculator, fromAlert bool) (*Query, error) {
model := &internalQueryModel{}
if err := json.Unmarshal(query.JSON, model); err != nil {
return nil, backend.DownstreamErrorf("error unmarshaling query: %w", err)
}
span.SetAttributes(attribute.String("rawExpr", model.Expr))
// Final step value for prometheus
calculatedStep, err := calculatePrometheusInterval(model.Interval, dsScrapeInterval, int64(model.IntervalMS), model.IntervalFactor, query, intervalCalculator)
if err != nil {
return nil, err
}
// Interpolate variables in expr
timeRange := query.TimeRange.To.Sub(query.TimeRange.From)
expr := InterpolateVariables(
model.Expr,
query.Interval,
calculatedStep,
model.Interval,
dsScrapeInterval,
timeRange,
)
var scopeFilters []scope.ScopeFilter
for _, scope := range model.Scopes {
scopeFilters = append(scopeFilters, scope.Filters...)
}
if len(scopeFilters) > 0 {
span.SetAttributes(attribute.StringSlice("scopeFilters", func() []string {
var filters []string
for _, f := range scopeFilters {
filters = append(filters, fmt.Sprintf("%q %q %q", f.Key, f.Operator, f.Value))
}
return filters
}()))
}
if len(model.AdhocFilters) > 0 {
span.SetAttributes(attribute.StringSlice("adhocFilters", func() []string {
var filters []string
for _, f := range model.AdhocFilters {
filters = append(filters, fmt.Sprintf("%q %q %q", f.Key, f.Operator, f.Value))
}
return filters
}()))
}
if len(scopeFilters) > 0 || len(model.AdhocFilters) > 0 || len(model.GroupByKeys) > 0 {
log.Info("Applying scope filters", "scopeFiltersCount", len(scopeFilters), "adhocFiltersCount", len(model.AdhocFilters), "groupByKeysCount", len(model.GroupByKeys))
expr, err = ApplyFiltersAndGroupBy(expr, scopeFilters, model.AdhocFilters, model.GroupByKeys)
if err != nil {
return nil, err
}
}
if !model.Instant && !model.Range {
// In older dashboards, we were not setting range query param and !range && !instant was run as range query
model.Range = true
}
// We never want to run exemplar query for alerting
if fromAlert {
model.Exemplar = false
}
span.SetAttributes(
attribute.String("expr", expr),
attribute.Int64("start_unixnano", query.TimeRange.From.UnixNano()),
attribute.Int64("stop_unixnano", query.TimeRange.To.UnixNano()),
)
return &Query{
Expr: expr,
Step: calculatedStep,
LegendFormat: model.LegendFormat,
Start: query.TimeRange.From,
End: query.TimeRange.To,
RefId: query.RefID,
InstantQuery: model.Instant,
RangeQuery: model.Range,
ExemplarQuery: model.Exemplar,
UtcOffsetSec: model.UtcOffsetSec,
}, nil
}
func (query *Query) Type() TimeSeriesQueryType {
if query.InstantQuery {
return InstantQueryType
}
if query.RangeQuery {
return RangeQueryType
}
if query.ExemplarQuery {
return ExemplarQueryType
}
return UnknownQueryType
}
func (query *Query) TimeRange() TimeRange {
return TimeRange{
Step: query.Step,
// Align query range to step. It rounds start and end down to a multiple of step.
Start: AlignTimeRange(query.Start, query.Step, query.UtcOffsetSec),
End: AlignTimeRange(query.End, query.Step, query.UtcOffsetSec),
}
}
// isRateIntervalVariable checks if the interval string is a rate interval variable
// ($__rate_interval, ${__rate_interval}, $__rate_interval_ms, or ${__rate_interval_ms})
func isRateIntervalVariable(interval string) bool {
return interval == varRateInterval ||
interval == varRateIntervalAlt ||
interval == varRateIntervalMs ||
interval == varRateIntervalMsAlt
}
// replaceVariable replaces both $__variable and ${__variable} formats in the expression
func replaceVariable(expr, dollarFormat, altFormat, replacement string) string {
expr = strings.ReplaceAll(expr, dollarFormat, replacement)
expr = strings.ReplaceAll(expr, altFormat, replacement)
return expr
}
// isManualIntervalOverride checks if the interval is a manually specified non-variable value
// that should override the calculated interval
func isManualIntervalOverride(interval string) bool {
return interval != "" &&
interval != varInterval &&
interval != varIntervalAlt &&
interval != varIntervalMs &&
interval != varIntervalMsAlt
}
// maxDuration returns the maximum of two durations
func maxDuration(a, b time.Duration) time.Duration {
if a > b {
return a
}
return b
}
// normalizeIntervalFactor ensures intervalFactor is at least 1
func normalizeIntervalFactor(factor int64) int64 {
if factor == 0 {
return 1
}
return factor
}
// calculatePrometheusInterval calculates the optimal step interval for a Prometheus query.
//
// The function determines the query step interval by considering multiple factors:
// - The minimum step specified in the query (queryInterval)
// - The data source scrape interval (dsScrapeInterval)
// - The requested interval in milliseconds (intervalMs)
// - The time range and maximum data points from the query
// - The interval factor multiplier
//
// Special handling:
// - Variable intervals ($__interval, $__rate_interval, etc.) are replaced with calculated values
// - Rate interval variables ($__rate_interval, ${__rate_interval}) use calculateRateInterval for proper rate() function support
// - Manual interval overrides (non-variable strings) take precedence over calculated values
// - The final interval ensures safe resolution limits are not exceeded
//
// Parameters:
// - queryInterval: The minimum step interval string (may contain variables like $__interval or $__rate_interval)
// - dsScrapeInterval: The data source scrape interval (e.g., "15s", "30s")
// - intervalMs: The requested interval in milliseconds
// - intervalFactor: Multiplier for the calculated interval (defaults to 1 if 0)
// - query: The backend data query containing time range and max data points
// - intervalCalculator: Calculator for determining optimal intervals
//
// Returns:
// - The calculated step interval as a time.Duration
// - An error if the interval cannot be calculated (e.g., invalid interval string)
func calculatePrometheusInterval(
queryInterval, dsScrapeInterval string,
intervalMs, intervalFactor int64,
query backend.DataQuery,
intervalCalculator intervalv2.Calculator,
) (time.Duration, error) {
// Preserve the original interval for later comparison, as it may be modified below
originalQueryInterval := queryInterval
// If we are using a variable for minStep, replace it with empty string
// so that the interval calculation proceeds with the default logic
if isVariableInterval(queryInterval) {
queryInterval = ""
}
// Get the minimum interval from various sources (dsScrapeInterval, queryInterval, intervalMs)
minInterval, err := gtime.GetIntervalFrom(dsScrapeInterval, queryInterval, intervalMs, 15*time.Second)
if err != nil {
return time.Duration(0), err
}
// Calculate the optimal interval based on time range and max data points
calculatedInterval := intervalCalculator.Calculate(query.TimeRange, minInterval, query.MaxDataPoints)
// Calculate the safe interval to prevent too many data points
safeInterval := intervalCalculator.CalculateSafeInterval(query.TimeRange, int64(safeResolution))
// Use the larger of calculated or safe interval to ensure we don't exceed resolution limits
adjustedInterval := maxDuration(calculatedInterval.Value, safeInterval.Value)
// Handle rate interval variables: these require special calculation
if isRateIntervalVariable(originalQueryInterval) {
// Rate interval is final and is not affected by resolution
return calculateRateInterval(adjustedInterval, dsScrapeInterval), nil
}
// Handle manual interval override: if user specified a non-variable interval,
// it takes precedence over calculated values
if isManualIntervalOverride(originalQueryInterval) {
if parsedInterval, err := gtime.ParseIntervalStringToTimeDuration(originalQueryInterval); err == nil {
return parsedInterval, nil
}
// If parsing fails, fall through to calculated interval with factor
}
// Apply interval factor to the adjusted interval
normalizedFactor := normalizeIntervalFactor(intervalFactor)
return time.Duration(int64(adjustedInterval) * normalizedFactor), nil
}
// calculateRateInterval calculates the $__rate_interval value
// queryInterval is the value calculated range / maxDataPoints on the frontend
// queryInterval is shown on the Query Options Panel above the query editor
// requestedMinStep is the data source scrape interval (default 15s)
// requestedMinStep can be changed by setting "Min Step" value in Options panel below the code editor
func calculateRateInterval(
queryInterval time.Duration,
requestedMinStep string,
) time.Duration {
scrape := requestedMinStep
if scrape == "" {
scrape = "15s"
}
scrapeIntervalDuration, err := gtime.ParseIntervalStringToTimeDuration(scrape)
if err != nil {
return time.Duration(0)
}
minRateInterval := rateIntervalMultiplier * scrapeIntervalDuration
rateInterval := maxDuration(queryInterval+scrapeIntervalDuration, minRateInterval)
return rateInterval
}
// InterpolateVariables interpolates built-in variables
// expr PromQL query
// queryInterval Requested interval in milliseconds. This value may be overridden by MinStep in query options
// calculatedStep Calculated final step value. It was calculated in calculatePrometheusInterval
// requestedMinStep Requested minimum step value. QueryModel.interval
// dsScrapeInterval Data source scrape interval in the config
// timeRange Requested time range for query
func InterpolateVariables(
expr string,
queryInterval time.Duration,
calculatedStep time.Duration,
requestedMinStep string,
dsScrapeInterval string,
timeRange time.Duration,
) string {
rangeMs := timeRange.Milliseconds()
rangeSRounded := int64(math.Round(float64(rangeMs) / 1000.0))
var rateInterval time.Duration
if requestedMinStep == varRateInterval || requestedMinStep == varRateIntervalAlt {
rateInterval = calculatedStep
} else {
if requestedMinStep == varInterval || requestedMinStep == varIntervalAlt {
requestedMinStep = calculatedStep.String()
}
if requestedMinStep == "" {
requestedMinStep = dsScrapeInterval
}
rateInterval = calculateRateInterval(queryInterval, requestedMinStep)
}
// Replace interval variables (both $__var and ${__var} formats)
expr = replaceVariable(expr, varIntervalMs, varIntervalMsAlt, strconv.FormatInt(int64(calculatedStep/time.Millisecond), 10))
expr = replaceVariable(expr, varInterval, varIntervalAlt, gtime.FormatInterval(calculatedStep))
// Replace range variables (both $__var and ${__var} formats)
expr = replaceVariable(expr, varRangeMs, varRangeMsAlt, strconv.FormatInt(rangeMs, 10))
expr = replaceVariable(expr, varRangeS, varRangeSAlt, strconv.FormatInt(rangeSRounded, 10))
expr = replaceVariable(expr, varRange, varRangeAlt, strconv.FormatInt(rangeSRounded, 10)+"s")
// Replace rate interval variables (both $__var and ${__var} formats)
expr = replaceVariable(expr, varRateIntervalMs, varRateIntervalMsAlt, strconv.FormatInt(int64(rateInterval/time.Millisecond), 10))
expr = replaceVariable(expr, varRateInterval, varRateIntervalAlt, rateInterval.String())
return expr
}
// isVariableInterval checks if the interval string is a variable interval
// (any of $__interval, ${__interval}, $__interval_ms, ${__interval_ms}, $__rate_interval, ${__rate_interval}, etc.)
func isVariableInterval(interval string) bool {
return interval == varInterval ||
interval == varIntervalAlt ||
interval == varIntervalMs ||
interval == varIntervalMsAlt ||
interval == varRateInterval ||
interval == varRateIntervalAlt ||
interval == varRateIntervalMs ||
interval == varRateIntervalMsAlt
}
// AlignTimeRange aligns query range to step and handles the time offset.
// It rounds start and end down to a multiple of step.
// Prometheus caching is dependent on the range being aligned with the step.
// Rounding to the step can significantly change the start and end of the range for larger steps, i.e. a week.
// In rounding the range to a 1w step the range will always start on a Thursday.
func AlignTimeRange(t time.Time, step time.Duration, offset int64) time.Time {
offsetNano := float64(offset * 1e9)
stepNano := float64(step.Nanoseconds())
return time.Unix(0, int64(math.Floor((float64(t.UnixNano())+offsetNano)/stepNano)*stepNano-offsetNano)).UTC()
}
//go:embed query.types.json
var f embed.FS
// QueryTypeDefinitionListJSON returns the query type definitions
func QueryTypeDefinitionListJSON() (json.RawMessage, error) {
return f.ReadFile("query.types.json")
}