diff --git a/ee/query-service/rules/anomaly.go b/ee/query-service/rules/anomaly.go index ff0aa40be8d8..2ac3b56cb949 100644 --- a/ee/query-service/rules/anomaly.go +++ b/ee/query-service/rules/anomaly.go @@ -166,16 +166,9 @@ func (r *AnomalyRule) prepareQueryRange(ctx context.Context, ts time.Time) (*v3. ctx, "prepare query range request v4", "ts", ts.UnixMilli(), "eval_window", r.EvalWindow().Milliseconds(), "eval_delay", r.EvalDelay().Milliseconds(), ) - start := ts.Add(-time.Duration(r.EvalWindow())).UnixMilli() - end := ts.UnixMilli() - - if r.EvalDelay() > 0 { - start = start - int64(r.EvalDelay().Milliseconds()) - end = end - int64(r.EvalDelay().Milliseconds()) - } - // round to minute otherwise we could potentially miss data - start = start - (start % (60 * 1000)) - end = end - (end % (60 * 1000)) + st, en := r.Timestamps(ts) + start := st.UnixMilli() + end := en.UnixMilli() compositeQuery := r.Condition().CompositeQuery diff --git a/ee/query-service/rules/manager.go b/ee/query-service/rules/manager.go index bf5cbbbec117..3212031f9f3f 100644 --- a/ee/query-service/rules/manager.go +++ b/ee/query-service/rules/manager.go @@ -3,8 +3,10 @@ package rules import ( "context" "fmt" + "time" + "github.com/SigNoz/signoz/pkg/errors" basemodel "github.com/SigNoz/signoz/pkg/query-service/model" baserules "github.com/SigNoz/signoz/pkg/query-service/rules" "github.com/SigNoz/signoz/pkg/query-service/utils/labels" @@ -20,6 +22,10 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error) var task baserules.Task ruleId := baserules.RuleIdFromTaskName(opts.TaskName) + evaluation, err := opts.Rule.Evaluation.GetEvaluation() + if err != nil { + return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "evaluation is invalid: %v", err) + } if opts.Rule.RuleType == ruletypes.RuleTypeThreshold { // create a threshold rule tr, err := baserules.NewThresholdRule( @@ -40,7 +46,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error) rules = append(rules, tr) // create ch rule task for evalution - task = newTask(baserules.TaskTypeCh, opts.TaskName, time.Duration(opts.Rule.Frequency), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID) + task = newTask(baserules.TaskTypeCh, opts.TaskName, time.Duration(evaluation.GetFrequency()), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID) } else if opts.Rule.RuleType == ruletypes.RuleTypeProm { @@ -62,7 +68,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error) rules = append(rules, pr) // create promql rule task for evalution - task = newTask(baserules.TaskTypeProm, opts.TaskName, time.Duration(opts.Rule.Frequency), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID) + task = newTask(baserules.TaskTypeProm, opts.TaskName, time.Duration(evaluation.GetFrequency()), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID) } else if opts.Rule.RuleType == ruletypes.RuleTypeAnomaly { // create anomaly rule @@ -84,7 +90,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error) rules = append(rules, ar) // create anomaly rule task for evalution - task = newTask(baserules.TaskTypeCh, opts.TaskName, time.Duration(opts.Rule.Frequency), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID) + task = newTask(baserules.TaskTypeCh, opts.TaskName, time.Duration(evaluation.GetFrequency()), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID) } else { return nil, fmt.Errorf("unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold) diff --git a/pkg/query-service/rules/base_rule.go b/pkg/query-service/rules/base_rule.go index be14b9133f9b..a0ddcbf8444d 100644 --- a/pkg/query-service/rules/base_rule.go +++ b/pkg/query-service/rules/base_rule.go @@ -9,6 +9,7 @@ import ( "sync" "time" + "github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/query-service/converter" "github.com/SigNoz/signoz/pkg/query-service/interfaces" "github.com/SigNoz/signoz/pkg/query-service/model" @@ -87,6 +88,8 @@ type BaseRule struct { TemporalityMap map[string]map[v3.Temporality]bool sqlstore sqlstore.SQLStore + + evaluation ruletypes.Evaluation } type RuleOption func(*BaseRule) @@ -129,6 +132,10 @@ func NewBaseRule(id string, orgID valuer.UUID, p *ruletypes.PostableRule, reader if err != nil { return nil, err } + evaluation, err := p.Evaluation.GetEvaluation() + if err != nil { + return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to get evaluation: %v", err) + } baseRule := &BaseRule{ id: id, @@ -146,6 +153,7 @@ func NewBaseRule(id string, orgID valuer.UUID, p *ruletypes.PostableRule, reader reader: reader, TemporalityMap: make(map[string]map[v3.Temporality]bool), Threshold: threshold, + evaluation: evaluation, } if baseRule.evalWindow == 0 { @@ -248,8 +256,10 @@ func (r *BaseRule) Unit() string { } func (r *BaseRule) Timestamps(ts time.Time) (time.Time, time.Time) { - start := ts.Add(-time.Duration(r.evalWindow)).UnixMilli() - end := ts.UnixMilli() + + st, en := r.evaluation.NextWindowFor(ts) + start := st.UnixMilli() + end := en.UnixMilli() if r.evalDelay > 0 { start = start - int64(r.evalDelay.Milliseconds()) diff --git a/pkg/query-service/rules/manager.go b/pkg/query-service/rules/manager.go index f80686682687..5264b28f85ff 100644 --- a/pkg/query-service/rules/manager.go +++ b/pkg/query-service/rules/manager.go @@ -12,12 +12,11 @@ import ( "go.uber.org/zap" - "errors" - "github.com/go-openapi/strfmt" "github.com/SigNoz/signoz/pkg/alertmanager" "github.com/SigNoz/signoz/pkg/cache" + "github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/modules/organization" "github.com/SigNoz/signoz/pkg/prometheus" querierV5 "github.com/SigNoz/signoz/pkg/querier" @@ -147,6 +146,12 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) { var task Task ruleId := RuleIdFromTaskName(opts.TaskName) + + evaluation, err := opts.Rule.Evaluation.GetEvaluation() + if err != nil { + return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "evaluation is invalid: %v", err) + } + if opts.Rule.RuleType == ruletypes.RuleTypeThreshold { // create a threshold rule tr, err := NewThresholdRule( @@ -167,7 +172,7 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) { rules = append(rules, tr) // create ch rule task for evalution - task = newTask(TaskTypeCh, opts.TaskName, taskNamesuffix, time.Duration(opts.Rule.Frequency), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID) + task = newTask(TaskTypeCh, opts.TaskName, taskNamesuffix, time.Duration(evaluation.GetFrequency()), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID) } else if opts.Rule.RuleType == ruletypes.RuleTypeProm { @@ -189,7 +194,7 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) { rules = append(rules, pr) // create promql rule task for evalution - task = newTask(TaskTypeProm, opts.TaskName, taskNamesuffix, time.Duration(opts.Rule.Frequency), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID) + task = newTask(TaskTypeProm, opts.TaskName, taskNamesuffix, time.Duration(evaluation.GetFrequency()), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID) } else { return nil, fmt.Errorf("unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold) @@ -400,7 +405,7 @@ func (m *Manager) editTask(_ context.Context, orgID valuer.UUID, rule *ruletypes if err != nil { zap.L().Error("loading tasks failed", zap.Error(err)) - return errors.New("error preparing rule with given parameters, previous rule set restored") + return errors.NewInvalidInputf(errors.CodeInvalidInput, "error preparing rule with given parameters, previous rule set restored") } for _, r := range newTask.Rules() { @@ -593,7 +598,7 @@ func (m *Manager) addTask(_ context.Context, orgID valuer.UUID, rule *ruletypes. if err != nil { zap.L().Error("creating rule task failed", zap.String("name", taskName), zap.Error(err)) - return errors.New("error loading rules, previous rule set restored") + return errors.NewInvalidInputf(errors.CodeInvalidInput, "error loading rules, previous rule set restored") } for _, r := range newTask.Rules() { diff --git a/pkg/query-service/rules/prom_rule.go b/pkg/query-service/rules/prom_rule.go index ea07f85e04b7..773c86a2368b 100644 --- a/pkg/query-service/rules/prom_rule.go +++ b/pkg/query-service/rules/prom_rule.go @@ -123,8 +123,7 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (interface{}, error) prevState := r.State() - start := ts.Add(-r.evalWindow) - end := ts + start, end := r.Timestamps(ts) interval := 60 * time.Second // TODO(srikanthccv): this should be configurable valueFormatter := formatter.FromUnit(r.Unit()) diff --git a/pkg/query-service/rules/promrule_test.go b/pkg/query-service/rules/promrule_test.go index a4e0b94d06a9..17177de622c9 100644 --- a/pkg/query-service/rules/promrule_test.go +++ b/pkg/query-service/rules/promrule_test.go @@ -25,11 +25,13 @@ func getVectorValues(vectors []ruletypes.Sample) []float64 { func TestPromRuleShouldAlert(t *testing.T) { postableRule := ruletypes.PostableRule{ - AlertName: "Test Rule", - AlertType: ruletypes.AlertTypeMetric, - RuleType: ruletypes.RuleTypeProm, - EvalWindow: ruletypes.Duration(5 * time.Minute), - Frequency: ruletypes.Duration(1 * time.Minute), + AlertName: "Test Rule", + AlertType: ruletypes.AlertTypeMetric, + RuleType: ruletypes.RuleTypeProm, + Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{ + EvalWindow: ruletypes.Duration(5 * time.Minute), + Frequency: ruletypes.Duration(1 * time.Minute), + }}, RuleCondition: &ruletypes.RuleCondition{ CompositeQuery: &v3.CompositeQuery{ QueryType: v3.QueryTypePromQL, diff --git a/pkg/query-service/rules/threshold_rule_test.go b/pkg/query-service/rules/threshold_rule_test.go index d6bc92c8ab44..d311a47e186e 100644 --- a/pkg/query-service/rules/threshold_rule_test.go +++ b/pkg/query-service/rules/threshold_rule_test.go @@ -31,11 +31,13 @@ import ( func TestThresholdRuleShouldAlert(t *testing.T) { postableRule := ruletypes.PostableRule{ - AlertName: "Tricky Condition Tests", - AlertType: ruletypes.AlertTypeMetric, - RuleType: ruletypes.RuleTypeThreshold, - EvalWindow: ruletypes.Duration(5 * time.Minute), - Frequency: ruletypes.Duration(1 * time.Minute), + AlertName: "Tricky Condition Tests", + AlertType: ruletypes.AlertTypeMetric, + RuleType: ruletypes.RuleTypeThreshold, + Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{ + EvalWindow: ruletypes.Duration(5 * time.Minute), + Frequency: ruletypes.Duration(1 * time.Minute), + }}, RuleCondition: &ruletypes.RuleCondition{ CompositeQuery: &v3.CompositeQuery{ QueryType: v3.QueryTypeBuilder, @@ -886,11 +888,13 @@ func TestNormalizeLabelName(t *testing.T) { func TestPrepareLinksToLogs(t *testing.T) { postableRule := ruletypes.PostableRule{ - AlertName: "Tricky Condition Tests", - AlertType: ruletypes.AlertTypeLogs, - RuleType: ruletypes.RuleTypeThreshold, - EvalWindow: ruletypes.Duration(5 * time.Minute), - Frequency: ruletypes.Duration(1 * time.Minute), + AlertName: "Tricky Condition Tests", + AlertType: ruletypes.AlertTypeLogs, + RuleType: ruletypes.RuleTypeThreshold, + Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{ + EvalWindow: ruletypes.Duration(5 * time.Minute), + Frequency: ruletypes.Duration(1 * time.Minute), + }}, RuleCondition: &ruletypes.RuleCondition{ CompositeQuery: &v3.CompositeQuery{ QueryType: v3.QueryTypeBuilder, @@ -938,11 +942,13 @@ func TestPrepareLinksToLogs(t *testing.T) { func TestPrepareLinksToLogsV5(t *testing.T) { postableRule := ruletypes.PostableRule{ - AlertName: "Tricky Condition Tests", - AlertType: ruletypes.AlertTypeLogs, - RuleType: ruletypes.RuleTypeThreshold, - EvalWindow: ruletypes.Duration(5 * time.Minute), - Frequency: ruletypes.Duration(1 * time.Minute), + AlertName: "Tricky Condition Tests", + AlertType: ruletypes.AlertTypeLogs, + RuleType: ruletypes.RuleTypeThreshold, + Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{ + EvalWindow: ruletypes.Duration(5 * time.Minute), + Frequency: ruletypes.Duration(1 * time.Minute), + }}, RuleCondition: &ruletypes.RuleCondition{ CompositeQuery: &v3.CompositeQuery{ QueryType: v3.QueryTypeBuilder, @@ -997,11 +1003,13 @@ func TestPrepareLinksToLogsV5(t *testing.T) { func TestPrepareLinksToTracesV5(t *testing.T) { postableRule := ruletypes.PostableRule{ - AlertName: "Tricky Condition Tests", - AlertType: ruletypes.AlertTypeTraces, - RuleType: ruletypes.RuleTypeThreshold, - EvalWindow: ruletypes.Duration(5 * time.Minute), - Frequency: ruletypes.Duration(1 * time.Minute), + AlertName: "Tricky Condition Tests", + AlertType: ruletypes.AlertTypeTraces, + RuleType: ruletypes.RuleTypeThreshold, + Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{ + EvalWindow: ruletypes.Duration(5 * time.Minute), + Frequency: ruletypes.Duration(1 * time.Minute), + }}, RuleCondition: &ruletypes.RuleCondition{ CompositeQuery: &v3.CompositeQuery{ QueryType: v3.QueryTypeBuilder, @@ -1056,11 +1064,13 @@ func TestPrepareLinksToTracesV5(t *testing.T) { func TestPrepareLinksToTraces(t *testing.T) { postableRule := ruletypes.PostableRule{ - AlertName: "Links to traces test", - AlertType: ruletypes.AlertTypeTraces, - RuleType: ruletypes.RuleTypeThreshold, - EvalWindow: ruletypes.Duration(5 * time.Minute), - Frequency: ruletypes.Duration(1 * time.Minute), + AlertName: "Links to traces test", + AlertType: ruletypes.AlertTypeTraces, + RuleType: ruletypes.RuleTypeThreshold, + Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{ + EvalWindow: ruletypes.Duration(5 * time.Minute), + Frequency: ruletypes.Duration(1 * time.Minute), + }}, RuleCondition: &ruletypes.RuleCondition{ CompositeQuery: &v3.CompositeQuery{ QueryType: v3.QueryTypeBuilder, @@ -1108,11 +1118,13 @@ func TestPrepareLinksToTraces(t *testing.T) { func TestThresholdRuleLabelNormalization(t *testing.T) { postableRule := ruletypes.PostableRule{ - AlertName: "Tricky Condition Tests", - AlertType: ruletypes.AlertTypeMetric, - RuleType: ruletypes.RuleTypeThreshold, - EvalWindow: ruletypes.Duration(5 * time.Minute), - Frequency: ruletypes.Duration(1 * time.Minute), + AlertName: "Tricky Condition Tests", + AlertType: ruletypes.AlertTypeMetric, + RuleType: ruletypes.RuleTypeThreshold, + Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{ + EvalWindow: ruletypes.Duration(5 * time.Minute), + Frequency: ruletypes.Duration(1 * time.Minute), + }}, RuleCondition: &ruletypes.RuleCondition{ CompositeQuery: &v3.CompositeQuery{ QueryType: v3.QueryTypeBuilder, @@ -1214,11 +1226,13 @@ func TestThresholdRuleLabelNormalization(t *testing.T) { func TestThresholdRuleEvalDelay(t *testing.T) { postableRule := ruletypes.PostableRule{ - AlertName: "Test Eval Delay", - AlertType: ruletypes.AlertTypeMetric, - RuleType: ruletypes.RuleTypeThreshold, - EvalWindow: ruletypes.Duration(5 * time.Minute), - Frequency: ruletypes.Duration(1 * time.Minute), + AlertName: "Test Eval Delay", + AlertType: ruletypes.AlertTypeMetric, + RuleType: ruletypes.RuleTypeThreshold, + Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{ + EvalWindow: ruletypes.Duration(5 * time.Minute), + Frequency: ruletypes.Duration(1 * time.Minute), + }}, RuleCondition: &ruletypes.RuleCondition{ CompositeQuery: &v3.CompositeQuery{ QueryType: v3.QueryTypeClickHouseSQL, @@ -1275,11 +1289,13 @@ func TestThresholdRuleEvalDelay(t *testing.T) { func TestThresholdRuleClickHouseTmpl(t *testing.T) { postableRule := ruletypes.PostableRule{ - AlertName: "Tricky Condition Tests", - AlertType: ruletypes.AlertTypeMetric, - RuleType: ruletypes.RuleTypeThreshold, - EvalWindow: ruletypes.Duration(5 * time.Minute), - Frequency: ruletypes.Duration(1 * time.Minute), + AlertName: "Tricky Condition Tests", + AlertType: ruletypes.AlertTypeMetric, + RuleType: ruletypes.RuleTypeThreshold, + Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{ + EvalWindow: ruletypes.Duration(5 * time.Minute), + Frequency: ruletypes.Duration(1 * time.Minute), + }}, RuleCondition: &ruletypes.RuleCondition{ CompositeQuery: &v3.CompositeQuery{ QueryType: v3.QueryTypeClickHouseSQL, @@ -1342,11 +1358,13 @@ func (m *queryMatcherAny) Match(x string, y string) error { func TestThresholdRuleUnitCombinations(t *testing.T) { postableRule := ruletypes.PostableRule{ - AlertName: "Units test", - AlertType: ruletypes.AlertTypeMetric, - RuleType: ruletypes.RuleTypeThreshold, - EvalWindow: ruletypes.Duration(5 * time.Minute), - Frequency: ruletypes.Duration(1 * time.Minute), + AlertName: "Units test", + AlertType: ruletypes.AlertTypeMetric, + RuleType: ruletypes.RuleTypeThreshold, + Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{ + EvalWindow: ruletypes.Duration(5 * time.Minute), + Frequency: ruletypes.Duration(1 * time.Minute), + }}, RuleCondition: &ruletypes.RuleCondition{ CompositeQuery: &v3.CompositeQuery{ QueryType: v3.QueryTypeBuilder, @@ -1535,11 +1553,13 @@ func TestThresholdRuleUnitCombinations(t *testing.T) { func TestThresholdRuleNoData(t *testing.T) { postableRule := ruletypes.PostableRule{ - AlertName: "No data test", - AlertType: ruletypes.AlertTypeMetric, - RuleType: ruletypes.RuleTypeThreshold, - EvalWindow: ruletypes.Duration(5 * time.Minute), - Frequency: ruletypes.Duration(1 * time.Minute), + AlertName: "No data test", + AlertType: ruletypes.AlertTypeMetric, + RuleType: ruletypes.RuleTypeThreshold, + Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{ + EvalWindow: ruletypes.Duration(5 * time.Minute), + Frequency: ruletypes.Duration(1 * time.Minute), + }}, RuleCondition: &ruletypes.RuleCondition{ CompositeQuery: &v3.CompositeQuery{ QueryType: v3.QueryTypeBuilder, @@ -1638,11 +1658,13 @@ func TestThresholdRuleNoData(t *testing.T) { func TestThresholdRuleTracesLink(t *testing.T) { postableRule := ruletypes.PostableRule{ - AlertName: "Traces link test", - AlertType: ruletypes.AlertTypeTraces, - RuleType: ruletypes.RuleTypeThreshold, - EvalWindow: ruletypes.Duration(5 * time.Minute), - Frequency: ruletypes.Duration(1 * time.Minute), + AlertName: "Traces link test", + AlertType: ruletypes.AlertTypeTraces, + RuleType: ruletypes.RuleTypeThreshold, + Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{ + EvalWindow: ruletypes.Duration(5 * time.Minute), + Frequency: ruletypes.Duration(1 * time.Minute), + }}, RuleCondition: &ruletypes.RuleCondition{ CompositeQuery: &v3.CompositeQuery{ QueryType: v3.QueryTypeBuilder, @@ -1763,11 +1785,13 @@ func TestThresholdRuleTracesLink(t *testing.T) { func TestThresholdRuleLogsLink(t *testing.T) { postableRule := ruletypes.PostableRule{ - AlertName: "Logs link test", - AlertType: ruletypes.AlertTypeLogs, - RuleType: ruletypes.RuleTypeThreshold, - EvalWindow: ruletypes.Duration(5 * time.Minute), - Frequency: ruletypes.Duration(1 * time.Minute), + AlertName: "Logs link test", + AlertType: ruletypes.AlertTypeLogs, + RuleType: ruletypes.RuleTypeThreshold, + Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{ + EvalWindow: ruletypes.Duration(5 * time.Minute), + Frequency: ruletypes.Duration(1 * time.Minute), + }}, RuleCondition: &ruletypes.RuleCondition{ CompositeQuery: &v3.CompositeQuery{ QueryType: v3.QueryTypeBuilder, @@ -1901,11 +1925,13 @@ func TestThresholdRuleLogsLink(t *testing.T) { func TestThresholdRuleShiftBy(t *testing.T) { target := float64(10) postableRule := ruletypes.PostableRule{ - AlertName: "Logs link test", - AlertType: ruletypes.AlertTypeLogs, - RuleType: ruletypes.RuleTypeThreshold, - EvalWindow: ruletypes.Duration(5 * time.Minute), - Frequency: ruletypes.Duration(1 * time.Minute), + AlertName: "Logs link test", + AlertType: ruletypes.AlertTypeLogs, + RuleType: ruletypes.RuleTypeThreshold, + Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{ + EvalWindow: ruletypes.Duration(5 * time.Minute), + Frequency: ruletypes.Duration(1 * time.Minute), + }}, RuleCondition: &ruletypes.RuleCondition{ Thresholds: &ruletypes.RuleThresholdData{ Kind: ruletypes.BasicThresholdKind, @@ -1973,11 +1999,13 @@ func TestThresholdRuleShiftBy(t *testing.T) { func TestMultipleThresholdRule(t *testing.T) { postableRule := ruletypes.PostableRule{ - AlertName: "Mulitple threshold test", - AlertType: ruletypes.AlertTypeMetric, - RuleType: ruletypes.RuleTypeThreshold, - EvalWindow: ruletypes.Duration(5 * time.Minute), - Frequency: ruletypes.Duration(1 * time.Minute), + AlertName: "Mulitple threshold test", + AlertType: ruletypes.AlertTypeMetric, + RuleType: ruletypes.RuleTypeThreshold, + Evaluation: &ruletypes.EvaluationEnvelope{ruletypes.RollingEvaluation, ruletypes.RollingWindow{ + EvalWindow: ruletypes.Duration(5 * time.Minute), + Frequency: ruletypes.Duration(1 * time.Minute), + }}, RuleCondition: &ruletypes.RuleCondition{ CompositeQuery: &v3.CompositeQuery{ QueryType: v3.QueryTypeBuilder, diff --git a/pkg/types/ruletypes/api_params.go b/pkg/types/ruletypes/api_params.go index f4ad6b55cd2b..9285b070fbe6 100644 --- a/pkg/types/ruletypes/api_params.go +++ b/pkg/types/ruletypes/api_params.go @@ -50,6 +50,8 @@ type PostableRule struct { PreferredChannels []string `json:"preferredChannels,omitempty"` Version string `json:"version,omitempty"` + + Evaluation *EvaluationEnvelope `yaml:"evaluation,omitempty" json:"evaluation,omitempty"` } func (r *PostableRule) processRuleDefaults() error { @@ -98,6 +100,9 @@ func (r *PostableRule) processRuleDefaults() error { r.RuleCondition.Thresholds = &thresholdData } } + if r.Evaluation == nil { + r.Evaluation = &EvaluationEnvelope{RollingEvaluation, RollingWindow{EvalWindow: r.EvalWindow, Frequency: r.Frequency}} + } return r.Validate() } diff --git a/pkg/types/ruletypes/evaluation.go b/pkg/types/ruletypes/evaluation.go new file mode 100644 index 000000000000..7677bbbbcbab --- /dev/null +++ b/pkg/types/ruletypes/evaluation.go @@ -0,0 +1,287 @@ +package ruletypes + +import ( + "encoding/json" + "time" + + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/valuer" +) + +type EvaluationKind struct { + valuer.String +} + +var ( + RollingEvaluation = EvaluationKind{valuer.NewString("rolling")} + CumulativeEvaluation = EvaluationKind{valuer.NewString("cumulative")} +) + +type Evaluation interface { + NextWindowFor(curr time.Time) (time.Time, time.Time) + GetFrequency() Duration +} + +type RollingWindow struct { + EvalWindow Duration `json:"evalWindow"` + Frequency Duration `json:"frequency"` +} + +func (rollingWindow RollingWindow) Validate() error { + if rollingWindow.EvalWindow <= 0 { + return errors.NewInvalidInputf(errors.CodeInvalidInput, "evalWindow must be greater than zero") + } + if rollingWindow.Frequency <= 0 { + return errors.NewInvalidInputf(errors.CodeInvalidInput, "frequency must be greater than zero") + } + return nil +} + +func (rollingWindow RollingWindow) NextWindowFor(curr time.Time) (time.Time, time.Time) { + return curr.Add(time.Duration(-rollingWindow.EvalWindow)), curr +} + +func (rollingWindow RollingWindow) GetFrequency() Duration { + return rollingWindow.Frequency +} + +type CumulativeWindow struct { + Schedule CumulativeSchedule `json:"schedule"` + Frequency Duration `json:"frequency"` + Timezone string `json:"timezone"` +} + +type CumulativeSchedule struct { + Type ScheduleType `json:"type"` + Minute *int `json:"minute,omitempty"` // 0-59, for all types + Hour *int `json:"hour,omitempty"` // 0-23, for daily/weekly/monthly + Day *int `json:"day,omitempty"` // 1-31, for monthly + Weekday *int `json:"weekday,omitempty"` // 0-6 (Sunday=0), for weekly +} + +type ScheduleType struct { + valuer.String +} + +var ( + ScheduleTypeHourly = ScheduleType{valuer.NewString("hourly")} + ScheduleTypeDaily = ScheduleType{valuer.NewString("daily")} + ScheduleTypeWeekly = ScheduleType{valuer.NewString("weekly")} + ScheduleTypeMonthly = ScheduleType{valuer.NewString("monthly")} +) + +func (cumulativeWindow CumulativeWindow) Validate() error { + // Validate schedule + if err := cumulativeWindow.Schedule.Validate(); err != nil { + return err + } + + if _, err := time.LoadLocation(cumulativeWindow.Timezone); err != nil { + return errors.NewInvalidInputf(errors.CodeInvalidInput, "timezone is invalid") + } + if cumulativeWindow.Frequency <= 0 { + return errors.NewInvalidInputf(errors.CodeInvalidInput, "frequency must be greater than zero") + } + return nil +} + +func (cs CumulativeSchedule) Validate() error { + switch cs.Type { + case ScheduleTypeHourly: + if cs.Minute == nil { + return errors.NewInvalidInputf(errors.CodeInvalidInput, "minute must be specified for hourly schedule") + } + if *cs.Minute < 0 || *cs.Minute > 59 { + return errors.NewInvalidInputf(errors.CodeInvalidInput, "minute must be between 0 and 59") + } + case ScheduleTypeDaily: + if cs.Hour == nil || cs.Minute == nil { + return errors.NewInvalidInputf(errors.CodeInvalidInput, "hour and minute must be specified for daily schedule") + } + if *cs.Hour < 0 || *cs.Hour > 23 { + return errors.NewInvalidInputf(errors.CodeInvalidInput, "hour must be between 0 and 23") + } + if *cs.Minute < 0 || *cs.Minute > 59 { + return errors.NewInvalidInputf(errors.CodeInvalidInput, "minute must be between 0 and 59") + } + case ScheduleTypeWeekly: + if cs.Weekday == nil || cs.Hour == nil || cs.Minute == nil { + return errors.NewInvalidInputf(errors.CodeInvalidInput, "weekday, hour and minute must be specified for weekly schedule") + } + if *cs.Weekday < 0 || *cs.Weekday > 6 { + return errors.NewInvalidInputf(errors.CodeInvalidInput, "weekday must be between 0 and 6 (Sunday=0)") + } + if *cs.Hour < 0 || *cs.Hour > 23 { + return errors.NewInvalidInputf(errors.CodeInvalidInput, "hour must be between 0 and 23") + } + if *cs.Minute < 0 || *cs.Minute > 59 { + return errors.NewInvalidInputf(errors.CodeInvalidInput, "minute must be between 0 and 59") + } + case ScheduleTypeMonthly: + if cs.Day == nil || cs.Hour == nil || cs.Minute == nil { + return errors.NewInvalidInputf(errors.CodeInvalidInput, "day, hour and minute must be specified for monthly schedule") + } + if *cs.Day < 1 || *cs.Day > 31 { + return errors.NewInvalidInputf(errors.CodeInvalidInput, "day must be between 1 and 31") + } + if *cs.Hour < 0 || *cs.Hour > 23 { + return errors.NewInvalidInputf(errors.CodeInvalidInput, "hour must be between 0 and 23") + } + if *cs.Minute < 0 || *cs.Minute > 59 { + return errors.NewInvalidInputf(errors.CodeInvalidInput, "minute must be between 0 and 59") + } + default: + return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid schedule type") + } + return nil +} + +func (cumulativeWindow CumulativeWindow) NextWindowFor(curr time.Time) (time.Time, time.Time) { + loc := time.UTC + if cumulativeWindow.Timezone != "" { + if tz, err := time.LoadLocation(cumulativeWindow.Timezone); err == nil { + loc = tz + } + } + + currInTZ := curr.In(loc) + windowStart := cumulativeWindow.getLastScheduleTime(currInTZ, loc) + + return windowStart.In(time.UTC), currInTZ.In(time.UTC) +} + +func (cw CumulativeWindow) getLastScheduleTime(curr time.Time, loc *time.Location) time.Time { + schedule := cw.Schedule + + switch schedule.Type { + case ScheduleTypeHourly: + // Find the most recent hour boundary with the specified minute + minute := *schedule.Minute + candidate := time.Date(curr.Year(), curr.Month(), curr.Day(), curr.Hour(), minute, 0, 0, loc) + if candidate.After(curr) { + candidate = candidate.Add(-time.Hour) + } + return candidate + + case ScheduleTypeDaily: + // Find the most recent day boundary with the specified hour and minute + hour := *schedule.Hour + minute := *schedule.Minute + candidate := time.Date(curr.Year(), curr.Month(), curr.Day(), hour, minute, 0, 0, loc) + if candidate.After(curr) { + candidate = candidate.AddDate(0, 0, -1) + } + return candidate + + case ScheduleTypeWeekly: + weekday := time.Weekday(*schedule.Weekday) + hour := *schedule.Hour + minute := *schedule.Minute + + // Calculate days to subtract to reach the target weekday + daysBack := int(curr.Weekday() - weekday) + if daysBack < 0 { + daysBack += 7 + } + + candidate := time.Date(curr.Year(), curr.Month(), curr.Day(), hour, minute, 0, 0, loc).AddDate(0, 0, -daysBack) + if candidate.After(curr) { + candidate = candidate.AddDate(0, 0, -7) + } + return candidate + + case ScheduleTypeMonthly: + // Find the most recent month boundary with the specified day, hour and minute + targetDay := *schedule.Day + hour := *schedule.Hour + minute := *schedule.Minute + + // Try current month first + lastDayOfCurrentMonth := time.Date(curr.Year(), curr.Month()+1, 0, 0, 0, 0, 0, loc).Day() + dayInCurrentMonth := targetDay + if targetDay > lastDayOfCurrentMonth { + dayInCurrentMonth = lastDayOfCurrentMonth + } + + candidate := time.Date(curr.Year(), curr.Month(), dayInCurrentMonth, hour, minute, 0, 0, loc) + if candidate.After(curr) { + prevMonth := curr.AddDate(0, -1, 0) + lastDayOfPrevMonth := time.Date(prevMonth.Year(), prevMonth.Month()+1, 0, 0, 0, 0, 0, loc).Day() + dayInPrevMonth := targetDay + if targetDay > lastDayOfPrevMonth { + dayInPrevMonth = lastDayOfPrevMonth + } + candidate = time.Date(prevMonth.Year(), prevMonth.Month(), dayInPrevMonth, hour, minute, 0, 0, loc) + } + return candidate + + default: + return curr + } +} + +func (cumulativeWindow CumulativeWindow) GetFrequency() Duration { + return cumulativeWindow.Frequency +} + +type EvaluationEnvelope struct { + Kind EvaluationKind `json:"kind"` + Spec any `json:"spec"` +} + +func (e *EvaluationEnvelope) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to unmarshal evaluation: %v", err) + } + if err := json.Unmarshal(raw["kind"], &e.Kind); err != nil { + return errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to unmarshal evaluation kind: %v", err) + } + switch e.Kind { + case RollingEvaluation: + var rollingWindow RollingWindow + if err := json.Unmarshal(raw["spec"], &rollingWindow); err != nil { + return errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to unmarshal rolling window: %v", err) + } + err := rollingWindow.Validate() + if err != nil { + return errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to validate rolling window: %v", err) + } + e.Spec = rollingWindow + case CumulativeEvaluation: + var cumulativeWindow CumulativeWindow + if err := json.Unmarshal(raw["spec"], &cumulativeWindow); err != nil { + return errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to unmarshal cumulative window: %v", err) + } + err := cumulativeWindow.Validate() + if err != nil { + return errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to validate cumulative window: %v", err) + } + e.Spec = cumulativeWindow + + default: + return errors.NewInvalidInputf(errors.CodeUnsupported, "unknown evaluation kind") + } + + return nil +} + +func (e *EvaluationEnvelope) GetEvaluation() (Evaluation, error) { + if e.Kind.IsZero() { + e.Kind = RollingEvaluation + } + + switch e.Kind { + case RollingEvaluation: + if rolling, ok := e.Spec.(RollingWindow); ok { + return rolling, nil + } + case CumulativeEvaluation: + if cumulative, ok := e.Spec.(CumulativeWindow); ok { + return cumulative, nil + } + default: + return nil, errors.NewInvalidInputf(errors.CodeUnsupported, "unknown evaluation kind") + } + return nil, errors.NewInvalidInputf(errors.CodeUnsupported, "unknown evaluation kind") +} diff --git a/pkg/types/ruletypes/evaluation_test.go b/pkg/types/ruletypes/evaluation_test.go new file mode 100644 index 000000000000..aded4c10e04a --- /dev/null +++ b/pkg/types/ruletypes/evaluation_test.go @@ -0,0 +1,878 @@ +package ruletypes + +import ( + "encoding/json" + "testing" + "time" +) + +func TestRollingWindow_EvaluationTime(t *testing.T) { + tests := []struct { + name string + evalWindow Duration + current time.Time + wantStart time.Time + wantEnd time.Time + }{ + { + name: "5 minute rolling window", + evalWindow: Duration(5 * time.Minute), + current: time.Date(2023, 12, 1, 12, 30, 0, 0, time.UTC), + wantStart: time.Date(2023, 12, 1, 12, 25, 0, 0, time.UTC), + wantEnd: time.Date(2023, 12, 1, 12, 30, 0, 0, time.UTC), + }, + { + name: "1 hour rolling window", + evalWindow: Duration(1 * time.Hour), + current: time.Date(2023, 12, 1, 15, 45, 30, 0, time.UTC), + wantStart: time.Date(2023, 12, 1, 14, 45, 30, 0, time.UTC), + wantEnd: time.Date(2023, 12, 1, 15, 45, 30, 0, time.UTC), + }, + { + name: "30 second rolling window", + evalWindow: Duration(30 * time.Second), + current: time.Date(2023, 12, 1, 12, 30, 15, 0, time.UTC), + wantStart: time.Date(2023, 12, 1, 12, 29, 45, 0, time.UTC), + wantEnd: time.Date(2023, 12, 1, 12, 30, 15, 0, time.UTC), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rw := &RollingWindow{ + EvalWindow: tt.evalWindow, + Frequency: Duration(1 * time.Minute), + } + + gotStart, gotEnd := rw.NextWindowFor(tt.current) + if !gotStart.Equal(tt.wantStart) { + t.Errorf("RollingWindow.NextWindowFor() start time = %v, want %v", gotStart, tt.wantStart) + } + if !gotEnd.Equal(tt.wantEnd) { + t.Errorf("RollingWindow.NextWindowFor() end time = %v, want %v", gotEnd, tt.wantEnd) + } + }) + } +} + +func TestCumulativeWindow_NewScheduleSystem(t *testing.T) { + tests := []struct { + name string + window CumulativeWindow + current time.Time + wantErr bool + }{ + { + name: "hourly schedule - minute 15", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeHourly, + Minute: intPtr(15), + }, + Frequency: Duration(5 * time.Minute), + Timezone: "UTC", + }, + current: time.Date(2025, 3, 15, 14, 30, 0, 0, time.UTC), + wantErr: false, + }, + { + name: "daily schedule - 9:30 AM IST", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeDaily, + Hour: intPtr(9), + Minute: intPtr(30), + }, + Frequency: Duration(1 * time.Hour), + Timezone: "Asia/Kolkata", + }, + current: time.Date(2025, 3, 15, 15, 30, 0, 0, time.UTC), + wantErr: false, + }, + { + name: "weekly schedule - Monday 2:00 PM", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeWeekly, + Weekday: intPtr(1), // Monday + Hour: intPtr(14), + Minute: intPtr(0), + }, + Frequency: Duration(24 * time.Hour), + Timezone: "America/New_York", + }, + current: time.Date(2025, 3, 18, 19, 0, 0, 0, time.UTC), // Tuesday + wantErr: false, + }, + { + name: "monthly schedule - 1st at midnight", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeMonthly, + Day: intPtr(1), + Hour: intPtr(0), + Minute: intPtr(0), + }, + Frequency: Duration(24 * time.Hour), + Timezone: "UTC", + }, + current: time.Date(2025, 3, 15, 12, 0, 0, 0, time.UTC), + wantErr: false, + }, + { + name: "invalid schedule - missing minute for hourly", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeHourly, + }, + Frequency: Duration(5 * time.Minute), + Timezone: "UTC", + }, + current: time.Date(2025, 3, 15, 14, 30, 0, 0, time.UTC), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test validation + err := tt.window.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("CumulativeWindow.Validate() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !tt.wantErr { + // Test NextWindowFor + start, end := tt.window.NextWindowFor(tt.current) + + // Basic validation + if start.After(end) { + t.Errorf("Window start should not be after end: start=%v, end=%v", start, end) + } + + if end.After(tt.current) { + t.Errorf("Window end should not be after current time: end=%v, current=%v", end, tt.current) + } + } + }) + } +} + +func intPtr(i int) *int { + return &i +} + +func TestCumulativeWindow_NextWindowFor(t *testing.T) { + tests := []struct { + name string + window CumulativeWindow + current time.Time + wantStart time.Time + wantEnd time.Time + }{ + // Hourly schedule tests + { + name: "hourly - current at exact minute", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeHourly, + Minute: intPtr(30), + }, + Timezone: "UTC", + }, + current: time.Date(2025, 3, 15, 14, 30, 0, 0, time.UTC), + wantStart: time.Date(2025, 3, 15, 14, 30, 0, 0, time.UTC), + wantEnd: time.Date(2025, 3, 15, 14, 30, 0, 0, time.UTC), + }, + { + name: "hourly - current after scheduled minute", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeHourly, + Minute: intPtr(15), + }, + Timezone: "UTC", + }, + current: time.Date(2025, 3, 15, 14, 45, 0, 0, time.UTC), + wantStart: time.Date(2025, 3, 15, 14, 15, 0, 0, time.UTC), + wantEnd: time.Date(2025, 3, 15, 14, 45, 0, 0, time.UTC), + }, + { + name: "hourly - current before scheduled minute", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeHourly, + Minute: intPtr(30), + }, + Timezone: "UTC", + }, + current: time.Date(2025, 3, 15, 14, 15, 0, 0, time.UTC), + wantStart: time.Date(2025, 3, 15, 13, 30, 0, 0, time.UTC), // Previous hour + wantEnd: time.Date(2025, 3, 15, 14, 15, 0, 0, time.UTC), + }, + { + name: "hourly - current before scheduled minute", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeHourly, + Minute: intPtr(30), + }, + Timezone: "UTC", + }, + current: time.Date(2025, 3, 15, 13, 14, 0, 0, time.UTC), + wantStart: time.Date(2025, 3, 15, 12, 30, 0, 0, time.UTC), // Previous hour + wantEnd: time.Date(2025, 3, 15, 13, 14, 0, 0, time.UTC), + }, + { + name: "hourly - current before scheduled minute", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeHourly, + Minute: intPtr(30), + }, + Timezone: "Asia/Kolkata", + }, + current: time.Date(2025, 3, 15, 13, 14, 0, 0, time.UTC), + wantStart: time.Date(2025, 3, 15, 13, 00, 0, 0, time.UTC), // Previous hour + wantEnd: time.Date(2025, 3, 15, 13, 14, 0, 0, time.UTC), + }, + + // Daily schedule tests + { + name: "daily - current at exact time", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeDaily, + Hour: intPtr(9), + Minute: intPtr(30), + }, + Timezone: "UTC", + }, + current: time.Date(2025, 3, 15, 9, 30, 0, 0, time.UTC), + wantStart: time.Date(2025, 3, 15, 9, 30, 0, 0, time.UTC), + wantEnd: time.Date(2025, 3, 15, 9, 30, 0, 0, time.UTC), + }, + { + name: "daily - current after scheduled time", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeDaily, + Hour: intPtr(9), + Minute: intPtr(30), + }, + Timezone: "UTC", + }, + current: time.Date(2025, 3, 15, 15, 45, 0, 0, time.UTC), + wantStart: time.Date(2025, 3, 15, 9, 30, 0, 0, time.UTC), + wantEnd: time.Date(2025, 3, 15, 15, 45, 0, 0, time.UTC), + }, + { + name: "daily - current before scheduled time", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeDaily, + Hour: intPtr(9), + Minute: intPtr(30), + }, + Timezone: "UTC", + }, + current: time.Date(2025, 3, 15, 8, 15, 0, 0, time.UTC), + wantStart: time.Date(2025, 3, 14, 9, 30, 0, 0, time.UTC), // Previous day + wantEnd: time.Date(2025, 3, 15, 8, 15, 0, 0, time.UTC), + }, + { + name: "daily - with timezone IST", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeDaily, + Hour: intPtr(9), + Minute: intPtr(30), + }, + Timezone: "Asia/Kolkata", + }, + current: time.Date(2025, 3, 15, 15, 30, 0, 0, time.UTC), // 9:00 PM IST + wantStart: time.Date(2025, 3, 15, 4, 0, 0, 0, time.UTC), // 9:30 AM IST in UTC + wantEnd: time.Date(2025, 3, 15, 15, 30, 0, 0, time.UTC), + }, + + // Weekly schedule tests + { + name: "weekly - current on scheduled day at exact time", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeWeekly, + Weekday: intPtr(1), // Monday + Hour: intPtr(14), + Minute: intPtr(0), + }, + Timezone: "UTC", + }, + current: time.Date(2025, 3, 17, 14, 0, 0, 0, time.UTC), // Monday + wantStart: time.Date(2025, 3, 17, 14, 0, 0, 0, time.UTC), + wantEnd: time.Date(2025, 3, 17, 14, 0, 0, 0, time.UTC), + }, + { + name: "weekly - current on different day", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeWeekly, + Weekday: intPtr(1), // Monday + Hour: intPtr(14), + Minute: intPtr(0), + }, + Timezone: "UTC", + }, + current: time.Date(2025, 3, 19, 10, 30, 0, 0, time.UTC), // Wednesday + wantStart: time.Date(2025, 3, 17, 14, 0, 0, 0, time.UTC), // Previous Monday + wantEnd: time.Date(2025, 3, 19, 10, 30, 0, 0, time.UTC), + }, + { + name: "weekly - current before scheduled time on same day", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeWeekly, + Weekday: intPtr(2), // Tuesday + Hour: intPtr(14), + Minute: intPtr(0), + }, + Timezone: "UTC", + }, + current: time.Date(2025, 3, 18, 10, 0, 0, 0, time.UTC), // Tuesday before 2 PM + wantStart: time.Date(2025, 3, 11, 14, 0, 0, 0, time.UTC), // Previous Tuesday + wantEnd: time.Date(2025, 3, 18, 10, 0, 0, 0, time.UTC), + }, + + // Monthly schedule tests + { + name: "monthly - current on scheduled day at exact time", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeMonthly, + Day: intPtr(15), + Hour: intPtr(12), + Minute: intPtr(0), + }, + Timezone: "UTC", + }, + current: time.Date(2025, 3, 15, 12, 0, 0, 0, time.UTC), + wantStart: time.Date(2025, 3, 15, 12, 0, 0, 0, time.UTC), + wantEnd: time.Date(2025, 3, 15, 12, 0, 0, 0, time.UTC), + }, + { + name: "monthly - current after scheduled time", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeMonthly, + Day: intPtr(1), + Hour: intPtr(0), + Minute: intPtr(0), + }, + Timezone: "UTC", + }, + current: time.Date(2025, 3, 15, 16, 30, 0, 0, time.UTC), + wantStart: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC), + wantEnd: time.Date(2025, 3, 15, 16, 30, 0, 0, time.UTC), + }, + { + name: "monthly - current before scheduled day", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeMonthly, + Day: intPtr(15), + Hour: intPtr(12), + Minute: intPtr(0), + }, + Timezone: "UTC", + }, + current: time.Date(2025, 3, 10, 10, 0, 0, 0, time.UTC), + wantStart: time.Date(2025, 2, 15, 12, 0, 0, 0, time.UTC), // Previous month + wantEnd: time.Date(2025, 3, 10, 10, 0, 0, 0, time.UTC), + }, + { + name: "monthly - day 31 in february (edge case)", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeMonthly, + Day: intPtr(31), + Hour: intPtr(12), + Minute: intPtr(0), + }, + Timezone: "UTC", + }, + current: time.Date(2025, 3, 15, 10, 0, 0, 0, time.UTC), + wantStart: time.Date(2025, 2, 28, 12, 0, 0, 0, time.UTC), // Feb 28 (last day of Feb) + wantEnd: time.Date(2025, 3, 15, 10, 0, 0, 0, time.UTC), + }, + + // Comprehensive timezone-based test cases + { + name: "Asia/Tokyo timezone - hourly schedule", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeHourly, + Minute: intPtr(45), + }, + Timezone: "Asia/Tokyo", + }, + current: time.Date(2023, 12, 15, 2, 30, 0, 0, time.UTC), // 11:30 AM JST + wantStart: time.Date(2023, 12, 15, 1, 45, 0, 0, time.UTC), // 10:45 AM JST in UTC + wantEnd: time.Date(2023, 12, 15, 2, 30, 0, 0, time.UTC), + }, + { + name: "America/New_York timezone - daily schedule (EST)", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeDaily, + Hour: intPtr(8), // 8 AM EST + Minute: intPtr(0), + }, + Timezone: "America/New_York", + }, + current: time.Date(2023, 12, 15, 20, 30, 0, 0, time.UTC), // 3:30 PM EST + wantStart: time.Date(2023, 12, 15, 13, 0, 0, 0, time.UTC), // 8 AM EST in UTC + wantEnd: time.Date(2023, 12, 15, 20, 30, 0, 0, time.UTC), + }, + { + name: "Europe/London timezone - weekly schedule (GMT)", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeWeekly, + Weekday: intPtr(1), // Monday + Hour: intPtr(12), + Minute: intPtr(0), + }, + Timezone: "Europe/London", + }, + current: time.Date(2023, 12, 15, 15, 0, 0, 0, time.UTC), // Friday 3 PM GMT + wantStart: time.Date(2023, 12, 11, 12, 0, 0, 0, time.UTC), // Previous Monday 12 PM GMT + wantEnd: time.Date(2023, 12, 15, 15, 0, 0, 0, time.UTC), + }, + { + name: "Australia/Sydney timezone - monthly schedule (AEDT)", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeMonthly, + Day: intPtr(1), + Hour: intPtr(0), // Midnight AEDT + Minute: intPtr(0), + }, + Timezone: "Australia/Sydney", + }, + current: time.Date(2023, 12, 15, 5, 0, 0, 0, time.UTC), // 4 PM AEDT on 15th + wantStart: time.Date(2023, 11, 30, 13, 0, 0, 0, time.UTC), // Midnight AEDT on Dec 1st in UTC (Nov 30 13:00 UTC) + wantEnd: time.Date(2023, 12, 15, 5, 0, 0, 0, time.UTC), + }, + { + name: "Pacific/Honolulu timezone - hourly schedule (HST)", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeHourly, + Minute: intPtr(30), + }, + Timezone: "Pacific/Honolulu", + }, + current: time.Date(2023, 12, 15, 22, 45, 0, 0, time.UTC), // 12:45 PM HST + wantStart: time.Date(2023, 12, 15, 22, 30, 0, 0, time.UTC), // 12:30 PM HST in UTC + wantEnd: time.Date(2023, 12, 15, 22, 45, 0, 0, time.UTC), + }, + { + name: "America/Los_Angeles timezone - DST transition daily", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeDaily, + Hour: intPtr(2), // 2 AM PST/PDT + Minute: intPtr(0), + }, + Timezone: "America/Los_Angeles", + }, + current: time.Date(2023, 3, 12, 15, 0, 0, 0, time.UTC), // Day after DST starts + wantStart: time.Date(2023, 3, 12, 9, 0, 0, 0, time.UTC), // 2 AM PDT in UTC (PDT = UTC-7) + wantEnd: time.Date(2023, 3, 12, 15, 0, 0, 0, time.UTC), + }, + { + name: "Europe/Berlin timezone - weekly schedule (CET)", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeWeekly, + Weekday: intPtr(5), // Friday + Hour: intPtr(16), // 4 PM CET + Minute: intPtr(30), + }, + Timezone: "Europe/Berlin", + }, + current: time.Date(2023, 12, 18, 10, 0, 0, 0, time.UTC), // Monday 11 AM CET + wantStart: time.Date(2023, 12, 15, 15, 30, 0, 0, time.UTC), // Previous Friday 4:30 PM CET + wantEnd: time.Date(2023, 12, 18, 10, 0, 0, 0, time.UTC), + }, + { + name: "Asia/Kolkata timezone - monthly edge case (IST)", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeMonthly, + Day: intPtr(31), // 31st (edge case for Feb) + Hour: intPtr(23), + Minute: intPtr(59), + }, + Timezone: "Asia/Kolkata", + }, + current: time.Date(2023, 3, 10, 12, 0, 0, 0, time.UTC), // March 10th 5:30 PM IST + wantStart: time.Date(2023, 2, 28, 18, 29, 0, 0, time.UTC), // Feb 28 11:59 PM IST (last day of Feb) + wantEnd: time.Date(2023, 3, 10, 12, 0, 0, 0, time.UTC), + }, + { + name: "America/Chicago timezone - hourly across midnight (CST)", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeHourly, + Minute: intPtr(0), // Top of hour + }, + Timezone: "America/Chicago", + }, + current: time.Date(2023, 12, 15, 6, 30, 0, 0, time.UTC), // 12:30 AM CST + wantStart: time.Date(2023, 12, 15, 6, 0, 0, 0, time.UTC), // Midnight CST in UTC + wantEnd: time.Date(2023, 12, 15, 6, 30, 0, 0, time.UTC), + }, + + // Boundary condition test cases + { + name: "boundary - end of year transition (Dec 31 to Jan 1)", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeDaily, + Hour: intPtr(0), + Minute: intPtr(0), + }, + Timezone: "UTC", + }, + current: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC), // Jan 1st noon + wantStart: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), // Jan 1st midnight + wantEnd: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC), + }, + { + name: "boundary - leap year Feb 29th monthly schedule", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeMonthly, + Day: intPtr(29), + Hour: intPtr(15), + Minute: intPtr(30), + }, + Timezone: "UTC", + }, + current: time.Date(2024, 3, 10, 10, 0, 0, 0, time.UTC), // March 10th (leap year) + wantStart: time.Date(2024, 2, 29, 15, 30, 0, 0, time.UTC), // Feb 29th exists in leap year + wantEnd: time.Date(2024, 3, 10, 10, 0, 0, 0, time.UTC), + }, + { + name: "boundary - non-leap year Feb 29th request (fallback to Feb 28th)", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeMonthly, + Day: intPtr(29), + Hour: intPtr(15), + Minute: intPtr(30), + }, + Timezone: "UTC", + }, + current: time.Date(2023, 3, 10, 10, 0, 0, 0, time.UTC), // March 10th (non-leap year) + wantStart: time.Date(2023, 2, 28, 15, 30, 0, 0, time.UTC), // Feb 28th (fallback) + wantEnd: time.Date(2023, 3, 10, 10, 0, 0, 0, time.UTC), + }, + { + name: "boundary - day 31 in April (30-day month fallback)", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeMonthly, + Day: intPtr(31), + Hour: intPtr(12), + Minute: intPtr(0), + }, + Timezone: "UTC", + }, + current: time.Date(2023, 5, 15, 10, 0, 0, 0, time.UTC), // May 15th + wantStart: time.Date(2023, 4, 30, 12, 0, 0, 0, time.UTC), // April 30th (fallback from 31st) + wantEnd: time.Date(2023, 5, 15, 10, 0, 0, 0, time.UTC), + }, + { + name: "boundary - weekly Sunday to Monday transition", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeWeekly, + Weekday: intPtr(0), // Sunday + Hour: intPtr(23), + Minute: intPtr(59), + }, + Timezone: "UTC", + }, + current: time.Date(2023, 12, 11, 1, 0, 0, 0, time.UTC), // Monday 1 AM + wantStart: time.Date(2023, 12, 10, 23, 59, 0, 0, time.UTC), // Previous Sunday 11:59 PM + wantEnd: time.Date(2023, 12, 11, 1, 0, 0, 0, time.UTC), + }, + { + name: "boundary - hourly minute 59 to minute 0 transition", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeHourly, + Minute: intPtr(59), + }, + Timezone: "UTC", + }, + current: time.Date(2023, 12, 15, 14, 5, 0, 0, time.UTC), // 14:05 + wantStart: time.Date(2023, 12, 15, 13, 59, 0, 0, time.UTC), // 13:59 (previous hour) + wantEnd: time.Date(2023, 12, 15, 14, 5, 0, 0, time.UTC), + }, + { + name: "boundary - DST spring forward (2 AM doesn't exist)", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeDaily, + Hour: intPtr(2), // 2 AM (skipped during DST) + Minute: intPtr(30), + }, + Timezone: "America/New_York", + }, + current: time.Date(2023, 3, 12, 15, 0, 0, 0, time.UTC), // Day DST starts + wantStart: time.Date(2023, 3, 12, 6, 30, 0, 0, time.UTC), // Same day 2:30 AM EDT (adjusted for DST) + wantEnd: time.Date(2023, 3, 12, 15, 0, 0, 0, time.UTC), + }, + { + name: "boundary - DST fall back (2 AM occurs twice)", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeDaily, + Hour: intPtr(2), // 2 AM (occurs twice) + Minute: intPtr(30), + }, + Timezone: "America/New_York", + }, + current: time.Date(2023, 11, 5, 15, 0, 0, 0, time.UTC), // Day DST ends + wantStart: time.Date(2023, 11, 5, 7, 30, 0, 0, time.UTC), // Same day 2:30 AM EST (after fall back) + wantEnd: time.Date(2023, 11, 5, 15, 0, 0, 0, time.UTC), + }, + { + name: "boundary - month transition January to February", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeMonthly, + Day: intPtr(31), + Hour: intPtr(0), + Minute: intPtr(0), + }, + Timezone: "UTC", + }, + current: time.Date(2023, 2, 15, 12, 0, 0, 0, time.UTC), // February 15th + wantStart: time.Date(2023, 1, 31, 0, 0, 0, 0, time.UTC), // January 31st (exists) + wantEnd: time.Date(2023, 2, 15, 12, 0, 0, 0, time.UTC), + }, + { + name: "boundary - extreme timezone offset (+14 hours)", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeDaily, + Hour: intPtr(12), + Minute: intPtr(0), + }, + Timezone: "Pacific/Kiritimati", // UTC+14 + }, + current: time.Date(2023, 12, 15, 5, 0, 0, 0, time.UTC), // 7 PM local time + wantStart: time.Date(2023, 12, 14, 22, 0, 0, 0, time.UTC), // 12 PM local time (previous day in UTC) + wantEnd: time.Date(2023, 12, 15, 5, 0, 0, 0, time.UTC), + }, + { + name: "boundary - extreme timezone offset (-12 hours)", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeDaily, + Hour: intPtr(12), + Minute: intPtr(0), + }, + Timezone: "Etc/GMT+12", // UTC-12 (use standard timezone name) + }, + current: time.Date(2023, 12, 15, 5, 0, 0, 0, time.UTC), // 5 PM previous day local time + wantStart: time.Date(2023, 12, 15, 0, 0, 0, 0, time.UTC), // 12 PM local time (same day in UTC) + wantEnd: time.Date(2023, 12, 15, 5, 0, 0, 0, time.UTC), + }, + { + name: "boundary - week boundary Saturday to Sunday", + window: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeWeekly, + Weekday: intPtr(6), // Saturday + Hour: intPtr(0), + Minute: intPtr(0), + }, + Timezone: "UTC", + }, + current: time.Date(2023, 12, 17, 12, 0, 0, 0, time.UTC), // Sunday noon + wantStart: time.Date(2023, 12, 16, 0, 0, 0, 0, time.UTC), // Saturday midnight + wantEnd: time.Date(2023, 12, 17, 12, 0, 0, 0, time.UTC), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotStart, gotEnd := tt.window.NextWindowFor(tt.current) + + if !gotStart.Equal(tt.wantStart) { + t.Errorf("NextWindowFor() start = %v, want %v", gotStart, tt.wantStart) + } + if !gotEnd.Equal(tt.wantEnd) { + t.Errorf("NextWindowFor() end = %v, want %v", gotEnd, tt.wantEnd) + } + + // Validate basic invariants + if gotStart.After(gotEnd) { + t.Errorf("Window start should not be after end: start=%v, end=%v", gotStart, gotEnd) + } + if gotEnd.After(tt.current) { + t.Errorf("Window end should not be after current time: end=%v, current=%v", gotEnd, tt.current) + } + + duration := gotEnd.Sub(gotStart) + + // Validate window length is reasonable + if duration < 0 { + t.Errorf("Window duration should not be negative: %v", duration) + } + if duration > 366*24*time.Hour { + t.Errorf("Window duration should not exceed 1 year: %v", duration) + } + }) + } +} + +func TestEvaluationEnvelope_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + jsonInput string + wantKind EvaluationKind + wantSpec interface{} + wantError bool + }{ + { + name: "rolling evaluation with valid data", + jsonInput: `{"kind":"rolling","spec":{"evalWindow":"5m","frequency":"1m"}}`, + wantKind: RollingEvaluation, + wantSpec: RollingWindow{ + EvalWindow: Duration(5 * time.Minute), + Frequency: Duration(1 * time.Minute), + }, + }, + { + name: "cumulative evaluation with valid data", + jsonInput: `{"kind":"cumulative","spec":{"schedule":{"type":"hourly","minute":30},"frequency":"2m","timezone":"UTC"}}`, + wantKind: CumulativeEvaluation, + wantSpec: CumulativeWindow{ + Schedule: CumulativeSchedule{ + Type: ScheduleTypeHourly, + Minute: intPtr(30), + }, + Frequency: Duration(2 * time.Minute), + Timezone: "UTC", + }, + }, + { + name: "rolling evaluation with validation error - zero evalWindow", + jsonInput: `{"kind":"rolling","spec":{"evalWindow":"0s","frequency":"1m"}}`, + wantError: true, + }, + { + name: "rolling evaluation with validation error - zero frequency", + jsonInput: `{"kind":"rolling","spec":{"evalWindow":"5m","frequency":"0s"}}`, + wantError: true, + }, + { + name: "cumulative evaluation with validation error - zero frequency", + jsonInput: `{"kind":"cumulative","spec":{"schedule":{"type":"hourly","minute":30},"frequency":"0s","timezone":"UTC"}}`, + wantError: true, + }, + { + name: "cumulative evaluation with validation error - invalid timezone", + jsonInput: `{"kind":"cumulative","spec":{"schedule":{"type":"daily","hour":9,"minute":30},"frequency":"1m","timezone":"Invalid/Timezone"}}`, + wantError: true, + }, + { + name: "cumulative evaluation with validation error - missing minute for hourly", + jsonInput: `{"kind":"cumulative","spec":{"schedule":{"type":"hourly"},"frequency":"1m","timezone":"UTC"}}`, + wantError: true, + }, + { + name: "unknown evaluation kind", + jsonInput: `{"kind":"unknown","spec":{"evalWindow":"5m","frequency":"1h"}}`, + wantError: true, + }, + { + name: "invalid JSON", + jsonInput: `{"kind":"rolling","spec":invalid}`, + wantError: true, + }, + { + name: "missing kind field", + jsonInput: `{"spec":{"evalWindow":"5m","frequency":"1m"}}`, + wantError: true, + }, + { + name: "missing spec field", + jsonInput: `{"kind":"rolling"}`, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var envelope EvaluationEnvelope + err := json.Unmarshal([]byte(tt.jsonInput), &envelope) + + if tt.wantError { + if err == nil { + t.Errorf("EvaluationEnvelope.UnmarshalJSON() expected error, got none") + } + return + } + + if err != nil { + t.Fatalf("EvaluationEnvelope.UnmarshalJSON() unexpected error = %v", err) + } + + if envelope.Kind != tt.wantKind { + t.Errorf("EvaluationEnvelope.Kind = %v, want %v", envelope.Kind, tt.wantKind) + } + + // Check spec content based on type + switch tt.wantKind { + case RollingEvaluation: + gotSpec, ok := envelope.Spec.(RollingWindow) + if !ok { + t.Fatalf("Expected RollingWindow spec, got %T", envelope.Spec) + } + wantSpec := tt.wantSpec.(RollingWindow) + if gotSpec.EvalWindow != wantSpec.EvalWindow { + t.Errorf("RollingWindow.EvalWindow = %v, want %v", gotSpec.EvalWindow, wantSpec.EvalWindow) + } + if gotSpec.Frequency != wantSpec.Frequency { + t.Errorf("RollingWindow.Frequency = %v, want %v", gotSpec.Frequency, wantSpec.Frequency) + } + case CumulativeEvaluation: + gotSpec, ok := envelope.Spec.(CumulativeWindow) + if !ok { + t.Fatalf("Expected CumulativeWindow spec, got %T", envelope.Spec) + } + wantSpec := tt.wantSpec.(CumulativeWindow) + if gotSpec.Schedule.Type != wantSpec.Schedule.Type { + t.Errorf("CumulativeWindow.Schedule.Type = %v, want %v", gotSpec.Schedule.Type, wantSpec.Schedule.Type) + } + if (gotSpec.Schedule.Minute == nil) != (wantSpec.Schedule.Minute == nil) || + (gotSpec.Schedule.Minute != nil && wantSpec.Schedule.Minute != nil && *gotSpec.Schedule.Minute != *wantSpec.Schedule.Minute) { + t.Errorf("CumulativeWindow.Schedule.Minute = %v, want %v", gotSpec.Schedule.Minute, wantSpec.Schedule.Minute) + } + if gotSpec.Frequency != wantSpec.Frequency { + t.Errorf("CumulativeWindow.Frequency = %v, want %v", gotSpec.Frequency, wantSpec.Frequency) + } + if gotSpec.Timezone != wantSpec.Timezone { + t.Errorf("CumulativeWindow.Timezone = %v, want %v", gotSpec.Timezone, wantSpec.Timezone) + } + } + }) + } +}