Files
cairnobs/alerting/internal/evaluator/evaluator.go
T
jcoffey-dev 9435115ab7 Phase 3: dashboards and alerting
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.
2026-08-13 17:29:38 -07:00

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)
}
}