Alerting: Add threshold expression (#55102)

This commit is contained in:
Gilles De Mey
2022-09-26 16:05:44 +02:00
committed by GitHub
parent f7a3e50b23
commit 9aa61ddd0e
8 changed files with 515 additions and 1 deletions
+4
View File
@@ -289,6 +289,8 @@ const (
TypeResample
// TypeClassicConditions is the CMDType for the classic condition operation.
TypeClassicConditions
// TypeThreshold is the CMDType for checking if a threshold has been crossed
TypeThreshold
)
func (gt CommandType) String() string {
@@ -317,6 +319,8 @@ func ParseCommandType(s string) (CommandType, error) {
return TypeResample, nil
case "classic_conditions":
return TypeClassicConditions, nil
case "threshold":
return TypeThreshold, nil
default:
return TypeUnknown, fmt.Errorf("'%v' is not a recognized expression type", s)
}
+2
View File
@@ -119,6 +119,8 @@ func buildCMDNode(dp *simple.DirectedGraph, rn *rawNode) (*CMDNode, error) {
node.Command, err = UnmarshalResampleCommand(rn)
case TypeClassicConditions:
node.Command, err = classic.UnmarshalConditionsCmd(rn.Query, rn.RefID)
case TypeThreshold:
node.Command, err = UnmarshalThresholdCommand(rn)
default:
return nil, fmt.Errorf("expression command type '%v' in expression '%v' not implemented", commandType, rn.RefID)
}
+131
View File
@@ -0,0 +1,131 @@
package expr
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/grafana/grafana/pkg/expr/mathexp"
)
type ThresholdCommand struct {
ReferenceVar string
RefID string
ThresholdFunc string
Conditions []float64
}
const (
ThresholdIsAbove = "gt"
ThresholdIsBelow = "lt"
ThresholdIsWithinRange = "within_range"
ThresholdIsOutsideRange = "outside_range"
)
var (
supportedThresholdFuncs = []string{ThresholdIsAbove, ThresholdIsBelow, ThresholdIsWithinRange, ThresholdIsOutsideRange}
)
func NewThresholdCommand(refID, referenceVar, thresholdFunc string, conditions []float64) (*ThresholdCommand, error) {
return &ThresholdCommand{
RefID: refID,
ReferenceVar: referenceVar,
ThresholdFunc: thresholdFunc,
Conditions: conditions,
}, nil
}
type ThresholdConditionJSON struct {
Evaluator ConditionEvalJSON `json:"evaluator"`
}
type ConditionEvalJSON struct {
Params []float64 `json:"params"`
Type string `json:"type"` // e.g. "gt"
}
// UnmarshalResampleCommand creates a ResampleCMD from Grafana's frontend query.
func UnmarshalThresholdCommand(rn *rawNode) (*ThresholdCommand, error) {
rawQuery := rn.Query
rawExpression, ok := rawQuery["expression"]
if !ok {
return nil, fmt.Errorf("no variable specified to reference for refId %v", rn.RefID)
}
referenceVar, ok := rawExpression.(string)
if !ok {
return nil, fmt.Errorf("expected threshold variable to be a string, got %T for refId %v", rawExpression, rn.RefID)
}
jsonFromM, err := json.Marshal(rawQuery["conditions"])
if err != nil {
return nil, fmt.Errorf("failed to remarshal threshold expression body: %w", err)
}
var conditions []ThresholdConditionJSON
if err = json.Unmarshal(jsonFromM, &conditions); err != nil {
return nil, fmt.Errorf("failed to unmarshal remarshaled threshold expression body: %w", err)
}
for _, condition := range conditions {
if !IsSupportedThresholdFunc(condition.Evaluator.Type) {
return nil, fmt.Errorf("expected threshold function to be one of %s, got %s", strings.Join(supportedThresholdFuncs, ", "), condition.Evaluator.Type)
}
}
// we only support one condition for now, we might want to turn this in to "OR" expressions later
if len(conditions) != 1 {
return nil, fmt.Errorf("threshold expression requires exactly one condition")
}
firstCondition := conditions[0]
return NewThresholdCommand(rn.RefID, referenceVar, firstCondition.Evaluator.Type, firstCondition.Evaluator.Params)
}
// NeedsVars returns the variable names (refIds) that are dependencies
// to execute the command and allows the command to fulfill the Command interface.
func (tc *ThresholdCommand) NeedsVars() []string {
return []string{tc.ReferenceVar}
}
func (tc *ThresholdCommand) Execute(ctx context.Context, vars mathexp.Vars) (mathexp.Results, error) {
mathExpression, err := createMathExpression(tc.ReferenceVar, tc.ThresholdFunc, tc.Conditions)
if err != nil {
return mathexp.Results{}, err
}
mathCommand, err := NewMathCommand(tc.ReferenceVar, mathExpression)
if err != nil {
return mathexp.Results{}, err
}
return mathCommand.Execute(ctx, vars)
}
// createMathExpression converts all the info we have about a "threshold" expression in to a Math expression
func createMathExpression(referenceVar string, thresholdFunc string, args []float64) (string, error) {
switch thresholdFunc {
case ThresholdIsAbove:
return fmt.Sprintf("${%s} > %f", referenceVar, args[0]), nil
case ThresholdIsBelow:
return fmt.Sprintf("${%s} < %f", referenceVar, args[0]), nil
case ThresholdIsWithinRange:
return fmt.Sprintf("${%s} > %f && ${%s} < %f", referenceVar, args[0], referenceVar, args[1]), nil
case ThresholdIsOutsideRange:
return fmt.Sprintf("${%s} < %f || ${%s} > %f", referenceVar, args[0], referenceVar, args[1]), nil
default:
return "", fmt.Errorf("failed to evaluate threshold expression: no such threshold function %s", thresholdFunc)
}
}
func IsSupportedThresholdFunc(name string) bool {
isSupported := false
for _, funcName := range supportedThresholdFuncs {
if funcName == name {
isSupported = true
}
}
return isSupported
}
+212
View File
@@ -0,0 +1,212 @@
package expr
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestNewThresholdCommand(t *testing.T) {
cmd, err := NewThresholdCommand("B", "A", "is_above", []float64{})
require.Nil(t, err)
require.NotNil(t, cmd)
}
func TestUnmarshalThresholdCommand(t *testing.T) {
type testCase struct {
description string
query string
shouldError bool
expectedError string
}
cases := []testCase{
{
description: "unmarshal proper object",
query: `{
"expression" : "A",
"type": "threshold",
"conditions": [{
"evaluator": {
"type": "gt",
"params": [20, 80]
}
}]
}`,
shouldError: false,
},
{
description: "unmarshal with missing conditions should error",
query: `{
"expression" : "A",
"type": "threshold",
"conditions": []
}`,
shouldError: true,
expectedError: "requires exactly one condition",
},
{
description: "unmarshal with missing conditions should error",
query: `{
"expression" : "A",
"type": "threshold",
"conditions": []
}`,
shouldError: true,
expectedError: "requires exactly one condition",
},
{
description: "unmarshal with unsupported threshold function",
query: `{
"expression" : "A",
"type": "threshold",
"conditions": [{
"evaluator": {
"type": "foo",
"params": [20, 80]
}
}]
}`,
shouldError: true,
expectedError: "expected threshold function to be one of",
},
{
description: "unmarshal with bad expression",
query: `{
"expression" : 0,
"type": "threshold",
"conditions": []
}`,
shouldError: true,
expectedError: "expected threshold variable to be a string",
},
}
for _, tc := range cases {
q := []byte(tc.query)
var qmap = make(map[string]interface{})
require.NoError(t, json.Unmarshal(q, &qmap))
cmd, err := UnmarshalThresholdCommand(&rawNode{
RefID: "",
Query: qmap,
QueryType: "",
TimeRange: TimeRange{},
DataSource: nil,
})
if tc.shouldError {
require.Nil(t, cmd)
require.NotNil(t, err)
require.Contains(t, err.Error(), tc.expectedError)
} else {
require.Nil(t, err)
require.NotNil(t, cmd)
}
}
}
func TestThresholdCommandVars(t *testing.T) {
cmd, err := NewThresholdCommand("B", "A", "is_above", []float64{})
require.Nil(t, err)
require.Equal(t, cmd.NeedsVars(), []string{"A"})
}
func TestCreateMathExpression(t *testing.T) {
type testCase struct {
description string
expected string
ref string
function string
params []float64
}
cases := []testCase{
{
description: "is above",
ref: "My Ref",
function: "gt",
params: []float64{0},
expected: "${My Ref} > 0.000000",
},
{
description: "is below",
ref: "A",
function: "lt",
params: []float64{0},
expected: "${A} < 0.000000",
},
{
description: "is within",
ref: "B",
function: "within_range",
params: []float64{20, 80},
expected: "${B} > 20.000000 && ${B} < 80.000000",
},
{
description: "is outside",
ref: "B",
function: "outside_range",
params: []float64{20, 80},
expected: "${B} < 20.000000 || ${B} > 80.000000",
},
}
for _, tc := range cases {
t.Run(tc.description, func(t *testing.T) {
expr, err := createMathExpression(tc.ref, tc.function, tc.params)
require.Nil(t, err)
require.NotNil(t, expr)
require.Equal(t, expr, tc.expected)
})
}
t.Run("should error if function is unsupported", func(t *testing.T) {
expr, err := createMathExpression("A", "foo", []float64{0})
require.Equal(t, expr, "")
require.NotNil(t, err)
require.Contains(t, err.Error(), "no such threshold function")
})
}
func TestIsSupportedThresholdFunc(t *testing.T) {
type testCase struct {
function string
supported bool
}
cases := []testCase{
{
function: ThresholdIsAbove,
supported: true,
},
{
function: ThresholdIsBelow,
supported: true,
},
{
function: ThresholdIsWithinRange,
supported: true,
},
{
function: ThresholdIsOutsideRange,
supported: true,
},
{
function: "foo",
supported: false,
},
}
for _, tc := range cases {
t.Run(tc.function, func(t *testing.T) {
supported := IsSupportedThresholdFunc(tc.function)
require.Equal(t, supported, tc.supported)
})
}
}