package backtesting import ( "context" "encoding/json" "errors" "fmt" "math/rand" "testing" "time" "github.com/stretchr/testify/require" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/services/ngalert/eval" "github.com/grafana/grafana/pkg/services/ngalert/eval/eval_mocks" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/state" "github.com/grafana/grafana/pkg/util" ) func TestNewBacktestingEvaluator(t *testing.T) { t.Run("creates data evaluator", func(t *testing.T) { frame := GenerateWideSeriesFrame(10, time.Second) d := struct { Data *data.Frame `json:"data"` }{ Data: frame, } validData, err := json.Marshal(d) require.NoError(t, err) refID := util.GenerateShortUID() evalFactory := eval_mocks.NewEvaluatorFactory(&eval_mocks.ConditionEvaluatorMock{}) testCases := []struct { name string condition models.Condition error bool expectedEval backtestingEvaluator }{ { name: "creates data evaluator when there is one query with type __data__", condition: models.Condition{ Condition: refID, Data: []models.AlertQuery{ { RefID: refID, QueryType: "__data__", RelativeTimeRange: models.RelativeTimeRange{}, DatasourceUID: "", Model: json.RawMessage(validData), }, }, }, expectedEval: &dataEvaluator{}, }, { name: "creates data evaluator when there is one query with datasource UID __data__", condition: models.Condition{ Condition: refID, Data: []models.AlertQuery{ { RefID: refID, QueryType: "", RelativeTimeRange: models.RelativeTimeRange{}, DatasourceUID: "__data__", Model: json.RawMessage(validData), }, }, }, expectedEval: &dataEvaluator{}, }, { name: "fails if queries contain data and other queries", condition: models.Condition{ Condition: refID, Data: []models.AlertQuery{ { RefID: refID, QueryType: "__data__", RelativeTimeRange: models.RelativeTimeRange{}, DatasourceUID: "", Model: json.RawMessage(validData), }, { RefID: "D", QueryType: "", RelativeTimeRange: models.RelativeTimeRange{}, DatasourceUID: util.GenerateShortUID(), }, }, }, error: true, }, { name: "fails if data query does not contain data", condition: models.Condition{ Condition: refID, Data: []models.AlertQuery{ { RefID: refID, QueryType: "__data__", RelativeTimeRange: models.RelativeTimeRange{}, DatasourceUID: "", Model: json.RawMessage(nil), }, }, }, error: true, }, { name: "fails if data query does not contain frame in data", condition: models.Condition{ Condition: refID, Data: []models.AlertQuery{ { RefID: refID, QueryType: "__data__", RelativeTimeRange: models.RelativeTimeRange{}, DatasourceUID: "", Model: json.RawMessage(`{ "data": "test"}`), }, }, }, error: true, }, { name: "fails if condition refID and data refID does not match", condition: models.Condition{ Condition: refID, Data: []models.AlertQuery{ { RefID: "B", QueryType: "__data__", RelativeTimeRange: models.RelativeTimeRange{}, DatasourceUID: "", Model: json.RawMessage(validData), }, }, }, error: true, }, } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { e, err := newBacktestingEvaluator(context.Background(), evalFactory, nil, testCase.condition, nil) if testCase.error { require.Error(t, err) return } require.NoError(t, err) require.IsType(t, &dataEvaluator{}, e) }) } }) } func TestEvaluatorTest(t *testing.T) { states := []eval.State{eval.Normal, eval.Alerting, eval.Pending} generateState := func(prefix string) *state.State { labels := models.GenerateAlertLabels(rand.Intn(5)+1, prefix+"-") return &state.State{ CacheID: labels.Fingerprint(), Labels: labels, State: states[rand.Intn(len(states))], } } randomResultCallback := func(now time.Time) (eval.Results, error) { return eval.GenerateResults(rand.Intn(5)+1, eval.ResultGen()), nil } evaluator := &fakeBacktestingEvaluator{ evalCallback: randomResultCallback, } manager := &fakeStateManager{} backtestingEvaluatorFactory = func(ctx context.Context, evalFactory eval.EvaluatorFactory, user identity.Requester, condition models.Condition, r eval.AlertingResultsReader) (backtestingEvaluator, error) { return evaluator, nil } t.Cleanup(func() { backtestingEvaluatorFactory = newBacktestingEvaluator }) engine := &Engine{ evalFactory: nil, createStateManager: func() stateManager { return manager }, } gen := models.RuleGen rule := gen.With(gen.WithInterval(time.Second)).GenerateRef() ruleInterval := time.Duration(rule.IntervalSeconds) * time.Second t.Run("should return data frame in specific format", func(t *testing.T) { from := time.Unix(0, 0) to := from.Add(5 * ruleInterval) allStates := [...]eval.State{eval.Normal, eval.Alerting, eval.Pending, eval.NoData, eval.Error} var states []state.StateTransition for _, s := range allStates { labels := models.GenerateAlertLabels(rand.Intn(5)+1, s.String()+"-") states = append(states, state.StateTransition{ State: &state.State{ CacheID: labels.Fingerprint(), Labels: labels, State: s, StateReason: util.GenerateShortUID(), }, }) } manager.stateCallback = func(now time.Time) []state.StateTransition { return states } frame, err := engine.Test(context.Background(), nil, rule, from, to) require.NoError(t, err) require.Len(t, frame.Fields, len(states)+1) // +1 - timestamp t.Run("should contain field Time", func(t *testing.T) { timestampField, _ := frame.FieldByName("Time") require.NotNil(t, timestampField, "frame does not contain field 'Time'") require.Equal(t, data.FieldTypeTime, timestampField.Type()) }) fieldByState := make(map[data.Fingerprint]*data.Field, len(states)) t.Run("should contain a field per state", func(t *testing.T) { for _, s := range states { var f *data.Field for _, field := range frame.Fields { if field.Labels.String() == s.Labels.String() { f = field break } } require.NotNilf(t, f, "Cannot find a field by state labels") fieldByState[s.CacheID] = f } }) t.Run("should be populated with correct values", func(t *testing.T) { timestampField, _ := frame.FieldByName("Time") expectedLength := timestampField.Len() for _, field := range frame.Fields { require.Equalf(t, expectedLength, field.Len(), "Field %s should have the size %d", field.Name, expectedLength) } for i := 0; i < expectedLength; i++ { expectedTime := from.Add(time.Duration(int64(i)*rule.IntervalSeconds) * time.Second) require.Equal(t, expectedTime, timestampField.At(i).(time.Time)) for _, s := range states { f := fieldByState[s.CacheID] if s.State.State == eval.NoData { require.Nil(t, f.At(i)) } else { v := f.At(i).(*string) require.NotNilf(t, v, "Field [%s] value at index %d should not be nil", s.CacheID, i) require.Equal(t, fmt.Sprintf("%s (%s)", s.State.State, s.StateReason), *v) } } } }) }) t.Run("should not fail if 'to-from' is not times of interval", func(t *testing.T) { from := time.Unix(0, 0) to := from.Add(5 * ruleInterval) labels := models.GenerateAlertLabels(rand.Intn(5)+1, "test-") states := []state.StateTransition{ { State: &state.State{ CacheID: labels.Fingerprint(), Labels: labels, State: eval.Normal, StateReason: util.GenerateShortUID(), }, }, } manager.stateCallback = func(now time.Time) []state.StateTransition { return states } frame, err := engine.Test(context.Background(), nil, rule, from, to) require.NoError(t, err) expectedLen := frame.Rows() for i := 0; i < 100; i++ { jitter := time.Duration(rand.Int63n(ruleInterval.Milliseconds())) * time.Millisecond frame, err = engine.Test(context.Background(), nil, rule, from, to.Add(jitter)) require.NoError(t, err) require.Equalf(t, expectedLen, frame.Rows(), "jitter %v caused result to be different that base-line", jitter) } }) t.Run("should backfill field with nulls if a new dimension created in the middle", func(t *testing.T) { from := time.Unix(0, 0) state1 := state.StateTransition{ State: generateState("1"), } state2 := state.StateTransition{ State: generateState("2"), } state3 := state.StateTransition{ State: generateState("3"), } stateByTime := map[time.Time][]state.StateTransition{ from: {state1, state2}, from.Add(1 * ruleInterval): {state1, state2}, from.Add(2 * ruleInterval): {state1, state2}, from.Add(3 * ruleInterval): {state1, state2, state3}, from.Add(4 * ruleInterval): {state1, state2, state3}, } to := from.Add(time.Duration(len(stateByTime)) * ruleInterval) manager.stateCallback = func(now time.Time) []state.StateTransition { return stateByTime[now] } frame, err := engine.Test(context.Background(), nil, rule, from, to) require.NoError(t, err) var field3 *data.Field for _, field := range frame.Fields { if field.Labels.String() == state3.Labels.String() { field3 = field break } } require.NotNilf(t, field3, "Result for state 3 was not found") require.Equalf(t, len(stateByTime), field3.Len(), "State3 result has unexpected number of values") idx := 0 for curTime, states := range stateByTime { value := field3.At(idx).(*string) if len(states) == 2 { require.Nilf(t, value, "The result should be nil if state3 was not available for time %v", curTime) } } }) t.Run("should fail", func(t *testing.T) { manager.stateCallback = func(now time.Time) []state.StateTransition { return nil } t.Run("when interval is not correct", func(t *testing.T) { from := time.Now() t.Run("when from=to", func(t *testing.T) { to := from _, err := engine.Test(context.Background(), nil, rule, from, to) require.ErrorIs(t, err, ErrInvalidInputData) }) t.Run("when from > to", func(t *testing.T) { to := from.Add(-ruleInterval) _, err := engine.Test(context.Background(), nil, rule, from, to) require.ErrorIs(t, err, ErrInvalidInputData) }) t.Run("when to-from < interval", func(t *testing.T) { to := from.Add(ruleInterval).Add(-time.Millisecond) _, err := engine.Test(context.Background(), nil, rule, from, to) require.ErrorIs(t, err, ErrInvalidInputData) }) }) t.Run("when evaluation fails", func(t *testing.T) { expectedError := errors.New("test-error") evaluator.evalCallback = func(now time.Time) (eval.Results, error) { return nil, expectedError } from := time.Now() to := from.Add(ruleInterval) _, err := engine.Test(context.Background(), nil, rule, from, to) require.ErrorIs(t, err, expectedError) }) }) } type fakeStateManager struct { stateCallback func(now time.Time) []state.StateTransition } func (f *fakeStateManager) ProcessEvalResults(_ context.Context, evaluatedAt time.Time, _ *models.AlertRule, _ eval.Results, _ data.Labels, _ state.Sender) state.StateTransitions { return f.stateCallback(evaluatedAt) } func (f *fakeStateManager) GetStatesForRuleUID(orgID int64, alertRuleUID string) []*state.State { return nil } type fakeBacktestingEvaluator struct { evalCallback func(now time.Time) (eval.Results, error) } func (f *fakeBacktestingEvaluator) Eval(_ context.Context, from time.Time, interval time.Duration, evaluations int, callback callbackFunc) error { for idx, now := 0, from; idx < evaluations; idx, now = idx+1, now.Add(interval) { results, err := f.evalCallback(now) if err != nil { return err } err = callback(idx, now, results) if err != nil { return err } } return nil }