Три вади, знайдені рецензією, яких щасливий шлях показати не міг: * outcome='sent' писався до доставки; помилка читання каналів клала в кеш порожню мапу й з'їдала всі сходинки кабінету за тік — усі зі слідом «надіслано». Канали тепер читаються до просування стану, журнал пишеться після доставки, з правдою. * UPDATE не мав stopped_at IS NULL — підтвердження алерту посеред партії не рятувало людину від дзвінка. * час брався раз на партію. Плюс суміжне: UpdateRule не гасив алертів вимкненого правила, сервер домислював enabled на оновленні, channel_ids сходинок не звірялись із каналами кабінету (зокрема чужого). І scripts/dbtest.sh — тести проти бази перестали мовчки пропускатись. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
201 lines
9.6 KiB
Go
201 lines
9.6 KiB
Go
package alerting
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"time"
|
||
|
||
"github.com/netpulse/netpulse/server/internal/store"
|
||
)
|
||
|
||
// Ескалація: «не підтвердили за 15 хвилин — буди наступного».
|
||
//
|
||
// Розділення обов'язків тут таке саме, як у решті движка, і воно
|
||
// принципове:
|
||
//
|
||
// - store.PlanEscalation ухвалює РІШЕННЯ і не знає ні про канали, ні
|
||
// про мережу. Функція чиста, тому «сходинка НЕ спрацювала, бо алерт
|
||
// підтвердили / закрили / хост заглушено» перевіряється тестом без
|
||
// бази — а це рівно та половина поведінки, яку легко залишити
|
||
// неперевіреною, бо вона нічого не робить.
|
||
//
|
||
// - цей файл ВИКОНУЄ рішення: бере канали сходинки й шле в них той
|
||
// самий алерт з іншою шапкою.
|
||
//
|
||
// Стан драбини живе в базі (alr.alert_escalations), а не в пам'яті. Це
|
||
// не педантизм: викочування нової версії о другій ночі перезапускає
|
||
// процес, і драбина в пам'яті померла б рівно посеред тієї аварії,
|
||
// заради якої вона й існує.
|
||
|
||
// escalationBatch — скільки сходинок обробляти за тік.
|
||
//
|
||
// Сотні достатньо з великим запасом: сходинка — це подія раз на
|
||
// чверть години на алерт, а не потік. Обмеження стоїть, щоб один
|
||
// патологічний кабінет не з'їв увесь тік.
|
||
const escalationBatch = 100
|
||
|
||
// escalate проганяє сходинки, час яких настав.
|
||
//
|
||
// Викликається з тіку движка, тобто під тим самим advisory-блокуванням,
|
||
// що й решта. Оренда на рядку (EscalationLease) — другий рубіж на
|
||
// випадок, коли блокування з якоїсь причини взяли двоє.
|
||
func (e *Engine) escalate(ctx context.Context) {
|
||
due, err := e.st.TakeDueEscalations(ctx, escalationBatch)
|
||
if err != nil {
|
||
e.log.Error("черга ескалацій", "помилка", err)
|
||
return
|
||
}
|
||
if len(due) == 0 {
|
||
return
|
||
}
|
||
|
||
// Канали читаються раз на кабінет, а не раз на сходинку: розшифровка
|
||
// секретів каналу коштує дорого, а сходинок одного кабінету в партії
|
||
// може бути десяток.
|
||
channels := map[string]map[string]store.Channel{}
|
||
|
||
for _, snap := range due {
|
||
// Час береться на кожну сходинку, а не на партію: між першою і
|
||
// останньою може пройти скільки завгодно — кожна доставка має
|
||
// власний таймаут. Застарілий момент зсував би стелю життя й
|
||
// підлогу інтервалу рівно на цю затримку.
|
||
d := store.PlanEscalation(snap, time.Now())
|
||
|
||
// Канали читаються ДО просування стану. Порядок не косметичний:
|
||
// якщо їх не вдалось прочитати, сходинка має лишитись належною й
|
||
// повторитись наступного тіку. У зворотному порядку тимчасова
|
||
// помилка бази списувала б сходинку назавжди — і в журналі
|
||
// стояло б «надіслано».
|
||
var byID map[string]store.Channel
|
||
if d.Action == store.EscFire {
|
||
var err error
|
||
byID, err = e.escalationChannels(ctx, snap.TenantID, channels)
|
||
if err != nil {
|
||
e.log.Error("читання каналів для ескалації — сходинку відкладено",
|
||
"tenant", snap.TenantID, "алерт", snap.AlertID, "помилка", err)
|
||
continue
|
||
}
|
||
}
|
||
|
||
applied, err := e.st.ApplyEscalation(ctx, snap, d)
|
||
if err != nil {
|
||
e.log.Error("запис рішення ескалації", "алерт", snap.AlertID, "помилка", err)
|
||
continue
|
||
}
|
||
if !applied {
|
||
// Драбину зупинили, поки сходинка чекала своєї черги —
|
||
// найчастіше людина натиснула «Прийняти». Це не помилка, це
|
||
// той випадок, заради якого кнопка й існує.
|
||
e.log.Info("сходинку скасовано: драбину вже зупинено",
|
||
"алерт", snap.AlertID, "сходинка", d.StepIdx+1)
|
||
continue
|
||
}
|
||
|
||
if d.Action != store.EscFire {
|
||
e.logStep(ctx, snap, d, d.Outcome, d.Detail)
|
||
e.log.Debug("ескалацію не продовжено", "алерт", snap.AlertID,
|
||
"причина", d.Outcome, "деталі", d.Detail)
|
||
continue
|
||
}
|
||
|
||
if sent := e.notifier.deliverEscalation(ctx, snap, d, byID); sent == 0 {
|
||
// Сходинка списана — інакше вона поверталася б щотіку. Але в
|
||
// журнал іде правда, а не намір: «надіслано» на сходинці, яка
|
||
// нікуди не пішла, — саме та мовчазна відмова, від якої
|
||
// ескалація рятує.
|
||
e.logStep(ctx, snap, d, "no_channels",
|
||
"жоден канал сходинки не прийняв повідомлення (видалено, вимкнено або поріг серйозності)")
|
||
continue
|
||
}
|
||
e.logStep(ctx, snap, d, d.Outcome, d.Detail)
|
||
}
|
||
}
|
||
|
||
// escalationChannels читає канали кабінету, кешуючи лише успіх.
|
||
//
|
||
// Кешувати помилку не можна: порожня мапа в кеші означала б, що одна
|
||
// тимчасова невдача з'їдає всі належні сходинки кабінету за цей тік, і
|
||
// кожна з них виглядала б доставленою.
|
||
func (e *Engine) escalationChannels(ctx context.Context, tenantID string,
|
||
cache map[string]map[string]store.Channel) (map[string]store.Channel, error) {
|
||
|
||
if byID, ok := cache[tenantID]; ok {
|
||
return byID, nil
|
||
}
|
||
cs, err := e.st.LoadChannels(ctx, tenantID, e.ring)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
byID := make(map[string]store.Channel, len(cs))
|
||
for _, c := range cs {
|
||
byID[c.ID] = c
|
||
}
|
||
cache[tenantID] = byID
|
||
return byID, nil
|
||
}
|
||
|
||
// logStep пише рядок журналу й не дає невдалому запису зупинити чергу.
|
||
func (e *Engine) logStep(ctx context.Context, snap store.EscalationSnapshot,
|
||
d store.EscalationDecision, outcome, detail string) {
|
||
|
||
if err := e.st.LogEscalationStep(ctx, snap, d, outcome, detail); err != nil {
|
||
e.log.Error("журнал ескалації", "алерт", snap.AlertID, "помилка", err)
|
||
}
|
||
}
|
||
|
||
// deliverEscalation шле сходинку в її канали й повертає, скільком дійшло.
|
||
//
|
||
// Кількість потрібна тому, хто пише журнал: сходинка без жодного каналу
|
||
// має лишити слід «нікуди не пішло», а не «надіслано».
|
||
func (n *Notifier) deliverEscalation(ctx context.Context, snap store.EscalationSnapshot,
|
||
d store.EscalationDecision, byID map[string]store.Channel) int {
|
||
|
||
a := snap.Alert
|
||
head := escalationHeader(snap, d)
|
||
|
||
sent := 0
|
||
for _, id := range d.ChannelIDs {
|
||
c, ok := byID[id]
|
||
if !ok {
|
||
continue
|
||
}
|
||
// Вимкнений канал і поріг серйозності діють на сходинку так
|
||
// само, як на звичайне сповіщення: обидва — рішення про канал, а
|
||
// не про драбину. Інакше «вимкнув Telegram на час переїзду»
|
||
// означало б «вимкнув усе, крім ескалації», тобто рівно те, чого
|
||
// людина не просила.
|
||
if !c.Enabled || severityRank[a.Severity] < severityRank[c.MinSeverity] {
|
||
continue
|
||
}
|
||
n.sendText(ctx, snap.TenantID, a, c, head+renderMessage(a, c))
|
||
sent++
|
||
}
|
||
|
||
if sent == 0 {
|
||
n.log.Warn("сходинка ескалації не мала куди піти",
|
||
"алерт", snap.AlertID, "сходинка", d.StepIdx+1,
|
||
"каналів у сходинці", len(d.ChannelIDs))
|
||
}
|
||
return sent
|
||
}
|
||
|
||
// escalationHeader пояснює людині, чому вона це читає.
|
||
//
|
||
// Без цього рядка третє повідомлення про ту саму аварію виглядає як
|
||
// збій розсилки, а не як ескалація, — і перше, що зробить розбуджений
|
||
// керівник, це вимкне канал.
|
||
func escalationHeader(snap store.EscalationSnapshot, d store.EscalationDecision) string {
|
||
total := len(snap.Steps)
|
||
mins := int(time.Since(snap.Alert.StartedAt).Round(time.Minute).Minutes())
|
||
if mins < 0 {
|
||
mins = 0
|
||
}
|
||
|
||
head := fmt.Sprintf("🔺 ЕСКАЛАЦІЯ %d/%d (%s): не підтверджено %s\n",
|
||
d.StepIdx+1, total, snap.PolicyName, humanDur(mins*60))
|
||
if d.RepeatIdx > 0 {
|
||
head = fmt.Sprintf("🔺 ЕСКАЛАЦІЯ %d/%d, повтор %d (%s): не підтверджено %s\n",
|
||
d.StepIdx+1, total, d.RepeatIdx, snap.PolicyName, humanDur(mins*60))
|
||
}
|
||
return head
|
||
}
|