Saved, shareable multi-panel dashboards (table/line/bar/single-stat panels via gridstack + uPlot, global + per-panel time range, JSON export/import) and threshold/absence alert rules with an ok/pending/firing evaluator and webhook/Slack/PagerDuty delivery. - New /metadata component: Postgres control-plane store for dashboards, panels, notification targets, alert rules/state, and delivery log -- see docs/phase-3-dashboard-design.md for why ClickHouse's MergeTree family isn't a fit for this access pattern (needs real row-level locking and read-your-writes consistency). - api/internal/dashboards: dashboard/panel CRUD, pure -- panel query execution stays client-side, reusing the existing /query endpoint. - New /alerting service: rule/target CRUD, a ticker-driven evaluator (claim-then-evaluate concurrency control, transactional-outbox delivery, query errors and threshold zero-rows never coerced into a false transition) and webhook/Slack/PagerDuty delivery with retry/backoff. See docs/phase-3-alerting-design.md for the full state-machine design and the four correctness properties it implements. - web: /dashboards and /alerts UIs; cli: sentryctl dashboards/alerts list/get/apply, seeding a future Terraform provider's JSON contract. - hack/alert-load-test: 500 rules against real ClickHouse data, real measured results in docs/phase-3-runbook.md. Five real bugs found by actually running this against a live stack (documented in the runbook, not just fixed silently): a latent Phase 2 bug where ClickHouse rejected the timestamp format used for earliest=/latest= queries; a "now" literal token injected into query text; a GridStack/uPlot layout-timing race; JS's Date.parse being too lenient to use as a timestamp-detection heuristic; a rule's "enabled" field silently defaulting to false when omitted; and the evaluator's claim-batch-size and worker-pool-concurrency defaulting to the same value, causing 500 concurrently-due rules to take 125s to cycle through instead of the configured 60s.
171 lines
5.6 KiB
Go
171 lines
5.6 KiB
Go
package evaluator
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/sentry/sentry/alerting/internal/delivery"
|
|
"github.com/sentry/sentry/alerting/internal/notifystore"
|
|
"github.com/sentry/sentry/alerting/internal/queryclient"
|
|
"github.com/sentry/sentry/alerting/internal/rulestore"
|
|
)
|
|
|
|
// Evaluator is the ticker-driven scheduler -- a bounded worker pool, not
|
|
// a workflow engine, per the design doc's explicit instruction. Each
|
|
// tick claims up to claimBatchSize due rules (rulestore.ClaimDueRules,
|
|
// fix 1's atomic claim) and evaluates them concurrently up to
|
|
// workerPoolSize at a time. These are deliberately different numbers --
|
|
// see config.EvaluatorConfig's doc comment for the real bug this fixes
|
|
// (500 rules due at once, both capped at 20, took 125s to cycle through
|
|
// instead of the configured 60s).
|
|
type Evaluator struct {
|
|
rules *rulestore.Store
|
|
notifications *notifystore.Store
|
|
queryClient *queryclient.Client
|
|
queryTimeout time.Duration
|
|
claimBatchSize int
|
|
workerPoolSize int
|
|
logger *slog.Logger
|
|
}
|
|
|
|
func New(rules *rulestore.Store, notifications *notifystore.Store, queryClient *queryclient.Client, queryTimeout time.Duration, claimBatchSize, workerPoolSize int, logger *slog.Logger) *Evaluator {
|
|
return &Evaluator{
|
|
rules: rules, notifications: notifications, queryClient: queryClient,
|
|
queryTimeout: queryTimeout, claimBatchSize: claimBatchSize, workerPoolSize: workerPoolSize, logger: logger,
|
|
}
|
|
}
|
|
|
|
// Run ticks every tickInterval until ctx is cancelled.
|
|
func (e *Evaluator) Run(ctx context.Context, tickInterval time.Duration) error {
|
|
ticker := time.NewTicker(tickInterval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-ticker.C:
|
|
e.tick(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (e *Evaluator) tick(ctx context.Context) {
|
|
claimed, err := e.rules.ClaimDueRules(ctx, e.claimBatchSize)
|
|
if err != nil {
|
|
e.logger.Error("claiming due rules", "error", err)
|
|
return
|
|
}
|
|
if len(claimed) == 0 {
|
|
return
|
|
}
|
|
|
|
sem := make(chan struct{}, e.workerPoolSize)
|
|
var wg sync.WaitGroup
|
|
for _, rule := range claimed {
|
|
wg.Add(1)
|
|
sem <- struct{}{}
|
|
go func(rule rulestore.RuleWithState) {
|
|
defer wg.Done()
|
|
defer func() { <-sem }()
|
|
e.evaluateOne(ctx, rule)
|
|
}(rule)
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
func (e *Evaluator) evaluateOne(ctx context.Context, rule rulestore.RuleWithState) {
|
|
result, err := e.queryClient.Query(ctx, rule.Query, rule.QueryLanguage, e.queryTimeout)
|
|
if err != nil {
|
|
// A failed /query call is an evaluation error, never
|
|
// "condition false" -- fix 3. Recording it here, not routing it
|
|
// through ComputeTransition at all, is what makes that guarantee
|
|
// hold structurally rather than by convention.
|
|
e.recordError(ctx, rule.ID, err.Error())
|
|
return
|
|
}
|
|
|
|
conditionTrue, value, evalErr := evaluateCondition(rule.Rule, result)
|
|
if evalErr != nil {
|
|
e.recordError(ctx, rule.ID, evalErr.Error())
|
|
return
|
|
}
|
|
|
|
var renotify *time.Duration
|
|
if rule.RenotifyIntervalMinutes != nil {
|
|
d := time.Duration(*rule.RenotifyIntervalMinutes) * time.Minute
|
|
renotify = &d
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
transition := ComputeTransition(TransitionInput{
|
|
CurrentState: rule.State.State,
|
|
ConditionTrueSince: rule.State.ConditionTrueSince,
|
|
FiredAt: rule.State.FiredAt,
|
|
LastNotifiedAt: rule.State.LastNotifiedAt,
|
|
ForMinutes: time.Duration(rule.ForMinutes) * time.Minute,
|
|
RenotifyInterval: renotify,
|
|
Now: now,
|
|
ConditionTrue: conditionTrue,
|
|
})
|
|
|
|
next := rulestore.AlertState{
|
|
State: transition.NextState,
|
|
ConditionTrueSince: transition.NextConditionTrueSince,
|
|
FiredAt: transition.NextFiredAt,
|
|
LastNotifiedAt: transition.NextLastNotifiedAt,
|
|
LastValue: value,
|
|
}
|
|
|
|
notify := e.buildNotifyEvent(ctx, rule, transition, value, now)
|
|
|
|
if err := e.rules.ApplyTransition(ctx, rule.ID, next, notify); err != nil {
|
|
e.logger.Error("applying alert state transition", "rule_id", rule.ID, "error", err)
|
|
}
|
|
}
|
|
|
|
// buildNotifyEvent resolves the notification target and renders the
|
|
// payload for a firing/resolved transition. A lookup or template
|
|
// failure here is logged and treated as "no notification this time" --
|
|
// the state still transitions correctly (that's the more important
|
|
// guarantee), it just means a misconfigured target/template silently
|
|
// drops one notification rather than blocking the whole evaluation.
|
|
func (e *Evaluator) buildNotifyEvent(ctx context.Context, rule rulestore.RuleWithState, transition TransitionResult, value *float64, now time.Time) *rulestore.NotifyEvent {
|
|
if transition.Notify == nil {
|
|
return nil
|
|
}
|
|
|
|
target, err := e.notifications.Get(ctx, rule.NotificationTargetID)
|
|
if err != nil {
|
|
e.logger.Error("looking up notification target", "rule_id", rule.ID, "target_id", rule.NotificationTargetID, "error", err)
|
|
return nil
|
|
}
|
|
|
|
comparator := ""
|
|
if rule.Comparator != nil {
|
|
comparator = string(*rule.Comparator)
|
|
}
|
|
payload, err := delivery.BuildPayload(*target, delivery.Event{
|
|
RuleID: rule.ID, RuleName: rule.Name, EventType: *transition.Notify,
|
|
ConditionType: string(rule.ConditionType), Comparator: comparator,
|
|
ThresholdValue: rule.ThresholdValue, Value: value, Timestamp: now,
|
|
})
|
|
if err != nil {
|
|
e.logger.Error("building notification payload", "rule_id", rule.ID, "error", err)
|
|
return nil
|
|
}
|
|
|
|
return &rulestore.NotifyEvent{
|
|
NotificationTargetID: rule.NotificationTargetID,
|
|
EventType: *transition.Notify,
|
|
Payload: payload,
|
|
}
|
|
}
|
|
|
|
func (e *Evaluator) recordError(ctx context.Context, ruleID, msg string) {
|
|
if err := e.rules.RecordError(ctx, ruleID, msg); err != nil {
|
|
e.logger.Error("recording evaluation error", "rule_id", ruleID, "error", err)
|
|
}
|
|
}
|