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 }