Netpulse_SasS/server/internal/alerting/escalation.go
byrsapty ae07bd2a79
All checks were successful
CI / hygiene (push) Successful in 8s
CI / web (push) Successful in 1m21s
CI / server (push) Successful in 1m48s
CI / agent (push) Successful in 2m59s
Прив'язка Telegram: сторінка була, дороги до неї не було
Кнопка під сповіщенням відповідала «ваш Telegram не прив'язано» і не
давала виходу. У базі нуль прив'язок і нуль кодів за весь час.

Сторінка профілю існує, але пункту меню не мала, а єдиний вхід — ім'я
користувача в шапці — малювався за умовою «є ім'я або пошта», тоді як
/me віддавало лише пошту. В облікового запису власника, який заводить
установник і який входить ІМЕНЕМ, вона порожня. Тобто в типовій
інсталяції входу в профіль не було взагалі.

* /me віддає username (тип Me на фронтенді його вже вимагав);
* вхід у профіль малюється завжди для людини;
* пункт меню «Обліковий запис → Мій профіль», perm став необов'язковим;
* текст бота називає те, що видно на екрані;
* сторінка каналів показує стан прив'язки біля telegram-каналу.

Плюс 0073: оренда сходинки ескалації отримала lease_token. Партія
переростає 2-хвилинну оренду, і другий інстанс доставляв ту саму
сходинку паралельно з першим. Тепер запис проходить лише за збігу
токена; при розбіжності не відбувається нічого, сходинка лишається
належною.

65 міграцій, усе зелене проти справжньої бази.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 19:57:09 +03:00

235 lines
12 KiB
Go
Raw Permalink 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) — другий рубіж на випадок, коли рядок усе-таки взяли
// двоє.
//
// Партія при цьому МОЖЕ пережити власну оренду, і це не аварія, а
// нормальний вечір: escalationBatch сходинок, кожна з мережевим
// таймаутом на канал, легко переростають дві хвилини. Рятує від
// подвоєння не строк, а токен оренди (0073): ApplyEscalation відмовить
// сходинці, чию оренду вже перебрали, і надсилання до неї не дійде — бо
// стан пишеться ПЕРЕД доставкою, а не після.
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 {
// Три причини, і всі — не помилка.
//
// Драбину зупинили, поки сходинка чекала своєї черги
// (найчастіше людина натиснула «Прийняти») — це той випадок,
// заради якого кнопка й існує. Алерт погас іншим шляхом —
// дзвонити нема про що. Або оренду перебрали: партія
// затягнулась довше за EscalationLease, і сходинку вже веде
// інше взяття (токен у 0073). У всіх трьох сходинка НЕ
// доставляється — саме тому запис іде перед надсиланням.
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
}