Netpulse_SasS/server/internal/alerting/escalation.go
byrsapty 954be1d643
All checks were successful
CI / hygiene (push) Successful in 9s
CI / web (push) Successful in 1m15s
CI / server (push) Successful in 1m34s
CI / agent (push) Successful in 2m59s
Ескалації: закриваю те, що минулого разу закрив наполовину
За другою рецензією:

* 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>
2026-08-28 17:44:38 +03:00

222 lines
11 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 проганяє сходинки, час яких настав.
//
// Викликається зі СВОГО такту (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
}