133 lines
4.4 KiB
Go
133 lines
4.4 KiB
Go
// 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 * interval '1 second')
|
|
`, ruleKey, intervalSeconds)
|
|
if err != nil {
|
|
return false, fmt.Errorf("drosselungszustand pruefen: %w", err)
|
|
}
|
|
return tag.RowsAffected() == 1, nil
|
|
}
|