ops_alert_evaluator_service.go 28 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076
  1. package service
  2. import (
  3. "context"
  4. "fmt"
  5. "math"
  6. "strconv"
  7. "strings"
  8. "sync"
  9. "time"
  10. "github.com/Wei-Shaw/sub2api/internal/config"
  11. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  12. "github.com/google/uuid"
  13. "github.com/redis/go-redis/v9"
  14. )
  15. const (
  16. opsAlertEvaluatorJobName = "ops_alert_evaluator"
  17. opsAlertEvaluatorTimeout = 45 * time.Second
  18. opsAlertEvaluatorLeaderLockKey = "ops:alert:evaluator:leader"
  19. opsAlertEvaluatorLeaderLockTTL = 90 * time.Second
  20. opsAlertEvaluatorSkipLogInterval = 1 * time.Minute
  21. )
  22. var opsAlertEvaluatorReleaseScript = redis.NewScript(`
  23. if redis.call("GET", KEYS[1]) == ARGV[1] then
  24. return redis.call("DEL", KEYS[1])
  25. end
  26. return 0
  27. `)
  28. type OpsAlertEvaluatorService struct {
  29. opsService *OpsService
  30. opsRepo OpsRepository
  31. emailService *EmailService
  32. proxyRepo ProxyRepository
  33. redisClient *redis.Client
  34. cfg *config.Config
  35. instanceID string
  36. stopCh chan struct{}
  37. startOnce sync.Once
  38. stopOnce sync.Once
  39. wg sync.WaitGroup
  40. mu sync.Mutex
  41. ruleStates map[int64]*opsAlertRuleState
  42. emailLimiter *slidingWindowLimiter
  43. skipLogMu sync.Mutex
  44. skipLogAt time.Time
  45. warnNoRedisOnce sync.Once
  46. }
  47. type opsAlertRuleState struct {
  48. LastEvaluatedAt time.Time
  49. ConsecutiveBreaches int
  50. }
  51. func NewOpsAlertEvaluatorService(
  52. opsService *OpsService,
  53. opsRepo OpsRepository,
  54. emailService *EmailService,
  55. redisClient *redis.Client,
  56. cfg *config.Config,
  57. proxyRepo ProxyRepository,
  58. ) *OpsAlertEvaluatorService {
  59. return &OpsAlertEvaluatorService{
  60. opsService: opsService,
  61. opsRepo: opsRepo,
  62. emailService: emailService,
  63. proxyRepo: proxyRepo,
  64. redisClient: redisClient,
  65. cfg: cfg,
  66. instanceID: uuid.NewString(),
  67. ruleStates: map[int64]*opsAlertRuleState{},
  68. emailLimiter: newSlidingWindowLimiter(0, time.Hour),
  69. }
  70. }
  71. func (s *OpsAlertEvaluatorService) Start() {
  72. if s == nil {
  73. return
  74. }
  75. s.startOnce.Do(func() {
  76. if s.stopCh == nil {
  77. s.stopCh = make(chan struct{})
  78. }
  79. s.wg.Add(1)
  80. go s.run()
  81. })
  82. }
  83. func (s *OpsAlertEvaluatorService) Stop() {
  84. if s == nil {
  85. return
  86. }
  87. s.stopOnce.Do(func() {
  88. if s.stopCh != nil {
  89. close(s.stopCh)
  90. }
  91. })
  92. s.wg.Wait()
  93. }
  94. func (s *OpsAlertEvaluatorService) run() {
  95. defer s.wg.Done()
  96. // Start immediately to produce early feedback in ops dashboard.
  97. timer := time.NewTimer(0)
  98. defer timer.Stop()
  99. for {
  100. select {
  101. case <-timer.C:
  102. interval := s.getInterval()
  103. s.evaluateOnce(interval)
  104. timer.Reset(interval)
  105. case <-s.stopCh:
  106. return
  107. }
  108. }
  109. }
  110. func (s *OpsAlertEvaluatorService) getInterval() time.Duration {
  111. // Default.
  112. interval := 60 * time.Second
  113. if s == nil || s.opsService == nil {
  114. return interval
  115. }
  116. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  117. defer cancel()
  118. cfg, err := s.opsService.GetOpsAlertRuntimeSettings(ctx)
  119. if err != nil || cfg == nil {
  120. return interval
  121. }
  122. if cfg.EvaluationIntervalSeconds <= 0 {
  123. return interval
  124. }
  125. if cfg.EvaluationIntervalSeconds < 1 {
  126. return interval
  127. }
  128. if cfg.EvaluationIntervalSeconds > int((24 * time.Hour).Seconds()) {
  129. return interval
  130. }
  131. return time.Duration(cfg.EvaluationIntervalSeconds) * time.Second
  132. }
  133. func (s *OpsAlertEvaluatorService) evaluateOnce(interval time.Duration) {
  134. if s == nil || s.opsRepo == nil {
  135. return
  136. }
  137. if s.cfg != nil && !s.cfg.Ops.Enabled {
  138. return
  139. }
  140. ctx, cancel := context.WithTimeout(context.Background(), opsAlertEvaluatorTimeout)
  141. defer cancel()
  142. if s.opsService != nil && !s.opsService.IsMonitoringEnabled(ctx) {
  143. return
  144. }
  145. runtimeCfg := defaultOpsAlertRuntimeSettings()
  146. if s.opsService != nil {
  147. if loaded, err := s.opsService.GetOpsAlertRuntimeSettings(ctx); err == nil && loaded != nil {
  148. runtimeCfg = loaded
  149. }
  150. }
  151. release, ok := s.tryAcquireLeaderLock(ctx, runtimeCfg.DistributedLock)
  152. if !ok {
  153. return
  154. }
  155. if release != nil {
  156. defer release()
  157. }
  158. startedAt := time.Now().UTC()
  159. runAt := startedAt
  160. rules, err := s.opsRepo.ListAlertRules(ctx)
  161. if err != nil {
  162. s.recordHeartbeatError(runAt, time.Since(startedAt), err)
  163. logger.LegacyPrintf("service.ops_alert_evaluator", "[OpsAlertEvaluator] list rules failed: %v", err)
  164. return
  165. }
  166. rulesTotal := len(rules)
  167. rulesEnabled := 0
  168. rulesEvaluated := 0
  169. eventsCreated := 0
  170. eventsResolved := 0
  171. emailsSent := 0
  172. now := time.Now().UTC()
  173. safeEnd := now.Truncate(time.Minute)
  174. if safeEnd.IsZero() {
  175. safeEnd = now
  176. }
  177. systemMetrics, _ := s.opsRepo.GetLatestSystemMetrics(ctx, 1)
  178. // Cleanup stale state for removed rules.
  179. s.pruneRuleStates(rules)
  180. for _, rule := range rules {
  181. if rule == nil || !rule.Enabled || rule.ID <= 0 {
  182. continue
  183. }
  184. rulesEnabled++
  185. scopePlatform, scopeGroupID, scopeRegion := parseOpsAlertRuleScope(rule.Filters)
  186. windowMinutes := rule.WindowMinutes
  187. if windowMinutes <= 0 {
  188. windowMinutes = 1
  189. }
  190. windowStart := safeEnd.Add(-time.Duration(windowMinutes) * time.Minute)
  191. windowEnd := safeEnd
  192. metricValue, ok := s.computeRuleMetric(ctx, rule, systemMetrics, windowStart, windowEnd, scopePlatform, scopeGroupID)
  193. if !ok {
  194. s.resetRuleState(rule.ID, now)
  195. continue
  196. }
  197. rulesEvaluated++
  198. breachedNow := compareMetric(metricValue, rule.Operator, rule.Threshold)
  199. required := requiredSustainedBreaches(rule.SustainedMinutes, interval)
  200. consecutive := s.updateRuleBreaches(rule.ID, now, interval, breachedNow)
  201. activeEvent, err := s.opsRepo.GetActiveAlertEvent(ctx, rule.ID)
  202. if err != nil {
  203. logger.LegacyPrintf("service.ops_alert_evaluator", "[OpsAlertEvaluator] get active event failed (rule=%d): %v", rule.ID, err)
  204. continue
  205. }
  206. if breachedNow && consecutive >= required {
  207. if activeEvent != nil {
  208. continue
  209. }
  210. // Scoped silencing: if a matching silence exists, skip creating a firing event.
  211. if s.opsService != nil {
  212. platform := strings.TrimSpace(scopePlatform)
  213. region := scopeRegion
  214. if platform != "" {
  215. if ok, err := s.opsService.IsAlertSilenced(ctx, rule.ID, platform, scopeGroupID, region, now); err == nil && ok {
  216. continue
  217. }
  218. }
  219. }
  220. latestEvent, err := s.opsRepo.GetLatestAlertEvent(ctx, rule.ID)
  221. if err != nil {
  222. logger.LegacyPrintf("service.ops_alert_evaluator", "[OpsAlertEvaluator] get latest event failed (rule=%d): %v", rule.ID, err)
  223. continue
  224. }
  225. if latestEvent != nil && rule.CooldownMinutes > 0 {
  226. cooldown := time.Duration(rule.CooldownMinutes) * time.Minute
  227. if now.Sub(latestEvent.FiredAt) < cooldown {
  228. continue
  229. }
  230. }
  231. firedEvent := &OpsAlertEvent{
  232. RuleID: rule.ID,
  233. Severity: strings.TrimSpace(rule.Severity),
  234. Status: OpsAlertStatusFiring,
  235. Title: fmt.Sprintf("%s: %s", strings.TrimSpace(rule.Severity), strings.TrimSpace(rule.Name)),
  236. Description: buildOpsAlertDescription(rule, metricValue, windowMinutes, scopePlatform, scopeGroupID),
  237. MetricValue: float64Ptr(metricValue),
  238. ThresholdValue: float64Ptr(rule.Threshold),
  239. Dimensions: buildOpsAlertDimensions(scopePlatform, scopeGroupID),
  240. FiredAt: now,
  241. CreatedAt: now,
  242. }
  243. created, err := s.opsRepo.CreateAlertEvent(ctx, firedEvent)
  244. if err != nil {
  245. logger.LegacyPrintf("service.ops_alert_evaluator", "[OpsAlertEvaluator] create event failed (rule=%d): %v", rule.ID, err)
  246. continue
  247. }
  248. eventsCreated++
  249. if created != nil && created.ID > 0 {
  250. if s.maybeSendAlertEmail(ctx, runtimeCfg, rule, created) {
  251. emailsSent++
  252. }
  253. }
  254. continue
  255. }
  256. // Not breached: resolve active event if present.
  257. if activeEvent != nil {
  258. resolvedAt := now
  259. if err := s.opsRepo.UpdateAlertEventStatus(ctx, activeEvent.ID, OpsAlertStatusResolved, &resolvedAt); err != nil {
  260. logger.LegacyPrintf("service.ops_alert_evaluator", "[OpsAlertEvaluator] resolve event failed (event=%d): %v", activeEvent.ID, err)
  261. } else {
  262. eventsResolved++
  263. }
  264. }
  265. }
  266. result := truncateString(fmt.Sprintf("rules=%d enabled=%d evaluated=%d created=%d resolved=%d emails_sent=%d", rulesTotal, rulesEnabled, rulesEvaluated, eventsCreated, eventsResolved, emailsSent), 2048)
  267. s.recordHeartbeatSuccess(runAt, time.Since(startedAt), result)
  268. }
  269. func (s *OpsAlertEvaluatorService) pruneRuleStates(rules []*OpsAlertRule) {
  270. s.mu.Lock()
  271. defer s.mu.Unlock()
  272. live := map[int64]struct{}{}
  273. for _, r := range rules {
  274. if r != nil && r.ID > 0 {
  275. live[r.ID] = struct{}{}
  276. }
  277. }
  278. for id := range s.ruleStates {
  279. if _, ok := live[id]; !ok {
  280. delete(s.ruleStates, id)
  281. }
  282. }
  283. }
  284. func (s *OpsAlertEvaluatorService) resetRuleState(ruleID int64, now time.Time) {
  285. if ruleID <= 0 {
  286. return
  287. }
  288. s.mu.Lock()
  289. defer s.mu.Unlock()
  290. state, ok := s.ruleStates[ruleID]
  291. if !ok {
  292. state = &opsAlertRuleState{}
  293. s.ruleStates[ruleID] = state
  294. }
  295. state.LastEvaluatedAt = now
  296. state.ConsecutiveBreaches = 0
  297. }
  298. func (s *OpsAlertEvaluatorService) updateRuleBreaches(ruleID int64, now time.Time, interval time.Duration, breached bool) int {
  299. if ruleID <= 0 {
  300. return 0
  301. }
  302. s.mu.Lock()
  303. defer s.mu.Unlock()
  304. state, ok := s.ruleStates[ruleID]
  305. if !ok {
  306. state = &opsAlertRuleState{}
  307. s.ruleStates[ruleID] = state
  308. }
  309. if !state.LastEvaluatedAt.IsZero() && interval > 0 {
  310. if now.Sub(state.LastEvaluatedAt) > interval*2 {
  311. state.ConsecutiveBreaches = 0
  312. }
  313. }
  314. state.LastEvaluatedAt = now
  315. if breached {
  316. state.ConsecutiveBreaches++
  317. } else {
  318. state.ConsecutiveBreaches = 0
  319. }
  320. return state.ConsecutiveBreaches
  321. }
  322. func requiredSustainedBreaches(sustainedMinutes int, interval time.Duration) int {
  323. if sustainedMinutes <= 0 {
  324. return 1
  325. }
  326. if interval <= 0 {
  327. return sustainedMinutes
  328. }
  329. required := int(math.Ceil(float64(sustainedMinutes*60) / interval.Seconds()))
  330. if required < 1 {
  331. return 1
  332. }
  333. return required
  334. }
  335. func parseOpsAlertRuleScope(filters map[string]any) (platform string, groupID *int64, region *string) {
  336. if filters == nil {
  337. return "", nil, nil
  338. }
  339. if v, ok := filters["platform"]; ok {
  340. if s, ok := v.(string); ok {
  341. platform = strings.TrimSpace(s)
  342. }
  343. }
  344. if v, ok := filters["group_id"]; ok {
  345. switch t := v.(type) {
  346. case float64:
  347. if t > 0 {
  348. id := int64(t)
  349. groupID = &id
  350. }
  351. case int64:
  352. if t > 0 {
  353. id := t
  354. groupID = &id
  355. }
  356. case int:
  357. if t > 0 {
  358. id := int64(t)
  359. groupID = &id
  360. }
  361. case string:
  362. n, err := strconv.ParseInt(strings.TrimSpace(t), 10, 64)
  363. if err == nil && n > 0 {
  364. groupID = &n
  365. }
  366. }
  367. }
  368. if v, ok := filters["region"]; ok {
  369. if s, ok := v.(string); ok {
  370. vv := strings.TrimSpace(s)
  371. if vv != "" {
  372. region = &vv
  373. }
  374. }
  375. }
  376. return platform, groupID, region
  377. }
  378. func (s *OpsAlertEvaluatorService) computeRuleMetric(
  379. ctx context.Context,
  380. rule *OpsAlertRule,
  381. systemMetrics *OpsSystemMetricsSnapshot,
  382. start time.Time,
  383. end time.Time,
  384. platform string,
  385. groupID *int64,
  386. ) (float64, bool) {
  387. if rule == nil {
  388. return 0, false
  389. }
  390. switch strings.TrimSpace(rule.MetricType) {
  391. case "cpu_usage_percent":
  392. if systemMetrics != nil && systemMetrics.CPUUsagePercent != nil {
  393. return *systemMetrics.CPUUsagePercent, true
  394. }
  395. return 0, false
  396. case "memory_usage_percent":
  397. if systemMetrics != nil && systemMetrics.MemoryUsagePercent != nil {
  398. return *systemMetrics.MemoryUsagePercent, true
  399. }
  400. return 0, false
  401. case "concurrency_queue_depth":
  402. if systemMetrics != nil && systemMetrics.ConcurrencyQueueDepth != nil {
  403. return float64(*systemMetrics.ConcurrencyQueueDepth), true
  404. }
  405. return 0, false
  406. case "group_available_accounts":
  407. if groupID == nil || *groupID <= 0 {
  408. return 0, false
  409. }
  410. if s == nil || s.opsService == nil {
  411. return 0, false
  412. }
  413. availability, err := s.opsService.GetAccountAvailability(ctx, platform, groupID)
  414. if err != nil || availability == nil {
  415. return 0, false
  416. }
  417. if availability.Group == nil {
  418. return 0, true
  419. }
  420. return float64(availability.Group.AvailableCount), true
  421. case "group_available_ratio":
  422. if groupID == nil || *groupID <= 0 {
  423. return 0, false
  424. }
  425. if s == nil || s.opsService == nil {
  426. return 0, false
  427. }
  428. availability, err := s.opsService.GetAccountAvailability(ctx, platform, groupID)
  429. if err != nil || availability == nil {
  430. return 0, false
  431. }
  432. return computeGroupAvailableRatio(availability.Group), true
  433. case "account_rate_limited_count":
  434. if s == nil || s.opsService == nil {
  435. return 0, false
  436. }
  437. availability, err := s.opsService.GetAccountAvailability(ctx, platform, groupID)
  438. if err != nil || availability == nil {
  439. return 0, false
  440. }
  441. return float64(countAccountsByCondition(availability.Accounts, func(acc *AccountAvailability) bool {
  442. return acc.IsRateLimited
  443. })), true
  444. case "account_error_count":
  445. if s == nil || s.opsService == nil {
  446. return 0, false
  447. }
  448. availability, err := s.opsService.GetAccountAvailability(ctx, platform, groupID)
  449. if err != nil || availability == nil {
  450. return 0, false
  451. }
  452. return float64(countAccountsByCondition(availability.Accounts, func(acc *AccountAvailability) bool {
  453. return acc.HasError && acc.TempUnschedulableUntil == nil
  454. })), true
  455. case "account_temp_unscheduled_count":
  456. if s == nil || s.opsService == nil {
  457. return 0, false
  458. }
  459. availability, err := s.opsService.GetAccountAvailability(ctx, platform, groupID)
  460. if err != nil || availability == nil {
  461. return 0, false
  462. }
  463. now := time.Now().UTC()
  464. return float64(countAccountsByCondition(availability.Accounts, func(acc *AccountAvailability) bool {
  465. return acc.TempUnschedulableUntil != nil && now.Before(*acc.TempUnschedulableUntil)
  466. })), true
  467. case "group_rate_limit_ratio":
  468. if groupID == nil || *groupID <= 0 {
  469. return 0, false
  470. }
  471. if s == nil || s.opsService == nil {
  472. return 0, false
  473. }
  474. availability, err := s.opsService.GetAccountAvailability(ctx, platform, groupID)
  475. if err != nil || availability == nil {
  476. return 0, false
  477. }
  478. if availability.Group == nil || availability.Group.TotalAccounts <= 0 {
  479. return 0, true
  480. }
  481. return (float64(availability.Group.RateLimitCount) / float64(availability.Group.TotalAccounts)) * 100, true
  482. case "account_error_ratio":
  483. if s == nil || s.opsService == nil {
  484. return 0, false
  485. }
  486. availability, err := s.opsService.GetAccountAvailability(ctx, platform, groupID)
  487. if err != nil || availability == nil {
  488. return 0, false
  489. }
  490. total := int64(len(availability.Accounts))
  491. if total <= 0 {
  492. return 0, true
  493. }
  494. errorCount := countAccountsByCondition(availability.Accounts, func(acc *AccountAvailability) bool {
  495. return acc.HasError && acc.TempUnschedulableUntil == nil
  496. })
  497. return (float64(errorCount) / float64(total)) * 100, true
  498. case "overload_account_count":
  499. if s == nil || s.opsService == nil {
  500. return 0, false
  501. }
  502. availability, err := s.opsService.GetAccountAvailability(ctx, platform, groupID)
  503. if err != nil || availability == nil {
  504. return 0, false
  505. }
  506. return float64(countAccountsByCondition(availability.Accounts, func(acc *AccountAvailability) bool {
  507. return acc.IsOverloaded
  508. })), true
  509. case "proxy_expired_count":
  510. if s == nil || s.proxyRepo == nil {
  511. return 0, false
  512. }
  513. n, err := s.proxyRepo.CountExpired(ctx)
  514. if err != nil {
  515. return 0, false
  516. }
  517. return float64(n), true
  518. case "proxy_expiring_soon_count":
  519. if s == nil || s.proxyRepo == nil {
  520. return 0, false
  521. }
  522. n, err := s.proxyRepo.CountExpiringSoon(ctx, time.Now())
  523. if err != nil {
  524. return 0, false
  525. }
  526. return float64(n), true
  527. }
  528. overview, err := s.opsRepo.GetDashboardOverview(ctx, &OpsDashboardFilter{
  529. StartTime: start,
  530. EndTime: end,
  531. Platform: platform,
  532. GroupID: groupID,
  533. QueryMode: OpsQueryModeRaw,
  534. })
  535. if err != nil {
  536. return 0, false
  537. }
  538. if overview == nil {
  539. return 0, false
  540. }
  541. switch strings.TrimSpace(rule.MetricType) {
  542. case "success_rate":
  543. if overview.RequestCountSLA <= 0 {
  544. return 0, false
  545. }
  546. return overview.SLA * 100, true
  547. case "error_rate":
  548. if overview.RequestCountSLA <= 0 {
  549. return 0, false
  550. }
  551. return overview.ErrorRate * 100, true
  552. case "upstream_error_rate":
  553. if overview.RequestCountSLA <= 0 {
  554. return 0, false
  555. }
  556. return overview.UpstreamErrorRate * 100, true
  557. default:
  558. return 0, false
  559. }
  560. }
  561. func compareMetric(value float64, operator string, threshold float64) bool {
  562. switch strings.TrimSpace(operator) {
  563. case ">":
  564. return value > threshold
  565. case ">=":
  566. return value >= threshold
  567. case "<":
  568. return value < threshold
  569. case "<=":
  570. return value <= threshold
  571. case "==":
  572. return value == threshold
  573. case "!=":
  574. return value != threshold
  575. default:
  576. return false
  577. }
  578. }
  579. func buildOpsAlertDimensions(platform string, groupID *int64) map[string]any {
  580. dims := map[string]any{}
  581. if strings.TrimSpace(platform) != "" {
  582. dims["platform"] = strings.TrimSpace(platform)
  583. }
  584. if groupID != nil && *groupID > 0 {
  585. dims["group_id"] = *groupID
  586. }
  587. if len(dims) == 0 {
  588. return nil
  589. }
  590. return dims
  591. }
  592. func buildOpsAlertDescription(rule *OpsAlertRule, value float64, windowMinutes int, platform string, groupID *int64) string {
  593. if rule == nil {
  594. return ""
  595. }
  596. scope := "overall"
  597. if strings.TrimSpace(platform) != "" {
  598. scope = fmt.Sprintf("platform=%s", strings.TrimSpace(platform))
  599. }
  600. if groupID != nil && *groupID > 0 {
  601. scope = fmt.Sprintf("%s group_id=%d", scope, *groupID)
  602. }
  603. if windowMinutes <= 0 {
  604. windowMinutes = 1
  605. }
  606. return fmt.Sprintf("%s %s %.2f (current %.2f) over last %dm (%s)",
  607. strings.TrimSpace(rule.MetricType),
  608. strings.TrimSpace(rule.Operator),
  609. rule.Threshold,
  610. value,
  611. windowMinutes,
  612. strings.TrimSpace(scope),
  613. )
  614. }
  615. func (s *OpsAlertEvaluatorService) maybeSendAlertEmail(ctx context.Context, runtimeCfg *OpsAlertRuntimeSettings, rule *OpsAlertRule, event *OpsAlertEvent) bool {
  616. if s == nil || s.emailService == nil || s.opsService == nil || event == nil || rule == nil {
  617. return false
  618. }
  619. if event.EmailSent {
  620. return false
  621. }
  622. if !rule.NotifyEmail {
  623. return false
  624. }
  625. emailCfg, err := s.opsService.GetEmailNotificationConfig(ctx)
  626. if err != nil || emailCfg == nil || !emailCfg.Alert.Enabled {
  627. return false
  628. }
  629. if len(emailCfg.Alert.Recipients) == 0 {
  630. return false
  631. }
  632. if !shouldSendOpsAlertEmailByMinSeverity(strings.TrimSpace(emailCfg.Alert.MinSeverity), strings.TrimSpace(rule.Severity)) {
  633. return false
  634. }
  635. if runtimeCfg != nil && runtimeCfg.Silencing.Enabled {
  636. if isOpsAlertSilenced(time.Now().UTC(), rule, event, runtimeCfg.Silencing) {
  637. return false
  638. }
  639. }
  640. // Apply/update rate limiter.
  641. s.emailLimiter.SetLimit(emailCfg.Alert.RateLimitPerHour)
  642. subject := fmt.Sprintf("[Ops Alert][%s] %s", strings.TrimSpace(rule.Severity), strings.TrimSpace(rule.Name))
  643. body := buildOpsAlertEmailBody(rule, event)
  644. anySent := false
  645. for _, to := range emailCfg.Alert.Recipients {
  646. addr := strings.TrimSpace(to)
  647. if addr == "" {
  648. continue
  649. }
  650. if !s.emailLimiter.Allow(time.Now().UTC()) {
  651. continue
  652. }
  653. if s.emailService.notificationEmailService != nil {
  654. if err := s.emailService.notificationEmailService.Send(ctx, NotificationEmailSendInput{
  655. Event: NotificationEmailEventOpsAlert,
  656. RecipientEmail: addr,
  657. RecipientName: emailRecipientName(addr),
  658. SourceType: "ops_alert",
  659. SourceID: fmt.Sprintf("%d", event.ID),
  660. Variables: opsAlertEmailVariables(rule, event),
  661. }); err == nil {
  662. anySent = true
  663. continue
  664. } else if !shouldFallbackNotificationEmail(err) {
  665. continue
  666. }
  667. }
  668. if err := s.emailService.SendEmail(ctx, addr, subject, body); err != nil {
  669. // Ignore per-recipient failures; continue best-effort.
  670. continue
  671. }
  672. anySent = true
  673. }
  674. if anySent {
  675. _ = s.opsRepo.UpdateAlertEventEmailSent(context.Background(), event.ID, true)
  676. }
  677. return anySent
  678. }
  679. func opsAlertEmailVariables(rule *OpsAlertRule, event *OpsAlertEvent) map[string]string {
  680. variables := map[string]string{
  681. "rule_name": "-",
  682. "severity": "-",
  683. "alert_status": "-",
  684. "metric_type": "-",
  685. "operator": "-",
  686. "metric_value": "-",
  687. "threshold_value": "-",
  688. "triggered_at": time.Now().UTC().Format(time.RFC3339),
  689. "alert_description": "-",
  690. }
  691. if rule != nil {
  692. variables["rule_name"] = strings.TrimSpace(rule.Name)
  693. variables["severity"] = strings.TrimSpace(rule.Severity)
  694. variables["metric_type"] = strings.TrimSpace(rule.MetricType)
  695. variables["operator"] = strings.TrimSpace(rule.Operator)
  696. variables["threshold_value"] = fmt.Sprintf("%.2f", rule.Threshold)
  697. if strings.TrimSpace(rule.Description) != "" {
  698. variables["alert_description"] = strings.TrimSpace(rule.Description)
  699. }
  700. }
  701. if event != nil {
  702. variables["alert_status"] = strings.TrimSpace(event.Status)
  703. if event.MetricValue != nil {
  704. variables["metric_value"] = fmt.Sprintf("%.2f", *event.MetricValue)
  705. }
  706. if event.ThresholdValue != nil {
  707. variables["threshold_value"] = fmt.Sprintf("%.2f", *event.ThresholdValue)
  708. }
  709. if !event.FiredAt.IsZero() {
  710. variables["triggered_at"] = event.FiredAt.UTC().Format(time.RFC3339)
  711. }
  712. if strings.TrimSpace(event.Description) != "" {
  713. variables["alert_description"] = strings.TrimSpace(event.Description)
  714. }
  715. }
  716. return variables
  717. }
  718. func buildOpsAlertEmailBody(rule *OpsAlertRule, event *OpsAlertEvent) string {
  719. if rule == nil || event == nil {
  720. return ""
  721. }
  722. metric := strings.TrimSpace(rule.MetricType)
  723. value := "-"
  724. threshold := fmt.Sprintf("%.2f", rule.Threshold)
  725. if event.MetricValue != nil {
  726. value = fmt.Sprintf("%.2f", *event.MetricValue)
  727. }
  728. if event.ThresholdValue != nil {
  729. threshold = fmt.Sprintf("%.2f", *event.ThresholdValue)
  730. }
  731. return fmt.Sprintf(`
  732. <h2>Ops Alert</h2>
  733. <p><b>Rule</b>: %s</p>
  734. <p><b>Severity</b>: %s</p>
  735. <p><b>Status</b>: %s</p>
  736. <p><b>Metric</b>: %s %s %s</p>
  737. <p><b>Fired at</b>: %s</p>
  738. <p><b>Description</b>: %s</p>
  739. `,
  740. htmlEscape(rule.Name),
  741. htmlEscape(rule.Severity),
  742. htmlEscape(event.Status),
  743. htmlEscape(metric),
  744. htmlEscape(rule.Operator),
  745. htmlEscape(fmt.Sprintf("%s (threshold %s)", value, threshold)),
  746. event.FiredAt.Format(time.RFC3339),
  747. htmlEscape(event.Description),
  748. )
  749. }
  750. func shouldSendOpsAlertEmailByMinSeverity(minSeverity string, ruleSeverity string) bool {
  751. minSeverity = strings.ToLower(strings.TrimSpace(minSeverity))
  752. if minSeverity == "" {
  753. return true
  754. }
  755. eventLevel := opsEmailSeverityForOps(ruleSeverity)
  756. minLevel := strings.ToLower(minSeverity)
  757. rank := func(level string) int {
  758. switch level {
  759. case "critical":
  760. return 3
  761. case "warning":
  762. return 2
  763. case "info":
  764. return 1
  765. default:
  766. return 0
  767. }
  768. }
  769. return rank(eventLevel) >= rank(minLevel)
  770. }
  771. func opsEmailSeverityForOps(severity string) string {
  772. switch strings.ToUpper(strings.TrimSpace(severity)) {
  773. case "P0":
  774. return "critical"
  775. case "P1":
  776. return "warning"
  777. default:
  778. return "info"
  779. }
  780. }
  781. func isOpsAlertSilenced(now time.Time, rule *OpsAlertRule, event *OpsAlertEvent, silencing OpsAlertSilencingSettings) bool {
  782. if !silencing.Enabled {
  783. return false
  784. }
  785. if now.IsZero() {
  786. now = time.Now().UTC()
  787. }
  788. if strings.TrimSpace(silencing.GlobalUntilRFC3339) != "" {
  789. if t, err := time.Parse(time.RFC3339, strings.TrimSpace(silencing.GlobalUntilRFC3339)); err == nil {
  790. if now.Before(t) {
  791. return true
  792. }
  793. }
  794. }
  795. for _, entry := range silencing.Entries {
  796. untilRaw := strings.TrimSpace(entry.UntilRFC3339)
  797. if untilRaw == "" {
  798. continue
  799. }
  800. until, err := time.Parse(time.RFC3339, untilRaw)
  801. if err != nil {
  802. continue
  803. }
  804. if now.After(until) {
  805. continue
  806. }
  807. if entry.RuleID != nil && rule != nil && rule.ID > 0 && *entry.RuleID != rule.ID {
  808. continue
  809. }
  810. if len(entry.Severities) > 0 {
  811. match := false
  812. for _, s := range entry.Severities {
  813. if strings.EqualFold(strings.TrimSpace(s), strings.TrimSpace(event.Severity)) || strings.EqualFold(strings.TrimSpace(s), strings.TrimSpace(rule.Severity)) {
  814. match = true
  815. break
  816. }
  817. }
  818. if !match {
  819. continue
  820. }
  821. }
  822. return true
  823. }
  824. return false
  825. }
  826. func (s *OpsAlertEvaluatorService) tryAcquireLeaderLock(ctx context.Context, lock OpsDistributedLockSettings) (func(), bool) {
  827. if !lock.Enabled {
  828. return nil, true
  829. }
  830. if s.redisClient == nil {
  831. s.warnNoRedisOnce.Do(func() {
  832. logger.LegacyPrintf("service.ops_alert_evaluator", "[OpsAlertEvaluator] redis not configured; running without distributed lock")
  833. })
  834. return nil, true
  835. }
  836. key := strings.TrimSpace(lock.Key)
  837. if key == "" {
  838. key = opsAlertEvaluatorLeaderLockKey
  839. }
  840. ttl := time.Duration(lock.TTLSeconds) * time.Second
  841. if ttl <= 0 {
  842. ttl = opsAlertEvaluatorLeaderLockTTL
  843. }
  844. ok, err := s.redisClient.SetNX(ctx, key, s.instanceID, ttl).Result()
  845. if err != nil {
  846. // Prefer fail-closed to avoid duplicate evaluators stampeding the DB when Redis is flaky.
  847. // Single-node deployments can disable the distributed lock via runtime settings.
  848. s.warnNoRedisOnce.Do(func() {
  849. logger.LegacyPrintf("service.ops_alert_evaluator", "[OpsAlertEvaluator] leader lock SetNX failed; skipping this cycle: %v", err)
  850. })
  851. return nil, false
  852. }
  853. if !ok {
  854. s.maybeLogSkip(key)
  855. return nil, false
  856. }
  857. return func() {
  858. releaseCtx, releaseCancel := context.WithTimeout(context.Background(), 5*time.Second)
  859. defer releaseCancel()
  860. _, _ = opsAlertEvaluatorReleaseScript.Run(releaseCtx, s.redisClient, []string{key}, s.instanceID).Result()
  861. }, true
  862. }
  863. func (s *OpsAlertEvaluatorService) maybeLogSkip(key string) {
  864. s.skipLogMu.Lock()
  865. defer s.skipLogMu.Unlock()
  866. now := time.Now()
  867. if !s.skipLogAt.IsZero() && now.Sub(s.skipLogAt) < opsAlertEvaluatorSkipLogInterval {
  868. return
  869. }
  870. s.skipLogAt = now
  871. logger.LegacyPrintf("service.ops_alert_evaluator", "[OpsAlertEvaluator] leader lock held by another instance; skipping (key=%q)", key)
  872. }
  873. func (s *OpsAlertEvaluatorService) recordHeartbeatSuccess(runAt time.Time, duration time.Duration, result string) {
  874. if s == nil || s.opsRepo == nil {
  875. return
  876. }
  877. now := time.Now().UTC()
  878. durMs := duration.Milliseconds()
  879. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  880. defer cancel()
  881. msg := strings.TrimSpace(result)
  882. if msg == "" {
  883. msg = "ok"
  884. }
  885. msg = truncateString(msg, 2048)
  886. _ = s.opsRepo.UpsertJobHeartbeat(ctx, &OpsUpsertJobHeartbeatInput{
  887. JobName: opsAlertEvaluatorJobName,
  888. LastRunAt: &runAt,
  889. LastSuccessAt: &now,
  890. LastDurationMs: &durMs,
  891. LastResult: &msg,
  892. })
  893. }
  894. func (s *OpsAlertEvaluatorService) recordHeartbeatError(runAt time.Time, duration time.Duration, err error) {
  895. if s == nil || s.opsRepo == nil || err == nil {
  896. return
  897. }
  898. now := time.Now().UTC()
  899. durMs := duration.Milliseconds()
  900. msg := truncateString(err.Error(), 2048)
  901. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  902. defer cancel()
  903. _ = s.opsRepo.UpsertJobHeartbeat(ctx, &OpsUpsertJobHeartbeatInput{
  904. JobName: opsAlertEvaluatorJobName,
  905. LastRunAt: &runAt,
  906. LastErrorAt: &now,
  907. LastError: &msg,
  908. LastDurationMs: &durMs,
  909. })
  910. }
  911. func htmlEscape(s string) string {
  912. replacer := strings.NewReplacer(
  913. "&", "&amp;",
  914. "<", "&lt;",
  915. ">", "&gt;",
  916. `"`, "&quot;",
  917. "'", "&#39;",
  918. )
  919. return replacer.Replace(s)
  920. }
  921. type slidingWindowLimiter struct {
  922. mu sync.Mutex
  923. limit int
  924. window time.Duration
  925. sent []time.Time
  926. }
  927. func newSlidingWindowLimiter(limit int, window time.Duration) *slidingWindowLimiter {
  928. if window <= 0 {
  929. window = time.Hour
  930. }
  931. return &slidingWindowLimiter{
  932. limit: limit,
  933. window: window,
  934. sent: []time.Time{},
  935. }
  936. }
  937. func (l *slidingWindowLimiter) SetLimit(limit int) {
  938. l.mu.Lock()
  939. defer l.mu.Unlock()
  940. l.limit = limit
  941. }
  942. func (l *slidingWindowLimiter) Allow(now time.Time) bool {
  943. l.mu.Lock()
  944. defer l.mu.Unlock()
  945. if l.limit <= 0 {
  946. return true
  947. }
  948. cutoff := now.Add(-l.window)
  949. keep := l.sent[:0]
  950. for _, t := range l.sent {
  951. if t.After(cutoff) {
  952. keep = append(keep, t)
  953. }
  954. }
  955. l.sent = keep
  956. if len(l.sent) >= l.limit {
  957. return false
  958. }
  959. l.sent = append(l.sent, now)
  960. return true
  961. }
  962. // computeGroupAvailableRatio returns the available percentage for a group.
  963. // Formula: (AvailableCount / TotalAccounts) * 100.
  964. // Returns 0 when TotalAccounts is 0.
  965. func computeGroupAvailableRatio(group *GroupAvailability) float64 {
  966. if group == nil || group.TotalAccounts <= 0 {
  967. return 0
  968. }
  969. return (float64(group.AvailableCount) / float64(group.TotalAccounts)) * 100
  970. }
  971. // countAccountsByCondition counts accounts that satisfy the given condition.
  972. func countAccountsByCondition(accounts map[int64]*AccountAvailability, condition func(*AccountAvailability) bool) int64 {
  973. if len(accounts) == 0 || condition == nil {
  974. return 0
  975. }
  976. var count int64
  977. for _, account := range accounts {
  978. if account != nil && condition(account) {
  979. count++
  980. }
  981. }
  982. return count
  983. }