2022-12-14 08:44:14 -06:00
package backtesting
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"time"
"github.com/benbjohnson/clock"
2023-11-14 08:47:34 -06:00
2022-12-14 08:44:14 -06:00
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/grafana/grafana/pkg/infra/log"
2023-08-16 02:04:18 -05:00
"github.com/grafana/grafana/pkg/infra/tracing"
2023-11-14 08:47:34 -06:00
"github.com/grafana/grafana/pkg/services/auth/identity"
2022-12-14 08:44:14 -06:00
"github.com/grafana/grafana/pkg/services/ngalert/eval"
"github.com/grafana/grafana/pkg/services/ngalert/models"
2024-01-04 10:47:13 -06:00
"github.com/grafana/grafana/pkg/services/ngalert/schedule"
2022-12-14 08:44:14 -06:00
"github.com/grafana/grafana/pkg/services/ngalert/state"
)
var (
ErrInvalidInputData = errors . New ( "invalid input data" )
logger = log . New ( "ngalert.backtesting.engine" )
backtestingEvaluatorFactory = newBacktestingEvaluator
)
2023-07-06 10:21:03 -05:00
type callbackFunc = func ( evaluationIndex int , now time . Time , results eval . Results ) error
2022-12-14 08:44:14 -06:00
type backtestingEvaluator interface {
2023-07-06 10:21:03 -05:00
Eval ( ctx context . Context , from time . Time , interval time . Duration , evaluations int , callback callbackFunc ) error
2022-12-14 08:44:14 -06:00
}
type stateManager interface {
ProcessEvalResults ( ctx context . Context , evaluatedAt time . Time , alertRule * models . AlertRule , results eval . Results , extraLabels data . Labels ) [ ] state . StateTransition
2024-01-04 10:47:13 -06:00
schedule . RuleStateProvider
2022-12-14 08:44:14 -06:00
}
type Engine struct {
evalFactory eval . EvaluatorFactory
createStateManager func ( ) stateManager
}
2023-08-16 02:04:18 -05:00
func NewEngine ( appUrl * url . URL , evalFactory eval . EvaluatorFactory , tracer tracing . Tracer ) * Engine {
2022-12-14 08:44:14 -06:00
return & Engine {
evalFactory : evalFactory ,
createStateManager : func ( ) stateManager {
2023-01-10 15:26:15 -06:00
cfg := state . ManagerCfg {
2024-01-17 06:33:13 -06:00
Metrics : nil ,
ExternalURL : appUrl ,
InstanceStore : nil ,
Images : & NoopImageService { } ,
Clock : clock . New ( ) ,
Historian : nil ,
Tracer : tracer ,
Log : log . New ( "ngalert.state.manager" ) ,
2023-01-10 15:26:15 -06:00
}
2024-01-17 06:33:13 -06:00
return state . NewManager ( cfg , state . NewNoopPersister ( ) )
2022-12-14 08:44:14 -06:00
} ,
}
}
2023-11-14 08:47:34 -06:00
func ( e * Engine ) Test ( ctx context . Context , user identity . Requester , rule * models . AlertRule , from , to time . Time ) ( * data . Frame , error ) {
2022-12-14 08:44:14 -06:00
ruleCtx := models . WithRuleKey ( ctx , rule . GetKey ( ) )
logger := logger . FromContext ( ctx )
if ! from . Before ( to ) {
return nil , fmt . Errorf ( "%w: invalid interval of the backtesting [%d,%d]" , ErrInvalidInputData , from . Unix ( ) , to . Unix ( ) )
}
if to . Sub ( from ) . Seconds ( ) < float64 ( rule . IntervalSeconds ) {
return nil , fmt . Errorf ( "%w: interval of the backtesting [%d,%d] is less than evaluation interval [%ds]" , ErrInvalidInputData , from . Unix ( ) , to . Unix ( ) , rule . IntervalSeconds )
}
length := int ( to . Sub ( from ) . Seconds ( ) ) / int ( rule . IntervalSeconds )
2024-01-04 10:47:13 -06:00
stateManager := e . createStateManager ( )
evaluator , err := backtestingEvaluatorFactory ( ruleCtx , e . evalFactory , user , rule . GetEvalCondition ( ) , & schedule . AlertingResultsFromRuleState {
Manager : stateManager ,
Rule : rule ,
} )
2022-12-14 08:44:14 -06:00
if err != nil {
2023-06-19 04:29:45 -05:00
return nil , errors . Join ( ErrInvalidInputData , err )
2022-12-14 08:44:14 -06:00
}
logger . Info ( "Start testing alert rule" , "from" , from , "to" , to , "interval" , rule . IntervalSeconds , "evaluations" , length )
start := time . Now ( )
tsField := data . NewField ( "Time" , nil , make ( [ ] time . Time , length ) )
valueFields := make ( map [ string ] * data . Field )
2023-07-06 10:21:03 -05:00
err = evaluator . Eval ( ruleCtx , from , time . Duration ( rule . IntervalSeconds ) * time . Second , length , func ( idx int , currentTime time . Time , results eval . Results ) error {
if idx >= length {
logger . Info ( "Unexpected evaluation. Skipping" , "from" , from , "to" , to , "interval" , rule . IntervalSeconds , "evaluationTime" , currentTime , "evaluationIndex" , idx , "expectedEvaluations" , length )
return nil
}
2022-12-14 08:44:14 -06:00
states := stateManager . ProcessEvalResults ( ruleCtx , currentTime , rule , results , nil )
tsField . Set ( idx , currentTime )
for _ , s := range states {
field , ok := valueFields [ s . CacheID ]
if ! ok {
field = data . NewField ( "" , s . Labels , make ( [ ] * string , length ) )
valueFields [ s . CacheID ] = field
}
if s . State . State != eval . NoData { // set nil if NoData
value := s . State . State . String ( )
if s . StateReason != "" {
value += " (" + s . StateReason + ")"
}
field . Set ( idx , & value )
continue
}
}
return nil
} )
fields := make ( [ ] * data . Field , 0 , len ( valueFields ) + 1 )
fields = append ( fields , tsField )
for _ , f := range valueFields {
fields = append ( fields , f )
}
2023-07-06 10:21:03 -05:00
result := data . NewFrame ( "Testing results" , fields ... )
2022-12-14 08:44:14 -06:00
if err != nil {
return nil , err
}
logger . Info ( "Rule testing finished successfully" , "duration" , time . Since ( start ) )
return result , nil
}
2024-01-04 10:47:13 -06:00
func newBacktestingEvaluator ( ctx context . Context , evalFactory eval . EvaluatorFactory , user identity . Requester , condition models . Condition , reader eval . AlertingResultsReader ) ( backtestingEvaluator , error ) {
2022-12-14 08:44:14 -06:00
for _ , q := range condition . Data {
if q . DatasourceUID == "__data__" || q . QueryType == "__data__" {
if len ( condition . Data ) != 1 {
return nil , errors . New ( "data queries are not supported with other expressions or data queries" )
}
if condition . Condition == "" {
return nil , fmt . Errorf ( "condition must not be empty and be set to the data query %s" , q . RefID )
}
if condition . Condition != q . RefID {
return nil , fmt . Errorf ( "condition must be set to the data query %s" , q . RefID )
}
model := struct {
DataFrame * data . Frame ` json:"data" `
} { }
err := json . Unmarshal ( q . Model , & model )
if err != nil {
return nil , fmt . Errorf ( "failed to parse data frame: %w" , err )
}
if model . DataFrame == nil {
return nil , errors . New ( "the data field must not be empty" )
}
return newDataEvaluator ( condition . Condition , model . DataFrame )
}
}
2024-01-04 10:47:13 -06:00
evaluator , err := evalFactory . Create ( eval . NewContextWithPreviousResults ( ctx , user , reader ) , condition )
2022-12-14 08:44:14 -06:00
if err != nil {
return nil , err
}
return & queryEvaluator {
eval : evaluator ,
} , nil
}
// NoopImageService is a no-op image service.
type NoopImageService struct { }
func ( s * NoopImageService ) NewImage ( _ context . Context , _ * models . AlertRule ) ( * models . Image , error ) {
return & models . Image { } , nil
}