Alerting: Fix stale values associated with states that have gone to NoData, unify values calculation (#89807)

* Unify values

* Fix with latest changes on main

* Fix up NaN test

* Keep refIDs with -1 as value

* Test that refIDs are preserved on Normal to Error transition

* Alerting to err test too

* Add a blurb to docs about this behavior
This commit is contained in:
Alexander Weaver 2024-07-08 12:30:23 -05:00 committed by GitHub
parent f763f2085b
commit 3b6a8775bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 209 additions and 82 deletions

View File

@ -269,6 +269,8 @@ You can also configure the alert instance state when its evaluation returns an e
| Normal | Sets alert instance state to `Normal`. | | Normal | Sets alert instance state to `Normal`. |
| Keep Last State | Maintains the alert instance in its last state. Useful for mitigating temporary issues, refer to [Keep last state](ref:keep-last-state). | | Keep Last State | Maintains the alert instance in its last state. Useful for mitigating temporary issues, refer to [Keep last state](ref:keep-last-state). |
When you configure the No data or Error behavior to `Alerting` or `Normal`, Grafana will attempt to keep a stable set of fields under notification `Values`. If your query returns no data or an error, Grafana re-uses the latest known set of fields in `Values`, but will use `-1` in place of the measured value.
## Create alerts from panels ## Create alerts from panels
Create alerts from any panel type. This means you can reuse the queries in the panel and create alerts based on them. Create alerts from any panel type. This means you can reuse the queries in the panel and create alerts based on them.

View File

@ -48,7 +48,7 @@ func Test_FormatValues(t *testing.T) {
name: "with no value, it renders the evaluation string", name: "with no value, it renders the evaluation string",
alertState: &state.State{ alertState: &state.State{
LastEvaluationString: "[ var='A' metric='vector(10) + time() % 50' labels={} value=1.1 ]", LastEvaluationString: "[ var='A' metric='vector(10) + time() % 50' labels={} value=1.1 ]",
LatestResult: &state.Evaluation{Condition: "A", Values: map[string]*float64{}}, LatestResult: &state.Evaluation{Condition: "A", Values: map[string]float64{}},
}, },
expected: "[ var='A' metric='vector(10) + time() % 50' labels={} value=1.1 ]", expected: "[ var='A' metric='vector(10) + time() % 50' labels={} value=1.1 ]",
}, },
@ -56,7 +56,7 @@ func Test_FormatValues(t *testing.T) {
name: "with one value, it renders the single value", name: "with one value, it renders the single value",
alertState: &state.State{ alertState: &state.State{
LastEvaluationString: "[ var='A' metric='vector(10) + time() % 50' labels={} value=1.1 ]", LastEvaluationString: "[ var='A' metric='vector(10) + time() % 50' labels={} value=1.1 ]",
LatestResult: &state.Evaluation{Condition: "A", Values: map[string]*float64{"A": &val1}}, LatestResult: &state.Evaluation{Condition: "A", Values: map[string]float64{"A": val1}},
}, },
expected: "1.1e+00", expected: "1.1e+00",
}, },
@ -64,7 +64,7 @@ func Test_FormatValues(t *testing.T) {
name: "with two values, it renders the value based on their refID and position", name: "with two values, it renders the value based on their refID and position",
alertState: &state.State{ alertState: &state.State{
LastEvaluationString: "[ var='B0' metric='vector(10) + time() % 50' labels={} value=1.1 ], [ var='B1' metric='vector(10) + time() % 50' labels={} value=1.4 ]", LastEvaluationString: "[ var='B0' metric='vector(10) + time() % 50' labels={} value=1.1 ], [ var='B1' metric='vector(10) + time() % 50' labels={} value=1.4 ]",
LatestResult: &state.Evaluation{Condition: "B", Values: map[string]*float64{"B0": &val1, "B1": &val2}}, LatestResult: &state.Evaluation{Condition: "B", Values: map[string]float64{"B0": val1, "B1": val2}},
}, },
expected: "B0: 1.1e+00, B1: 1.4e+00", expected: "B0: 1.1e+00, B1: 1.4e+00",
}, },
@ -72,7 +72,7 @@ func Test_FormatValues(t *testing.T) {
name: "with a high number of values, it renders the value based on their refID and position using a natural order", name: "with a high number of values, it renders the value based on their refID and position using a natural order",
alertState: &state.State{ alertState: &state.State{
LastEvaluationString: "[ var='B0' metric='vector(10) + time() % 50' labels={} value=1.1 ], [ var='B1' metric='vector(10) + time() % 50' labels={} value=1.4 ]", LastEvaluationString: "[ var='B0' metric='vector(10) + time() % 50' labels={} value=1.1 ], [ var='B1' metric='vector(10) + time() % 50' labels={} value=1.4 ]",
LatestResult: &state.Evaluation{Condition: "B", Values: map[string]*float64{"B0": &val1, "B1": &val2, "B2": &val1, "B10": &val2, "B11": &val1}}, LatestResult: &state.Evaluation{Condition: "B", Values: map[string]float64{"B0": val1, "B1": val2, "B2": val1, "B10": val2, "B11": val1}},
}, },
expected: "B0: 1.1e+00, B10: 1.4e+00, B11: 1.1e+00, B1: 1.4e+00, B2: 1.1e+00", expected: "B0: 1.1e+00, B10: 1.4e+00, B11: 1.1e+00, B1: 1.4e+00, B2: 1.1e+00",
}, },
@ -240,11 +240,10 @@ func TestRouteGetAlertStatuses(t *testing.T) {
func withAlertingState() forEachState { func withAlertingState() forEachState {
return func(s *state.State) *state.State { return func(s *state.State) *state.State {
s.State = eval.Alerting s.State = eval.Alerting
value := float64(1.1)
s.LatestResult = &state.Evaluation{ s.LatestResult = &state.Evaluation{
EvaluationState: eval.Alerting, EvaluationState: eval.Alerting,
EvaluationTime: timeNow(), EvaluationTime: timeNow(),
Values: map[string]*float64{"B": &value}, Values: map[string]float64{"B": float64(1.1)},
Condition: "B", Condition: "B",
} }
return s return s

View File

@ -86,7 +86,7 @@ func (f *fakeAlertInstanceManager) GenerateAlertInstances(orgID int64, alertRule
LatestResult: &state.Evaluation{ LatestResult: &state.Evaluation{
EvaluationTime: evaluationTime.Add(1 * time.Minute), EvaluationTime: evaluationTime.Add(1 * time.Minute),
EvaluationState: eval.Normal, EvaluationState: eval.Normal,
Values: make(map[string]*float64), Values: make(map[string]float64),
}, },
LastEvaluationTime: evaluationTime.Add(1 * time.Minute), LastEvaluationTime: evaluationTime.Add(1 * time.Minute),
EvaluationDuration: evaluationDuration, EvaluationDuration: evaluationDuration,

View File

@ -80,6 +80,12 @@ func WithLabels(labels data.Labels) ResultMutator {
} }
} }
func WithValues(values map[string]NumberValueCapture) ResultMutator {
return func(r *Result) {
r.Values = values
}
}
type FakeLoadedMetricsReader struct { type FakeLoadedMetricsReader struct {
fingerprints map[data.Fingerprint]struct{} fingerprints map[data.Fingerprint]struct{}
} }

View File

@ -3,7 +3,6 @@ package state
import ( import (
"context" "context"
"errors" "errors"
"math"
"net/url" "net/url"
"strings" "strings"
"sync" "sync"
@ -157,15 +156,6 @@ func calculateState(ctx context.Context, log log.Logger, alertRule *ngModels.Ale
labels, _ := expand(ctx, log, alertRule.Title, alertRule.Labels, templateData, externalURL, result.EvaluatedAt) labels, _ := expand(ctx, log, alertRule.Title, alertRule.Labels, templateData, externalURL, result.EvaluatedAt)
annotations, _ := expand(ctx, log, alertRule.Title, alertRule.Annotations, templateData, externalURL, result.EvaluatedAt) annotations, _ := expand(ctx, log, alertRule.Title, alertRule.Annotations, templateData, externalURL, result.EvaluatedAt)
values := make(map[string]float64)
for refID, v := range result.Values {
if v.Value != nil {
values[refID] = *v.Value
} else {
values[refID] = math.NaN()
}
}
lbs := make(data.Labels, len(extraLabels)+len(labels)+len(resultLabels)) lbs := make(data.Labels, len(extraLabels)+len(labels)+len(resultLabels))
dupes := make(data.Labels) dupes := make(data.Labels)
for key, val := range extraLabels { for key, val := range extraLabels {
@ -210,7 +200,6 @@ func calculateState(ctx context.Context, log log.Logger, alertRule *ngModels.Ale
Labels: lbs, Labels: lbs,
Annotations: annotations, Annotations: annotations,
EvaluationDuration: result.EvaluationDuration, EvaluationDuration: result.EvaluationDuration,
Values: values,
StartsAt: result.EvaluatedAt, StartsAt: result.EvaluatedAt,
EndsAt: result.EvaluatedAt, EndsAt: result.EvaluatedAt,
ResultFingerprint: result.Instance.Fingerprint(), // remember original result fingerprint ResultFingerprint: result.Instance.Fingerprint(), // remember original result fingerprint

View File

@ -238,34 +238,6 @@ func Test_getOrCreate(t *testing.T) {
} }
}) })
t.Run("expected Reduce and Math expression values", func(t *testing.T) {
result := eval.Result{
Instance: models.GenerateAlertLabels(5, "result-"),
Values: map[string]eval.NumberValueCapture{
"A": {Var: "A", Value: util.Pointer(1.0)},
"B": {Var: "B", Value: util.Pointer(2.0)},
},
}
rule := generateRule()
state := c.getOrCreate(context.Background(), l, rule, result, nil, url)
assert.Equal(t, map[string]float64{"A": 1, "B": 2}, state.Values)
})
t.Run("expected Classic Condition values", func(t *testing.T) {
result := eval.Result{
Instance: models.GenerateAlertLabels(5, "result-"),
Values: map[string]eval.NumberValueCapture{
"B0": {Var: "B", Value: util.Pointer(1.0)},
"B1": {Var: "B", Value: util.Pointer(2.0)},
},
}
rule := generateRule()
state := c.getOrCreate(context.Background(), l, rule, result, nil, url)
assert.Equal(t, map[string]float64{"B0": 1, "B1": 2}, state.Values)
})
t.Run("when result labels collide with system labels from LabelsUserCannotSpecify", func(t *testing.T) { t.Run("when result labels collide with system labels from LabelsUserCannotSpecify", func(t *testing.T) {
result := eval.Result{ result := eval.Result{
Instance: models.GenerateAlertLabels(5, "result-"), Instance: models.GenerateAlertLabels(5, "result-"),

View File

@ -405,10 +405,11 @@ func (st *Manager) setNextState(ctx context.Context, alertRule *ngModels.AlertRu
currentState.LastEvaluationTime = result.EvaluatedAt currentState.LastEvaluationTime = result.EvaluatedAt
currentState.EvaluationDuration = result.EvaluationDuration currentState.EvaluationDuration = result.EvaluationDuration
currentState.SetNextValues(result)
currentState.LatestResult = &Evaluation{ currentState.LatestResult = &Evaluation{
EvaluationTime: result.EvaluatedAt, EvaluationTime: result.EvaluatedAt,
EvaluationState: result.State, EvaluationState: result.State,
Values: NewEvaluationValues(result.Values), Values: currentState.Values,
Condition: alertRule.Condition, Condition: alertRule.Condition,
} }
currentState.LastEvaluationString = result.EvaluationString currentState.LastEvaluationString = result.EvaluationString

View File

@ -23,6 +23,7 @@ import (
"github.com/grafana/grafana/pkg/services/ngalert/eval" "github.com/grafana/grafana/pkg/services/ngalert/eval"
"github.com/grafana/grafana/pkg/services/ngalert/metrics" "github.com/grafana/grafana/pkg/services/ngalert/metrics"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/util"
) )
// Not for parallel tests. // Not for parallel tests.
@ -149,14 +150,18 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) {
return ngmodels.CopyRule(baseRule, mutators...) return ngmodels.CopyRule(baseRule, mutators...)
} }
newEvaluation := func(evalTime time.Time, evalState eval.State) *Evaluation { newEvaluationWithValues := func(evalTime time.Time, evalState eval.State, values map[string]float64) *Evaluation {
return &Evaluation{ return &Evaluation{
EvaluationTime: evalTime, EvaluationTime: evalTime,
EvaluationState: evalState, EvaluationState: evalState,
Values: make(map[string]*float64), Values: values,
} }
} }
newEvaluation := func(evalTime time.Time, evalState eval.State) *Evaluation {
return newEvaluationWithValues(evalTime, evalState, make(map[string]float64))
}
newResult := func(mutators ...eval.ResultMutator) eval.Result { newResult := func(mutators ...eval.ResultMutator) eval.Result {
r := eval.Result{ r := eval.Result{
State: eval.Normal, State: eval.Normal,
@ -894,7 +899,11 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) {
desc: "t1[1:normal] t2[NoData] at t2", desc: "t1[1:normal] t2[NoData] at t2",
results: map[time.Time]eval.Results{ results: map[time.Time]eval.Results{
t1: { t1: {
newResult(eval.WithState(eval.Normal), eval.WithLabels(labels1)), newResult(
eval.WithState(eval.Normal),
eval.WithLabels(labels1),
eval.WithValues(map[string]eval.NumberValueCapture{"A": {Var: "A", Value: util.Pointer(1.0)}}),
),
}, },
t2: { t2: {
newResult(eval.WithState(eval.NoData), eval.WithLabels(noDataLabels)), newResult(eval.WithState(eval.NoData), eval.WithLabels(noDataLabels)),
@ -913,6 +922,7 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) {
EndsAt: t2.Add(ResendDelay * 4), EndsAt: t2.Add(ResendDelay * 4),
LastEvaluationTime: t2, LastEvaluationTime: t2,
LastSentAt: &t2, LastSentAt: &t2,
Values: map[string]float64{},
}, },
}, },
}, },
@ -925,11 +935,12 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) {
Labels: labels["system + rule + no-data"], Labels: labels["system + rule + no-data"],
State: eval.Alerting, State: eval.Alerting,
StateReason: eval.NoData.String(), StateReason: eval.NoData.String(),
LatestResult: newEvaluation(t2, eval.NoData), LatestResult: newEvaluationWithValues(t2, eval.NoData, map[string]float64{}),
StartsAt: t2, StartsAt: t2,
EndsAt: t2.Add(ResendDelay * 4), EndsAt: t2.Add(ResendDelay * 4),
LastEvaluationTime: t2, LastEvaluationTime: t2,
LastSentAt: &t2, LastSentAt: &t2,
Values: map[string]float64{},
}, },
}, },
}, },
@ -942,10 +953,11 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) {
Labels: labels["system + rule + no-data"], Labels: labels["system + rule + no-data"],
State: eval.Normal, State: eval.Normal,
StateReason: eval.NoData.String(), StateReason: eval.NoData.String(),
LatestResult: newEvaluation(t2, eval.NoData), LatestResult: newEvaluationWithValues(t2, eval.NoData, map[string]float64{}),
StartsAt: t2, StartsAt: t2,
EndsAt: t2, EndsAt: t2,
LastEvaluationTime: t2, LastEvaluationTime: t2,
Values: map[string]float64{},
}, },
}, },
}, },
@ -976,11 +988,12 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) {
Labels: labels["system + rule + labels1"], Labels: labels["system + rule + labels1"],
State: eval.Alerting, State: eval.Alerting,
StateReason: eval.NoData.String(), StateReason: eval.NoData.String(),
LatestResult: newEvaluation(t2, eval.NoData), LatestResult: newEvaluationWithValues(t2, eval.NoData, map[string]float64{"A": float64(-1)}),
StartsAt: t2, StartsAt: t2,
EndsAt: t2.Add(ResendDelay * 4), EndsAt: t2.Add(ResendDelay * 4),
LastEvaluationTime: t2, LastEvaluationTime: t2,
LastSentAt: &t2, LastSentAt: &t2,
Values: map[string]float64{"A": float64(-1)},
}, },
}, },
}, },
@ -993,10 +1006,11 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) {
Labels: labels["system + rule + labels1"], Labels: labels["system + rule + labels1"],
State: eval.Normal, State: eval.Normal,
StateReason: eval.NoData.String(), StateReason: eval.NoData.String(),
LatestResult: newEvaluation(t2, eval.NoData), LatestResult: newEvaluationWithValues(t2, eval.NoData, map[string]float64{"A": float64(-1)}),
StartsAt: t1, StartsAt: t1,
EndsAt: t1, EndsAt: t1,
LastEvaluationTime: t2, LastEvaluationTime: t2,
Values: map[string]float64{"A": float64(-1)},
}, },
}, },
}, },
@ -1009,10 +1023,11 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) {
Labels: labels["system + rule + labels1"], Labels: labels["system + rule + labels1"],
State: eval.Normal, State: eval.Normal,
StateReason: ngmodels.ConcatReasons(eval.NoData.String(), ngmodels.StateReasonKeepLast), StateReason: ngmodels.ConcatReasons(eval.NoData.String(), ngmodels.StateReasonKeepLast),
LatestResult: newEvaluation(t2, eval.NoData), LatestResult: newEvaluationWithValues(t2, eval.NoData, map[string]float64{"A": float64(-1)}),
StartsAt: t1, StartsAt: t1,
EndsAt: t1, EndsAt: t1,
LastEvaluationTime: t2, LastEvaluationTime: t2,
Values: map[string]float64{"A": float64(-1)},
}, },
}, },
}, },
@ -2807,7 +2822,7 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) {
ruleMutators: []ngmodels.AlertRuleMutator{ngmodels.RuleMuts.WithForNTimes(1)}, ruleMutators: []ngmodels.AlertRuleMutator{ngmodels.RuleMuts.WithForNTimes(1)},
results: map[time.Time]eval.Results{ results: map[time.Time]eval.Results{
t1: { t1: {
newResult(eval.WithState(eval.Alerting), eval.WithLabels(labels1)), newResult(eval.WithState(eval.Alerting), eval.WithLabels(labels1), eval.WithValues(map[string]eval.NumberValueCapture{"A": {Var: "A", Value: util.Pointer(1.0)}})),
}, },
t2: { t2: {
newResult(eval.WithError(datasourceError)), newResult(eval.WithError(datasourceError)),
@ -2895,11 +2910,12 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) {
State: eval.Alerting, State: eval.Alerting,
StateReason: eval.Error.String(), StateReason: eval.Error.String(),
Error: datasourceError, Error: datasourceError,
LatestResult: newEvaluation(t2, eval.Error), LatestResult: newEvaluationWithValues(t2, eval.Error, map[string]float64{"A": float64(-1)}),
StartsAt: t2, StartsAt: t2,
EndsAt: t2.Add(ResendDelay * 4), EndsAt: t2.Add(ResendDelay * 4),
LastEvaluationTime: t2, LastEvaluationTime: t2,
LastSentAt: &t2, LastSentAt: &t2,
Values: map[string]float64{"A": float64(-1)},
}, },
}, },
}, },
@ -2912,10 +2928,11 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) {
Labels: labels["system + rule + labels1"], Labels: labels["system + rule + labels1"],
State: eval.Normal, State: eval.Normal,
StateReason: eval.Error.String(), StateReason: eval.Error.String(),
LatestResult: newEvaluation(t2, eval.Error), LatestResult: newEvaluationWithValues(t2, eval.Error, map[string]float64{"A": float64(-1)}),
StartsAt: t2, StartsAt: t2,
EndsAt: t2, EndsAt: t2,
LastEvaluationTime: t2, LastEvaluationTime: t2,
Values: map[string]float64{"A": float64(-1)},
}, },
}, },
}, },
@ -2928,11 +2945,12 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) {
Labels: labels["system + rule + labels1"], Labels: labels["system + rule + labels1"],
State: eval.Alerting, State: eval.Alerting,
StateReason: ngmodels.ConcatReasons(eval.Error.String(), ngmodels.StateReasonKeepLast), StateReason: ngmodels.ConcatReasons(eval.Error.String(), ngmodels.StateReasonKeepLast),
LatestResult: newEvaluation(t2, eval.Error), LatestResult: newEvaluationWithValues(t2, eval.Error, map[string]float64{"A": float64(-1)}),
StartsAt: t2, StartsAt: t2,
EndsAt: t2.Add(ResendDelay * 4), EndsAt: t2.Add(ResendDelay * 4),
LastEvaluationTime: t2, LastEvaluationTime: t2,
LastSentAt: &t2, LastSentAt: &t2,
Values: map[string]float64{"A": float64(-1)},
}, },
}, },
}, },
@ -2943,7 +2961,7 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) {
desc: "t1[1:normal] t2[QueryError] at t2", desc: "t1[1:normal] t2[QueryError] at t2",
results: map[time.Time]eval.Results{ results: map[time.Time]eval.Results{
t1: { t1: {
newResult(eval.WithState(eval.Normal), eval.WithLabels(labels1)), newResult(eval.WithState(eval.Normal), eval.WithLabels(labels1), eval.WithValues(map[string]eval.NumberValueCapture{"A": {Var: "A", Value: util.Pointer(1.0)}})),
}, },
t2: { t2: {
newResult(eval.WithError(datasourceError)), newResult(eval.WithError(datasourceError)),
@ -3032,11 +3050,12 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) {
State: eval.Alerting, State: eval.Alerting,
StateReason: eval.Error.String(), StateReason: eval.Error.String(),
Error: datasourceError, Error: datasourceError,
LatestResult: newEvaluation(t2, eval.Error), LatestResult: newEvaluationWithValues(t2, eval.Error, map[string]float64{"A": float64(-1)}),
StartsAt: t2, StartsAt: t2,
EndsAt: t2.Add(ResendDelay * 4), EndsAt: t2.Add(ResendDelay * 4),
LastEvaluationTime: t2, LastEvaluationTime: t2,
LastSentAt: &t2, LastSentAt: &t2,
Values: map[string]float64{"A": float64(-1)},
}, },
}, },
}, },
@ -3049,10 +3068,11 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) {
Labels: labels["system + rule + labels1"], Labels: labels["system + rule + labels1"],
State: eval.Normal, State: eval.Normal,
StateReason: eval.Error.String(), StateReason: eval.Error.String(),
LatestResult: newEvaluation(t2, eval.Error), LatestResult: newEvaluationWithValues(t2, eval.Error, map[string]float64{"A": float64(-1)}),
StartsAt: t1, StartsAt: t1,
EndsAt: t1, EndsAt: t1,
LastEvaluationTime: t2, LastEvaluationTime: t2,
Values: map[string]float64{"A": float64(-1)},
}, },
}, },
}, },
@ -3065,10 +3085,11 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) {
Labels: labels["system + rule + labels1"], Labels: labels["system + rule + labels1"],
State: eval.Normal, State: eval.Normal,
StateReason: ngmodels.ConcatReasons(eval.Error.String(), ngmodels.StateReasonKeepLast), StateReason: ngmodels.ConcatReasons(eval.Error.String(), ngmodels.StateReasonKeepLast),
LatestResult: newEvaluation(t2, eval.Error), LatestResult: newEvaluationWithValues(t2, eval.Error, map[string]float64{"A": float64(-1)}),
StartsAt: t1, StartsAt: t1,
EndsAt: t1, EndsAt: t1,
LastEvaluationTime: t2, LastEvaluationTime: t2,
Values: map[string]float64{"A": float64(-1)},
}, },
}, },
}, },

View File

@ -324,14 +324,18 @@ func TestProcessEvalResults(t *testing.T) {
ExecErrState: models.ErrorErrState, ExecErrState: models.ErrorErrState,
} }
newEvaluation := func(evalTime time.Time, evalState eval.State) *state.Evaluation { newEvaluationWithValues := func(evalTime time.Time, evalState eval.State, values map[string]float64) *state.Evaluation {
return &state.Evaluation{ return &state.Evaluation{
EvaluationTime: evalTime, EvaluationTime: evalTime,
EvaluationState: evalState, EvaluationState: evalState,
Values: make(map[string]*float64), Values: values,
} }
} }
newEvaluation := func(evalTime time.Time, evalState eval.State) *state.Evaluation {
return newEvaluationWithValues(evalTime, evalState, make(map[string]float64))
}
baseRuleWith := func(mutators ...models.AlertRuleMutator) *models.AlertRule { baseRuleWith := func(mutators ...models.AlertRuleMutator) *models.AlertRule {
r := models.CopyRule(baseRule, mutators...) r := models.CopyRule(baseRule, mutators...)
return r return r
@ -1378,6 +1382,76 @@ func TestProcessEvalResults(t *testing.T) {
}, },
}, },
}, },
{
desc: "expected Reduce and Math expression values",
alertRule: baseRuleWith(),
expectedAnnotations: 1,
evalResults: map[time.Time]eval.Results{
t1: {
newResult(
eval.WithState(eval.Alerting),
eval.WithLabels(data.Labels{}),
eval.WithValues(map[string]eval.NumberValueCapture{
"A": {Var: "A", Labels: data.Labels{}, Value: util.Pointer(1.0)},
"B": {Var: "B", Labels: data.Labels{}, Value: util.Pointer(2.0)},
})),
},
},
expectedStates: []*state.State{
{
Labels: labels["system + rule"],
ResultFingerprint: data.Labels{}.Fingerprint(),
State: eval.Alerting,
LatestResult: newEvaluationWithValues(t1, eval.Alerting, map[string]float64{
"A": 1.0,
"B": 2.0,
}),
StartsAt: t1,
EndsAt: t1.Add(state.ResendDelay * 4),
LastEvaluationTime: t1,
LastSentAt: &t1,
Values: map[string]float64{
"A": 1.0,
"B": 2.0,
},
},
},
},
{
desc: "expected Classic Condition values",
alertRule: baseRuleWith(),
expectedAnnotations: 1,
evalResults: map[time.Time]eval.Results{
t1: {
newResult(
eval.WithState(eval.Alerting),
eval.WithLabels(data.Labels{}),
eval.WithValues(map[string]eval.NumberValueCapture{
"B0": {Var: "B", Labels: data.Labels{}, Value: util.Pointer(1.0)},
"B1": {Var: "B", Labels: data.Labels{}, Value: util.Pointer(2.0)},
})),
},
},
expectedStates: []*state.State{
{
Labels: labels["system + rule"],
ResultFingerprint: data.Labels{}.Fingerprint(),
State: eval.Alerting,
LatestResult: newEvaluationWithValues(t1, eval.Alerting, map[string]float64{
"B0": 1.0,
"B1": 2.0,
}),
StartsAt: t1,
EndsAt: t1.Add(state.ResendDelay * 4),
LastEvaluationTime: t1,
LastSentAt: &t1,
Values: map[string]float64{
"B0": 1.0,
"B1": 2.0,
},
},
},
},
} }
for _, tc := range testCases { for _, tc := range testCases {
@ -1488,6 +1562,43 @@ func TestProcessEvalResults(t *testing.T) {
}) })
} }
t.Run("converts values to NaN if not defined", func(t *testing.T) {
// We set up our own special test for this, since we need special comparison logic - NaN != NaN
instanceStore := &state.FakeInstanceStore{}
clk := clock.NewMock()
cfg := state.ManagerCfg{
Metrics: metrics.NewNGAlert(prometheus.NewPedanticRegistry()).GetStateMetrics(),
ExternalURL: nil,
InstanceStore: instanceStore,
Images: &state.NotAvailableImageService{},
Clock: clk,
Historian: &state.FakeHistorian{},
Tracer: tracing.InitializeTracerForTest(),
Log: log.New("ngalert.state.manager"),
MaxStateSaveConcurrency: 1,
}
st := state.NewManager(cfg, state.NewNoopPersister())
rule := baseRuleWith()
time := t1
res := eval.Results{newResult(
eval.WithState(eval.Alerting),
eval.WithLabels(data.Labels{}),
eval.WithEvaluatedAt(t1),
eval.WithValues(map[string]eval.NumberValueCapture{
"A": {Var: "A", Labels: data.Labels{}, Value: nil},
}),
)}
_ = st.ProcessEvalResults(context.Background(), time, rule, res, systemLabels, state.NoopSender)
states := st.GetStatesForRuleUID(rule.OrgID, rule.UID)
require.Len(t, states, 1)
state := states[0]
require.NotNil(t, state.Values)
require.Contains(t, state.Values, "A")
require.Truef(t, math.IsNaN(state.Values["A"]), "expected NaN but got %v", state.Values["A"])
})
t.Run("should save state to database", func(t *testing.T) { t.Run("should save state to database", func(t *testing.T) {
instanceStore := &state.FakeInstanceStore{} instanceStore := &state.FakeInstanceStore{}
clk := clock.New() clk := clock.New()
@ -1623,7 +1734,7 @@ func TestStaleResultsHandler(t *testing.T) {
LatestResult: &state.Evaluation{ LatestResult: &state.Evaluation{
EvaluationTime: evaluationTime, EvaluationTime: evaluationTime,
EvaluationState: eval.Normal, EvaluationState: eval.Normal,
Values: make(map[string]*float64), Values: make(map[string]float64),
Condition: "A", Condition: "A",
}, },
StartsAt: evaluationTime, StartsAt: evaluationTime,

View File

@ -163,6 +163,32 @@ func (a *State) AddErrorAnnotations(err error, rule *models.AlertRule) {
} }
} }
func (a *State) SetNextValues(result eval.Result) {
const sentinel = float64(-1)
// We try to provide a reasonable object for Values in the event of nodata/error.
// In order to not break templates that might refer to refIDs,
// we instead fill values with the latest known set of refIDs, but with a sentinel -1 to indicate that the value didn't exist.
if result.State == eval.NoData || result.State == eval.Error {
placeholder := make(map[string]float64, len(a.Values))
for refID := range a.Values {
placeholder[refID] = sentinel
}
a.Values = placeholder
return
}
newValues := make(map[string]float64, len(result.Values))
for k, v := range result.Values {
if v.Value != nil {
newValues[k] = *v.Value
} else {
newValues[k] = math.NaN()
}
}
a.Values = newValues
}
// IsNormalStateWithNoReason returns true if the state is Normal and reason is empty // IsNormalStateWithNoReason returns true if the state is Normal and reason is empty
func IsNormalStateWithNoReason(s *State) bool { func IsNormalStateWithNoReason(s *State) bool {
return s.State == eval.Normal && s.StateReason == "" return s.State == eval.Normal && s.StateReason == ""
@ -206,16 +232,20 @@ type Evaluation struct {
// Values contains the RefID and value of reduce and math expressions. // Values contains the RefID and value of reduce and math expressions.
// Classic conditions can have different values for the same RefID as they can include multiple conditions. // Classic conditions can have different values for the same RefID as they can include multiple conditions.
// For these, we use the index of the condition in addition RefID as the key e.g. "A0, A1, A2, etc.". // For these, we use the index of the condition in addition RefID as the key e.g. "A0, A1, A2, etc.".
Values map[string]*float64 Values map[string]float64
// Condition is the refID specified as the condition in the alerting rule at the time of the evaluation. // Condition is the refID specified as the condition in the alerting rule at the time of the evaluation.
Condition string Condition string
} }
// NewEvaluationValues returns the labels and values for each RefID in the capture. // NewEvaluationValues returns the labels and values for each RefID in the capture.
func NewEvaluationValues(m map[string]eval.NumberValueCapture) map[string]*float64 { func NewEvaluationValues(m map[string]eval.NumberValueCapture) map[string]float64 {
result := make(map[string]*float64, len(m)) result := make(map[string]float64, len(m))
for k, v := range m { for k, v := range m {
result[k] = v.Value if v.Value != nil {
result[k] = *v.Value
} else {
result[k] = math.NaN()
}
} }
return result return result
} }
@ -486,11 +516,7 @@ func (a *State) GetLastEvaluationValuesForCondition() map[string]float64 {
for refID, value := range lastResult.Values { for refID, value := range lastResult.Values {
if strings.Contains(refID, lastResult.Condition) { if strings.Contains(refID, lastResult.Condition) {
if value != nil { r[refID] = value
r[refID] = *value
continue
}
r[refID] = math.NaN()
} }
} }

View File

@ -528,9 +528,9 @@ func TestGetLastEvaluationValuesForCondition(t *testing.T) {
eval := &Evaluation{ eval := &Evaluation{
EvaluationTime: time.Time{}, EvaluationTime: time.Time{},
EvaluationState: 0, EvaluationState: 0,
Values: map[string]*float64{ Values: map[string]float64{
"B": util.Pointer(rand.Float64()), "B": rand.Float64(),
"A": util.Pointer(expected), "A": expected,
}, },
Condition: "A", Condition: "A",
} }
@ -543,8 +543,8 @@ func TestGetLastEvaluationValuesForCondition(t *testing.T) {
eval := &Evaluation{ eval := &Evaluation{
EvaluationTime: time.Time{}, EvaluationTime: time.Time{},
EvaluationState: 0, EvaluationState: 0,
Values: map[string]*float64{ Values: map[string]float64{
"C": util.Pointer(rand.Float64()), "C": rand.Float64(),
}, },
Condition: "A", Condition: "A",
} }
@ -556,8 +556,8 @@ func TestGetLastEvaluationValuesForCondition(t *testing.T) {
eval := &Evaluation{ eval := &Evaluation{
EvaluationTime: time.Time{}, EvaluationTime: time.Time{},
EvaluationState: 0, EvaluationState: 0,
Values: map[string]*float64{ Values: map[string]float64{
"A": nil, "A": math.NaN(),
}, },
Condition: "A", Condition: "A",
} }