Files
opsx b8eca87a89
CI / Backend (go vet + test) (push) Waiting to run
CI / Frontend (lint + build + test) (push) Waiting to run
fix(alerts): Pro 到期后告警引擎停止评估 + 规则/通道写操作挂 RequirePro
用户指出的漏洞:免费期配完规则,到期后若引擎继续跑,告警等于免费功能,Pro 形同虚设;
重装又拿新试用继续白用。修复:
- 引擎每轮先查 IsPro(),非 Pro 整轮跳过(规则与投递历史保留,重新授权即恢复)
- 规则建/改、通道保存、立即检查挂 RequirePro 闸门(读列表/历史保持可见,升级引导有据可依)
- 试用期内 IsPro=true 照常工作(实测建规则 200)
2026-11-01 06:06:10 +08:00

424 lines
13 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 services
// 日志告警引擎:周期执行 AlertRule 的 LogQL 计数查询,超阈值发送飞书卡片。
// 设计参照运维 app-alert.py:即时查询 sum(count_over_time(q[窗口])) 精确计数 +
// 按 app/pod 分组定位 + 日志样例 + 告警冷却(同规则冷却期内不重复发)。
import (
"context"
"encoding/json"
"fmt"
"log"
"net/url"
"strings"
"sync"
"sync/atomic"
"time"
"lokistack/models"
"lokistack/pkg/metrics"
"lokistack/repository"
)
// 告警风暴抑制参数:最近 1 小时内全平台最多成功发送多少条。
//
// 为什么需要:单规则 cooldown 只能防"同一条重复",挡不住"多条规则同时炸"。
// 实测过一次存储抖动让 8 条内置规则同时命中,10 秒内群里刷了 8 条,
// 之后大家开始无视这个群 —— 告警疲劳比漏报更难挽回。
const (
alertStormWindow = time.Hour
alertStormMaxPerHour = 12
)
// overStormLimit 判断是否已经触发全局抑制。
// 返回 (是否抑制, 最近窗口内已发送条数)。
func overStormLimit() (bool, int) {
db := repository.GetDB()
if db == nil {
return false, 0
}
cutoff := time.Now().Add(-alertStormWindow)
db.RLock()
defer db.RUnlock()
sent := 0
for _, delivery := range db.AlertDeliveries {
if delivery.Status == "sent" && delivery.CreatedAt.After(cutoff) {
sent++
}
}
return sent >= alertStormMaxPerHour, sent
}
// 检查间隔:规则窗口都是 15 分钟,1 分钟查一次等于用 15 倍频率扫同样的数据。
// Loki 单实例 CPU 是瓶颈(实测被限流 76%),这里降到 5 分钟:
// 对告警及时性影响很小(窗口本身就是 15 分钟),但稳态负载降到 1/5。
const alertCheckInterval = 5 * time.Minute
const (
// alertRoundTimeout 一轮检查的总时限。超过就中断本轮,
// 否则重叠保护会让引擎一直"在运行",后续轮次全部被跳过。
alertRoundTimeout = 5 * time.Minute
// alertRuleTimeout 单条规则的查询时限:Loki 单次查询不该超过它。
alertRuleTimeout = 90 * time.Second
)
// alertCheckRunning 防止 ticker 重叠执行。
var alertCheckRunning atomic.Bool
var alertTicker *time.Ticker
// StartAlertEngine 启动周期检查(main 调用)
func StartAlertEngine() {
if alertTicker != nil {
return
}
alertTicker = time.NewTicker(alertCheckInterval)
go func() {
for range alertTicker.C {
// ⚠️ 重叠保护(重要):
// ticker 是每分钟触发,而一轮规则检查(每条规则都要扫一遍日志内容,
// 实测 20~30s/条 × 8 条 = 2~4 分钟)远超一分钟。
// 原来没有这个保护,于是每过一分钟就再开一轮 → 查询越堆越多,
// 既抢走页面查询的 Loki 资源,又让每轮越来越慢,最后看起来像"告警不响了"。
// 现在:上一轮没跑完就直接跳过本轮,宁可少评估一次也不堆积。
if !alertCheckRunning.CompareAndSwap(false, true) {
log.Printf("[alert] 上一轮检查仍在运行,跳过本轮")
continue
}
// Pro 闸门:日志告警是 Pro 功能,免费/试用到期后引擎不再评估
// (规则与历史保留,重新授权即恢复,不删任何数据)
if !IsPro() {
alertCheckRunning.Store(false)
continue
}
start := time.Now()
ok := true
func() {
defer func() {
// 整轮兜底:即使有未预期的 panic 也不能让引擎 goroutine 挂掉
if r := recover(); r != nil {
ok = false
log.Printf("[alert] 本轮检查 panic: %v", r)
}
alertCheckRunning.Store(false)
}()
// 每轮总超时:防止个别慢查询把整轮拖成"永久运行",
// 那种情况下重叠保护会让引擎再也不启动新一轮。
ctx, cancel := context.WithTimeout(context.Background(), alertRoundTimeout)
defer cancel()
RunAlertCheck(ctx)
}()
metrics.ObserveAlertEvaluation(time.Since(start).Seconds(), ok)
}
}()
}
// StopAlertEngine 优雅关停
func StopAlertEngine() {
if alertTicker != nil {
alertTicker.Stop()
alertTicker = nil
}
}
// lokiInstantCount 即时计数:sum(count_over_time(q[window]))
func lokiInstantCount(ctx context.Context, src *models.LogSource, query string, windowMin int) (int, error) {
wrapped := fmt.Sprintf("sum(count_over_time(%s[%dm]))", query, windowMin)
q := url.Values{}
q.Set("query", wrapped)
q.Set("time", fmt.Sprintf("%d", time.Now().UnixNano()))
body, _, err := lokiProxyGET(ctx, src, "/loki/api/v1/query", q)
if err != nil {
return 0, err
}
var resp struct {
Data struct {
Result []struct {
Value [2]interface{} `json:"value"`
} `json:"result"`
} `json:"data"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return 0, err
}
if len(resp.Data.Result) == 0 {
return 0, nil
}
count := 0
switch v := resp.Data.Result[0].Value[1].(type) {
case string:
fmt.Sscanf(v, "%d", &count)
case float64:
count = int(v)
}
return count, nil
}
// lokiGroupTop 按 app/pod 分组定位(取前 10)
func lokiGroupTop(ctx context.Context, src *models.LogSource, query string, windowMin int) []string {
wrapped := fmt.Sprintf("sum by (app, pod) (count_over_time(%s[%dm]))", query, windowMin)
q := url.Values{}
q.Set("query", wrapped)
q.Set("time", fmt.Sprintf("%d", time.Now().UnixNano()))
body, _, err := lokiProxyGET(ctx, src, "/loki/api/v1/query", q)
if err != nil {
return nil
}
var resp struct {
Data struct {
Result []struct {
Metric map[string]string `json:"metric"`
Value [2]interface{} `json:"value"`
} `json:"result"`
} `json:"data"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil
}
lines := []string{}
for _, r := range resp.Data.Result {
count := 0
if v, ok := r.Value[1].(string); ok {
fmt.Sscanf(v, "%d", &count)
} else if v, ok := r.Value[1].(float64); ok {
count = int(v)
}
app := r.Metric["app"]
if app == "" {
app = "?"
}
pod := r.Metric["pod"]
if pod == "" {
pod = "?"
}
lines = append(lines, fmt.Sprintf("- %s / %s(%d 条)", app, pod, count))
if len(lines) >= 10 {
break
}
}
return lines
}
// lokiSampleLines 日志样例(最多 3 条,每条截 220)
func lokiSampleLines(ctx context.Context, src *models.LogSource, query string, windowMin int) []string {
res, err := QueryLokiLogs(ctx, src, query, 0, 0, 3, "backward", "")
if err != nil {
return nil
}
out := []string{}
for _, e := range res.Entries {
line := e.Message
if len(line) > 220 {
line = line[:220]
}
out = append(out, "> "+line)
if len(out) >= 3 {
break
}
}
return out
}
// cooldownRecordLast 记录每条规则上次写"冷却"记录的时间,用于节流。
var (
cooldownRecordMu sync.Mutex
cooldownRecordLast = map[uint]time.Time{}
)
// recordCooldownThrottled 同一规则 10 分钟内只允许写一条冷却记录。
func recordCooldownThrottled(ruleID uint) bool {
cooldownRecordMu.Lock()
defer cooldownRecordMu.Unlock()
now := time.Now()
if last, ok := cooldownRecordLast[ruleID]; ok && now.Sub(last) < 10*time.Minute {
return false
}
cooldownRecordLast[ruleID] = now
return true
}
// pruneDeliveriesLocked 按保留期 + 条数上限裁剪投递历史(调用方必须已持锁)。
func pruneDeliveriesLocked(db *repository.JsonDB) {
cut := 0
// 注意:调用方已持写锁,必须用无锁读取版本,否则 RWMutex 会死锁
deadline := time.Now().Add(-alertDeliveryRetentionLocked(db))
for cut < len(db.AlertDeliveries) && db.AlertDeliveries[cut].CreatedAt.Before(deadline) {
cut++
}
if remain := len(db.AlertDeliveries) - cut; remain > maxAlertDeliveries {
cut += remain - maxAlertDeliveries
}
if cut > 0 {
db.AlertDeliveries = append([]models.AlertDelivery{}, db.AlertDeliveries[cut:]...)
}
}
// PruneAlertDeliveries 按当前保留期裁剪一次(供保存设置后立即生效)。
func PruneAlertDeliveries() {
db := repository.GetDB()
if db == nil {
return
}
db.Lock()
defer db.Unlock()
before := len(db.AlertDeliveries)
pruneDeliveriesLocked(db)
if len(db.AlertDeliveries) != before {
db.RequestSave()
}
}
func recordDelivery(ruleID uint, ruleName string, count int, status, reason string) {
db := repository.GetDB()
newID := db.NextID("alertDeliveries")
db.Lock()
db.AlertDeliveries = append(db.AlertDeliveries, models.AlertDelivery{
ID: newID, RuleID: ruleID, RuleName: ruleName,
Count: count, Status: status, Reason: reason,
})
// 保留策略:按 告警发送历史保留天数(默认 7 天,系统设置可改)+ 条数硬上限兜底。
// 原来写死 只留最近 50 条,真实发送记录一小时内就被冷却记录挤光了。
pruneDeliveriesLocked(db)
db.RequestSave()
db.Unlock()
}
// RunAlertCheck 执行一次全部启用规则的检查(定时器与手动触发共用)
func RunAlertCheck(ctx context.Context) {
db := repository.GetDB()
db.RLock()
rules := make([]models.AlertRule, 0, len(db.AlertRules))
for i := range db.AlertRules {
if db.AlertRules[i].Enabled {
rules = append(rules, db.AlertRules[i])
}
}
db.RUnlock()
for i := range rules {
// 单条规则独立超时 + 独立 panic 隔离:
// 一条规则查询卡住或代码 panic,不能连带把后面所有规则都跳过。
ruleCtx, cancel := context.WithTimeout(ctx, alertRuleTimeout)
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("[alert] 规则 %q 检查 panic: %v", rules[i].Name, r)
recordDelivery(rules[i].ID, rules[i].Name, 0, "failed", fmt.Sprintf("规则检查异常: %v", r))
}
}()
checkRule(ruleCtx, &rules[i])
}()
cancel()
}
}
func checkRule(ctx context.Context, rule *models.AlertRule) {
src := GetLogSource(rule.SourceID)
if src == nil {
return
}
count, err := lokiInstantCount(ctx, src, rule.Query, rule.WindowMin)
if err != nil {
recordDelivery(rule.ID, rule.Name, 0, "failed", err.Error())
return
}
if count <= rule.Threshold {
return
}
// 冷却检查(同规则)
db := repository.GetDB()
db.RLock()
lastFired := rule.LastFiredAt
cooldown := rule.CooldownMin
if cooldown <= 0 {
cooldown = 60
}
db.RUnlock()
if !lastFired.IsZero() && time.Since(lastFired) < time.Duration(cooldown)*time.Minute {
// 冷却记录节流:命中但处于冷却时,**每 10 分钟最多记一条**。
// 原来每个 tick(1 分钟)都写,50 条上限下,真实的"已发送"记录
// 一小时内就被冷却记录挤光了,发送历史基本没法看。
if recordCooldownThrottled(rule.ID) {
recordDelivery(rule.ID, rule.Name, count, "cooldown", "冷却中,未重复发送")
}
return
}
// 组装卡片
groups := lokiGroupTop(ctx, src, rule.Query, rule.WindowMin)
samples := lokiSampleLines(ctx, src, rule.Query, rule.WindowMin)
ts := time.Now().Format("2006-01-02 15:04:05")
lines := []string{
fmt.Sprintf("**告警时间**: %s", ts),
fmt.Sprintf("**规则**: %s", rule.Name),
fmt.Sprintf("**命中数量**: %d 条(%d 分钟内)", count, rule.WindowMin),
fmt.Sprintf("**触发阈值**: > %d 条", rule.Threshold),
fmt.Sprintf("**查询**: `%s`", rule.Query),
}
if len(groups) > 0 {
lines = append(lines, "**来源 app/pod**:\n"+strings.Join(groups, "\n"))
}
if len(samples) > 0 {
lines = append(lines, "**日志样例**:\n"+strings.Join(samples, "\n"))
}
// ── 告警风暴抑制:全平台每小时最多发 N 条 ──────────────────────
//
// 单条规则有 cooldown 只能说"同一条别重复",挡不住"20 条规则同时炸"。
// 实测过:一次存储抖动让 8 条内置规则同时命中,10 秒内往群里刷了 8 条,
// 之后大家开始无视这个群。所以再加一道**全局闸门**:
// 最近 1 小时内成功发送数达到上限后,后续命中只记"被抑制",不再发送。
// 被抑制的记录仍写进发送历史(可见、可追溯),并写明原因。
if suppressed, sentCount := overStormLimit(); suppressed {
recordDelivery(rule.ID, rule.Name, count, "cooldown",
fmt.Sprintf("告警风暴抑制:最近 1 小时已发送 %d 条(上限 %d),本条未发送", sentCount, alertStormMaxPerHour))
return
}
// 扇出到全部关联通道;部分失败记录失败原因(成功任一即更新冷却)
title := "🔴 日志告警 " + rule.Name
var failMsgs []string
if len(rule.ChannelIDs) > 0 {
for _, chID := range rule.ChannelIDs {
if err := SendLarkCardToChannel(chID, title, "red", lines); err != nil {
chName := fmt.Sprintf("通道%d", chID)
if ch := GetNotifyChannel(chID); ch != nil {
chName = ch.Name
}
failMsgs = append(failMsgs, fmt.Sprintf("%s: %v", chName, err))
}
}
} else if cfg := GetNotifyConfig(); cfg.Enabled && cfg.Webhook != "" {
// 兼容:未关联通道回退旧全局配置
if err := SendLarkCard(title, "red", lines); err != nil {
failMsgs = append(failMsgs, err.Error())
}
} else {
failMsgs = append(failMsgs, "规则未关联通知通道")
}
if len(failMsgs) > 0 {
recordDelivery(rule.ID, rule.Name, count, "failed", strings.Join(failMsgs, "; "))
return
}
recordDelivery(rule.ID, rule.Name, count, "sent", fmt.Sprintf("已扇出到 %d 个通道", maxInt(len(rule.ChannelIDs), 1)))
// 更新 LastFiredAt
db.Lock()
for i := range db.AlertRules {
if db.AlertRules[i].ID == rule.ID {
db.AlertRules[i].LastFiredAt = time.Now()
break
}
}
db.RequestSave()
db.Unlock()
recordDelivery(rule.ID, rule.Name, count, "sent", "")
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}