// Package alerting implementiert Core OPS-05: schwellwertbasierte // Alarmierung auf den aus OPS-03 aggregierten Metriken, Zustellung über den // Core-Benachrichtigungs-Dispatcher (CFG-02). Der Alertmanager-Gedanke von // Prometheus/Grafana, aber auf das Nötigste reduziert (Schwellwert, Ziel, // Drosselung) — keine eigene Ausdruckssprache. package alerting import ( "context" "encoding/json" "errors" "fmt" "github.com/jackc/pgx/v5/pgxpool" ) // Comparison legt fest, ob ein Schwellwert nach oben oder unten überwacht // wird — bewusst nur zwei Operatoren, keine eigene Ausdruckssprache // (Ticket-Produkt-DNA). type Comparison string const ( ComparisonGreaterThan Comparison = "gt" ComparisonLessThan Comparison = "lt" ) // Rule ist eine Schwellwert-Regel auf einer beliebigen aggregierten Metrik // (Akzeptanzkriterium 1). LabelFilters schränkt optional auf bestimmte // Label-Werte ein (z. B. tenant/module), leer = alle Zeitreihen der Metrik. type Rule struct { ID string MetricName string Comparison Comparison Threshold float64 LabelFilters map[string]string Recipient string Description string } // RuleStore verwaltet Alert-Regeln in der zentralen Registry-DB. type RuleStore struct { pool *pgxpool.Pool } func NewRuleStore(pool *pgxpool.Pool) *RuleStore { return &RuleStore{pool: pool} } // CreateRule legt eine neue Schwellwert-Regel an (Akzeptanzkriterium 1). func (s *RuleStore) CreateRule(ctx context.Context, r Rule) (Rule, error) { if r.MetricName == "" || r.Recipient == "" { return Rule{}, errors.New("alerting: metricName und recipient duerfen nicht leer sein") } if r.Comparison != ComparisonGreaterThan && r.Comparison != ComparisonLessThan { return Rule{}, fmt.Errorf("alerting: unbekannter comparison-operator %q", r.Comparison) } if r.LabelFilters == nil { r.LabelFilters = map[string]string{} } filtersJSON, err := json.Marshal(r.LabelFilters) if err != nil { return Rule{}, fmt.Errorf("label-filter serialisieren: %w", err) } err = s.pool.QueryRow(ctx, ` INSERT INTO alert_rules (metric_name, comparison, threshold, label_filters, recipient, description) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id `, r.MetricName, string(r.Comparison), r.Threshold, filtersJSON, r.Recipient, r.Description).Scan(&r.ID) if err != nil { return Rule{}, fmt.Errorf("regel speichern: %w", err) } return r, nil } // ListRules liefert alle konfigurierten Regeln — Grundlage für Evaluate. func (s *RuleStore) ListRules(ctx context.Context) ([]Rule, error) { rows, err := s.pool.Query(ctx, ` SELECT id, metric_name, comparison, threshold, label_filters, recipient, description FROM alert_rules ORDER BY created_at `) if err != nil { return nil, fmt.Errorf("regeln auflisten: %w", err) } defer rows.Close() var out []Rule for rows.Next() { var r Rule var comparison string var filtersJSON []byte if err := rows.Scan(&r.ID, &r.MetricName, &comparison, &r.Threshold, &filtersJSON, &r.Recipient, &r.Description); err != nil { return nil, fmt.Errorf("regel lesen: %w", err) } r.Comparison = Comparison(comparison) if err := json.Unmarshal(filtersJSON, &r.LabelFilters); err != nil { return nil, fmt.Errorf("label-filter lesen: %w", err) } out = append(out, r) } return out, rows.Err() } // DeleteRule entfernt eine Regel. func (s *RuleStore) DeleteRule(ctx context.Context, id string) error { _, err := s.pool.Exec(ctx, `DELETE FROM alert_rules WHERE id = $1`, id) if err != nil { return fmt.Errorf("regel loeschen: %w", err) } return nil } // debounceStore kapselt die Drosselungs-Zustandstabelle (Akzeptanzkriterium 3). type debounceStore struct { pool *pgxpool.Pool } // shouldFire prueft, ob seit dem letzten Alarm fuer ruleKey mindestens // interval vergangen ist — atomar ueber eine bedingte UPDATE/INSERT- // Sequenz, damit zwei gleichzeitige Evaluate-Laeufe (z. B. bei mehreren // Core-Instanzen) nicht beide gleichzeitig alarmieren. func (d *debounceStore) shouldFire(ctx context.Context, ruleKey string, intervalSeconds float64) (bool, error) { tag, err := d.pool.Exec(ctx, ` INSERT INTO alert_debounce_state (rule_key, last_fired_at) VALUES ($1, now()) ON CONFLICT (rule_key) DO UPDATE SET last_fired_at = now() WHERE alert_debounce_state.last_fired_at <= now() - ($2 || ' seconds')::interval `, ruleKey, intervalSeconds) if err != nil { return false, fmt.Errorf("drosselungszustand pruefen: %w", err) } return tag.RowsAffected() == 1, nil }