Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 159 additions & 0 deletions internal/rules/rules.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// Package rules 는 알림 룰을 주기적으로 평가하고 그 상태를 관리한다.
//
// 상태가 이 패키지의 존재 이유다. 조건이 참인지는 promql 이 답하지만, "언제부터
// 참이었나"·"이미 알린 것인가"는 평가 사이에 기억해야 알 수 있다.
package rules

import (
"fmt"
"sort"
"strings"
"time"

"github.com/KeiaiLab/nodevitals-observatory/internal/promql"
)

// State 는 알림 하나의 생애다. Prometheus 와 같은 3단계를 쓴다.
type State int

const (
StateInactive State = iota
StatePending // 조건은 참이지만 for 가 아직 안 찼다
StateFiring
)

func (s State) String() string {
switch s {
case StatePending:
return "pending"
case StateFiring:
return "firing"
default:
return "inactive"
}
}

// Alert 는 활성 상태인 알림 하나다.
type Alert struct {
Labels map[string]string
Annotations map[string]string
State State
// ActiveAtMS 는 조건이 *연속으로* 참이기 시작한 시각이다. 조건이 한 번
// 풀리면 다시 찍힌다 — 그래야 짧은 깜빡임이 쌓여 firing 이 되지 않는다.
ActiveAtMS int64
Value float64
}

// Rule 은 알림 룰 하나와 그 활성 상태다.
type Rule struct {
Name string
Expr promql.Node
exprText string
For time.Duration
Labels map[string]string
Annotations map[string]string

// active 는 라벨 지문 → 알림. 평가 사이에 유지되어야 for 와 중복 발송
// 판단이 성립한다.
active map[string]*Alert
}

// NewRule 은 식을 파싱해 룰을 만든다. 파싱 실패는 여기서 걸러진다 — 평가할
// 때마다 실패하는 룰을 안고 도는 것보다 만들 때 거절하는 편이 낫다.
func NewRule(name, expr string, forDur time.Duration, lbls, anns map[string]string) (*Rule, error) {
node, err := promql.Parse(expr)
if err != nil {
return nil, fmt.Errorf("rules: 룰 %q 의 식을 읽을 수 없다: %w", name, err)
}
if lbls == nil {
lbls = map[string]string{}
}
if anns == nil {
anns = map[string]string{}
}
return &Rule{
Name: name, Expr: node, exprText: expr, For: forDur,
Labels: lbls, Annotations: anns,
active: map[string]*Alert{},
}, nil
}

// Expression 은 룰의 원문 식이다(API 노출용).
func (r *Rule) Expression() string { return r.exprText }

// Active 는 현재 pending 또는 firing 인 알림들이다.
func (r *Rule) Active() []*Alert {
out := make([]*Alert, 0, len(r.active))
for _, a := range r.active {
out = append(out, a)
}
sort.Slice(out, func(i, j int) bool { return fingerprint(out[i].Labels) < fingerprint(out[j].Labels) })
return out
}

// Eval 은 evalMS 시점에 룰을 평가하고 활성 알림 목록을 낸다. 조건이 풀린
// 알림은 목록에서도 내부 상태에서도 사라진다 — 남겨 두면 해소된 알림이
// 계속 울린다.
func (r *Rule) Eval(q promql.Queryable, evalMS int64) []*Alert {
res, err := promql.NewEngine().Eval(q, r.Expr, evalMS)
if err != nil {
// 평가 실패는 "조건이 거짓" 과 다르다. 기존 상태를 건드리지 않고
// 그대로 두어, 일시적 실패가 알림을 해소시키지 않게 한다.
return r.Active()
}
vec, ok := res.(promql.Vector)
if !ok {
return r.Active()
}

seen := map[string]bool{}
for _, s := range vec {
lbls := map[string]string{"alertname": r.Name}
for _, l := range s.Labels {
lbls[l.Name] = l.Value
}
// 룰 라벨이 마지막에 온다 — severity 같은 값은 룰이 정한다.
for k, v := range r.Labels {
lbls[k] = v
}

fp := fingerprint(lbls)
seen[fp] = true

a, exists := r.active[fp]
if !exists {
a = &Alert{Labels: lbls, Annotations: r.Annotations, ActiveAtMS: evalMS, State: StatePending}
r.active[fp] = a
}
a.Value = s.V
if evalMS-a.ActiveAtMS >= r.For.Milliseconds() {
a.State = StateFiring
}
}

// 이번 평가에서 조건을 만족하지 않은 알림은 지운다.
for fp := range r.active {
if !seen[fp] {
delete(r.active, fp)
}
}
return r.Active()
}

// fingerprint 는 라벨셋의 안정적인 키다. 정렬해서 이어 붙이므로 map 순회
// 순서에 흔들리지 않는다.
func fingerprint(lbls map[string]string) string {
keys := make([]string, 0, len(lbls))
for k := range lbls {
keys = append(keys, k)
}
sort.Strings(keys)
var sb strings.Builder
for _, k := range keys {
sb.WriteString(k)
sb.WriteByte(0)
sb.WriteString(lbls[k])
sb.WriteByte(1)
}
return sb.String()
}
155 changes: 155 additions & 0 deletions internal/rules/rules_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package rules

import (
"testing"
"time"

"github.com/KeiaiLab/nodevitals-observatory/internal/labels"
"github.com/KeiaiLab/nodevitals-observatory/internal/promql"
"github.com/KeiaiLab/nodevitals-observatory/internal/storage"
"github.com/KeiaiLab/nodevitals-observatory/internal/tsdb"
)

func newQ(t *testing.T, samples ...sample) promql.Queryable {
t.Helper()
db, err := tsdb.Open(tsdb.DefaultOptions(t.TempDir()))
if err != nil {
t.Fatalf("tsdb.Open: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
for _, s := range samples {
m := map[string]string{labels.MetricName: s.name}
for k, v := range s.kv {
m[k] = v
}
if err := db.Append(labels.LabelsFromMap(m), s.t, s.v); err != nil {
t.Fatalf("Append: %v", err)
}
}
return storage.New(db)
}

type sample struct {
name string
kv map[string]string
t int64
v float64
}

func mustRule(t *testing.T, alert, expr string, forDur time.Duration) *Rule {
t.Helper()
r, err := NewRule(alert, expr, forDur, map[string]string{"severity": "critical"}, nil)
if err != nil {
t.Fatalf("NewRule: %v", err)
}
return r
}

// for 가 0 이면 조건을 만족한 즉시 firing 이다.
func TestRuleFiresImmediatelyWhenForIsZero(t *testing.T) {
q := newQ(t, sample{"x", map[string]string{"node": "a"}, 1000, 5})
r := mustRule(t, "HighX", `x > 3`, 0)

alerts := r.Eval(q, 1000)
if len(alerts) != 1 {
t.Fatalf("알림 %d 개, want 1: %+v", len(alerts), alerts)
}
if alerts[0].State != StateFiring {
t.Errorf("state=%v, want firing", alerts[0].State)
}
}

// for 가 있으면 조건을 만족해도 그 시간이 지나기 전에는 pending 이다. 이
// 경계를 잘못 잡으면 순간적인 스파이크가 전부 알림이 된다.
func TestRulePendingUntilForElapses(t *testing.T) {
q := newQ(t, sample{"x", nil, 1000, 5})
r := mustRule(t, "HighX", `x > 3`, 2*time.Minute)

a1 := r.Eval(q, 1000)
if len(a1) != 1 || a1[0].State != StatePending {
t.Fatalf("첫 평가 %+v, want pending 하나", a1)
}

// 1분 뒤 — 아직 for 미충족
a2 := r.Eval(q, 1000+60_000)
if len(a2) != 1 || a2[0].State != StatePending {
t.Fatalf("1분 뒤 %+v, want 여전히 pending", a2)
}

// 2분 뒤 — 충족
a3 := r.Eval(q, 1000+120_000)
if len(a3) != 1 || a3[0].State != StateFiring {
t.Fatalf("2분 뒤 %+v, want firing", a3)
}
}

// 조건이 풀리면 상태가 사라져야 한다. 남아 있으면 해소된 알림이 계속 울린다.
func TestRuleClearsWhenConditionNoLongerHolds(t *testing.T) {
q := newQ(t, sample{"x", nil, 1000, 5})
r := mustRule(t, "HighX", `x > 3`, 0)
if got := r.Eval(q, 1000); len(got) != 1 {
t.Fatalf("첫 평가 %+v, want 1", got)
}

// 값이 임계 아래로 내려간 새 저장소
q2 := newQ(t, sample{"x", nil, 2000, 1})
got := r.Eval(q2, 2000)
if len(got) != 0 {
t.Errorf("해소 후 %+v, want 빈 목록", got)
}
if n := len(r.Active()); n != 0 {
t.Errorf("활성 알림 %d 개, want 0", n)
}
}

// pending 중에 조건이 풀렸다가 다시 만족하면 for 가 처음부터 다시 세야 한다
// — 이어서 세면 짧은 깜빡임이 쌓여 firing 이 된다.
func TestRuleRestartsForAfterConditionBreaks(t *testing.T) {
up := newQ(t, sample{"x", nil, 0, 5})
down := newQ(t, sample{"x", nil, 0, 1})
r := mustRule(t, "HighX", `x > 3`, 2*time.Minute)

r.Eval(up, 0) // pending 시작
r.Eval(down, 60_000) // 1분 뒤 해소
got := r.Eval(up, 120_000) // 2분 뒤 다시 만족 — 여기서 firing 이면 안 된다
if len(got) != 1 || got[0].State != StatePending {
t.Errorf("재시작 후 %+v, want pending (for 를 처음부터 다시 센다)", got)
}
}

// 라벨셋이 다르면 각각 독립된 알림이다.
func TestRuleTracksAlertsPerLabelSet(t *testing.T) {
q := newQ(t,
sample{"x", map[string]string{"node": "a"}, 1000, 5},
sample{"x", map[string]string{"node": "b"}, 1000, 9},
)
r := mustRule(t, "HighX", `x > 3`, 0)
got := r.Eval(q, 1000)
if len(got) != 2 {
t.Fatalf("알림 %d 개, want 2 (라벨셋마다 하나)", len(got))
}
}

// 룰 라벨은 결과 라벨을 덮어쓴다 — severity 같은 것을 룰이 정하기 때문이다.
func TestRuleLabelsAreAttached(t *testing.T) {
q := newQ(t, sample{"x", map[string]string{"node": "a"}, 1000, 5})
r := mustRule(t, "HighX", `x > 3`, 0)
got := r.Eval(q, 1000)
if got[0].Labels["severity"] != "critical" {
t.Errorf("severity 라벨이 없다: %+v", got[0].Labels)
}
if got[0].Labels["node"] != "a" {
t.Errorf("결과 라벨이 유지되지 않았다: %+v", got[0].Labels)
}
if got[0].Labels["alertname"] != "HighX" {
t.Errorf("alertname 이 붙지 않았다: %+v", got[0].Labels)
}
}

// 식이 틀리면 룰 생성 시점에 거절한다 — 평가 때마다 실패하는 룰을 안고
// 도는 것보다 낫다.
func TestNewRuleRejectsBadExpression(t *testing.T) {
if _, err := NewRule("Bad", `topk(3, x)`, 0, nil, nil); err == nil {
t.Error("미지원 문법이 룰로 만들어졌다")
}
}
Loading