Netpulse_SasS/server/internal/alerting/escalation.go
byrsapty cedf1d261d
All checks were successful
CI / hygiene (push) Successful in 9s
CI / web (push) Successful in 1m18s
CI / server (push) Successful in 1m50s
CI / agent (push) Successful in 1m2s
Ескалації: журнал більше не бреше, ack не воскрешає драбину
Три вади, знайдені рецензією, яких щасливий шлях показати не міг:

* outcome='sent' писався до доставки; помилка читання каналів клала в
  кеш порожню мапу й з'їдала всі сходинки кабінету за тік — усі зі
  слідом «надіслано». Канали тепер читаються до просування стану,
  журнал пишеться після доставки, з правдою.
* UPDATE не мав stopped_at IS NULL — підтвердження алерту посеред
  партії не рятувало людину від дзвінка.
* час брався раз на партію.

Плюс суміжне: UpdateRule не гасив алертів вимкненого правила, сервер
домислював enabled на оновленні, channel_ids сходинок не звірялись із
каналами кабінету (зокрема чужого).

І scripts/dbtest.sh — тести проти бази перестали мовчки пропускатись.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 17:20:18 +03:00

201 lines
9.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}