За другою рецензією:
* scripts/dbtest.sh писав у шапці «не напрямляйте на робочу базу» й
нічого для цього не робив — перевірено, пішов котити міграції на базу
з бойовим іменем. Тепер вимагає probe/test в імені.
* sendText ковтав помилку, тож журнал ескалацій писав «надіслано» на
сходинці, жодне повідомлення якої не дійшло. Три результати замість
двох: no_channels, failed, sent.
* stopped_at IS NULL рятував лише від ack; гасіння правилом і
ResolveMissing рядка драбини не чіпають, і сходинка дзвонила за
погашеним алертом. Додано перевірку стану алерту в тому ж UPDATE.
* escalate() блокував весь тік движка — мертвий вебхук одного кабінету
зупиняв обчислення правил усім. Винесено в RunEscalations.
* алерт, народжений під заглушенням, не сповіщався ніколи: ні при
народженні, ні коли вікно скінчилось. Тепер перехід suppressed→firing
сповіщається, а драбина рахує час від першого сповіщення.
* alr.rules.channel_ids приймав чужі канали, глушачи і сповіщення, і
драбину. Перевірка як для сходинок; DeleteChannel чистить посилання.
І перше, що зловив прогін проти справжньої бази: nil-зріз каналів їде
явним NULL повз DEFAULT '{}' — правило без каналів давало 500.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
222 lines
11 KiB
Go
222 lines
11 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 проганяє сходинки, час яких настав.
|
||
//
|
||
// Викликається зі СВОГО такту (RunEscalations), а не з тіку движка:
|
||
// доставка синхронна й повільна, і поки вона жила в тіку, мертвий канал
|
||
// одного кабінету затримував обчислення правил усім.
|
||
//
|
||
// Advisory-блокування тут немає й не треба: TakeDueEscalations розбирає
|
||
// чергу через FOR UPDATE SKIP LOCKED, а оренда на рядку
|
||
// (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
|
||
}
|
||
|
||
// Сходинка вже списана — інакше вона поверталася б щотіку. Але в
|
||
// журнал іде правда, а не намір: «надіслано» на сходинці, яка
|
||
// нікуди не пішла, — саме та мовчазна відмова, від якої ескалація
|
||
// рятує. Тому три різні результати, а не два:
|
||
// no_channels — не було кому слати (канал видалили, вимкнули,
|
||
// підняли поріг серйозності);
|
||
// failed — слали, і жодне не дійшло (транспорт лежить);
|
||
// sent — дійшло принаймні одне.
|
||
sent, eligible := e.notifier.deliverEscalation(ctx, snap, d, byID)
|
||
switch {
|
||
case eligible == 0:
|
||
e.logStep(ctx, snap, d, "no_channels",
|
||
"жоден канал сходинки не придатний: видалено, вимкнено або поріг серйозності вищий")
|
||
case sent == 0:
|
||
e.logStep(ctx, snap, d, "failed",
|
||
"жодне повідомлення сходинки не дійшло — подробиці в журналі доставки")
|
||
default:
|
||
// d.Outcome на останній сходинці — це 'done', і воно правильне
|
||
// для причини зупинки драбини, але не для рядка про доставку:
|
||
// підрахунок «скільки разів реально слали» інакше щоразу
|
||
// недорахував би останню сходинку кожного проходу.
|
||
e.logStep(ctx, snap, d, "sent", 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) (sent, eligible int) {
|
||
|
||
a := snap.Alert
|
||
head := escalationHeader(snap, d)
|
||
|
||
for _, id := range d.ChannelIDs {
|
||
c, ok := byID[id]
|
||
if !ok {
|
||
continue
|
||
}
|
||
// Вимкнений канал і поріг серйозності діють на сходинку так
|
||
// само, як на звичайне сповіщення: обидва — рішення про канал, а
|
||
// не про драбину. Інакше «вимкнув Telegram на час переїзду»
|
||
// означало б «вимкнув усе, крім ескалації», тобто рівно те, чого
|
||
// людина не просила.
|
||
if !c.Enabled || severityRank[a.Severity] < severityRank[c.MinSeverity] {
|
||
continue
|
||
}
|
||
eligible++
|
||
// Рахуємо доставки, а не спроби: сходинка, чиї повідомлення всі
|
||
// впали, не має лишати в журналі «надіслано».
|
||
if err := n.sendText(ctx, snap.TenantID, a, c, head+renderMessage(a, c)); err == nil {
|
||
sent++
|
||
}
|
||
}
|
||
|
||
if sent == 0 {
|
||
n.log.Warn("сходинка ескалації нікому не дійшла",
|
||
"алерт", snap.AlertID, "сходинка", d.StepIdx+1,
|
||
"каналів у сходинці", len(d.ChannelIDs), "придатних", eligible)
|
||
}
|
||
return sent, eligible
|
||
}
|
||
|
||
// 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
|
||
}
|