ops_scheduled_report_service.go 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926
  1. package service
  2. import (
  3. "context"
  4. "fmt"
  5. "log"
  6. "strconv"
  7. "strings"
  8. "sync"
  9. "time"
  10. "github.com/Wei-Shaw/sub2api/internal/config"
  11. "github.com/google/uuid"
  12. "github.com/redis/go-redis/v9"
  13. "github.com/robfig/cron/v3"
  14. )
  15. const (
  16. opsScheduledReportJobName = "ops_scheduled_reports"
  17. opsScheduledReportLeaderLockKeyDefault = "ops:scheduled_reports:leader"
  18. opsScheduledReportLeaderLockTTLDefault = 5 * time.Minute
  19. opsScheduledReportLastRunKeyPrefix = "ops:scheduled_reports:last_run:"
  20. opsScheduledReportTickInterval = 1 * time.Minute
  21. )
  22. var opsScheduledReportCronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
  23. var opsScheduledReportReleaseScript = redis.NewScript(`
  24. if redis.call("GET", KEYS[1]) == ARGV[1] then
  25. return redis.call("DEL", KEYS[1])
  26. end
  27. return 0
  28. `)
  29. type OpsScheduledReportService struct {
  30. opsService *OpsService
  31. userService *UserService
  32. emailService *EmailService
  33. redisClient *redis.Client
  34. cfg *config.Config
  35. instanceID string
  36. loc *time.Location
  37. distributedLockOn bool
  38. warnNoRedisOnce sync.Once
  39. startOnce sync.Once
  40. stopOnce sync.Once
  41. stopCtx context.Context
  42. stop context.CancelFunc
  43. wg sync.WaitGroup
  44. }
  45. func NewOpsScheduledReportService(
  46. opsService *OpsService,
  47. userService *UserService,
  48. emailService *EmailService,
  49. redisClient *redis.Client,
  50. cfg *config.Config,
  51. ) *OpsScheduledReportService {
  52. lockOn := cfg == nil || strings.TrimSpace(cfg.RunMode) != config.RunModeSimple
  53. loc := time.Local
  54. if cfg != nil && strings.TrimSpace(cfg.Timezone) != "" {
  55. if parsed, err := time.LoadLocation(strings.TrimSpace(cfg.Timezone)); err == nil && parsed != nil {
  56. loc = parsed
  57. }
  58. }
  59. return &OpsScheduledReportService{
  60. opsService: opsService,
  61. userService: userService,
  62. emailService: emailService,
  63. redisClient: redisClient,
  64. cfg: cfg,
  65. instanceID: uuid.NewString(),
  66. loc: loc,
  67. distributedLockOn: lockOn,
  68. warnNoRedisOnce: sync.Once{},
  69. startOnce: sync.Once{},
  70. stopOnce: sync.Once{},
  71. stopCtx: nil,
  72. stop: nil,
  73. wg: sync.WaitGroup{},
  74. }
  75. }
  76. func (s *OpsScheduledReportService) Start() {
  77. s.StartWithContext(context.Background())
  78. }
  79. func (s *OpsScheduledReportService) StartWithContext(ctx context.Context) {
  80. if s == nil {
  81. return
  82. }
  83. if ctx == nil {
  84. ctx = context.Background()
  85. }
  86. if s.cfg != nil && !s.cfg.Ops.Enabled {
  87. return
  88. }
  89. if s.opsService == nil || s.emailService == nil {
  90. return
  91. }
  92. s.startOnce.Do(func() {
  93. s.stopCtx, s.stop = context.WithCancel(ctx)
  94. s.wg.Add(1)
  95. go s.run()
  96. })
  97. }
  98. func (s *OpsScheduledReportService) Stop() {
  99. if s == nil {
  100. return
  101. }
  102. s.stopOnce.Do(func() {
  103. if s.stop != nil {
  104. s.stop()
  105. }
  106. })
  107. s.wg.Wait()
  108. }
  109. func (s *OpsScheduledReportService) run() {
  110. defer s.wg.Done()
  111. ticker := time.NewTicker(opsScheduledReportTickInterval)
  112. defer ticker.Stop()
  113. s.runOnce()
  114. for {
  115. select {
  116. case <-ticker.C:
  117. s.runOnce()
  118. case <-s.stopCtx.Done():
  119. return
  120. }
  121. }
  122. }
  123. func (s *OpsScheduledReportService) runOnce() {
  124. if s == nil || s.opsService == nil || s.emailService == nil {
  125. return
  126. }
  127. startedAt := time.Now().UTC()
  128. runAt := startedAt
  129. ctx, cancel := context.WithTimeout(s.stopCtx, 60*time.Second)
  130. defer cancel()
  131. // Respect ops monitoring enabled switch.
  132. if !s.opsService.IsMonitoringEnabled(ctx) {
  133. return
  134. }
  135. release, ok := s.tryAcquireLeaderLock(ctx)
  136. if !ok {
  137. return
  138. }
  139. if release != nil {
  140. defer release()
  141. }
  142. now := time.Now()
  143. if s.loc != nil {
  144. now = now.In(s.loc)
  145. }
  146. reports := s.listScheduledReports(ctx, now)
  147. if len(reports) == 0 {
  148. return
  149. }
  150. reportsTotal := len(reports)
  151. reportsDue := 0
  152. sentAttempts := 0
  153. for _, report := range reports {
  154. if report == nil || !report.Enabled {
  155. continue
  156. }
  157. if report.NextRunAt.After(now) {
  158. continue
  159. }
  160. reportsDue++
  161. attempts, err := s.runReport(ctx, report, now)
  162. if err != nil {
  163. s.recordHeartbeatError(runAt, time.Since(startedAt), err)
  164. return
  165. }
  166. sentAttempts += attempts
  167. }
  168. result := truncateString(fmt.Sprintf("reports=%d due=%d send_attempts=%d", reportsTotal, reportsDue, sentAttempts), 2048)
  169. s.recordHeartbeatSuccess(runAt, time.Since(startedAt), result)
  170. }
  171. type opsScheduledReport struct {
  172. Name string
  173. ReportType string
  174. Schedule string
  175. Enabled bool
  176. TimeRange time.Duration
  177. Recipients []string
  178. ErrorDigestMinCount int
  179. AccountHealthErrorRateThreshold float64
  180. LastRunAt *time.Time
  181. NextRunAt time.Time
  182. }
  183. type opsScheduledReportContent struct {
  184. html string
  185. overview *OpsDashboardOverview
  186. }
  187. func (s *OpsScheduledReportService) listScheduledReports(ctx context.Context, now time.Time) []*opsScheduledReport {
  188. if s == nil || s.opsService == nil {
  189. return nil
  190. }
  191. if ctx == nil {
  192. ctx = context.Background()
  193. }
  194. emailCfg, err := s.opsService.GetEmailNotificationConfig(ctx)
  195. if err != nil || emailCfg == nil {
  196. return nil
  197. }
  198. if !emailCfg.Report.Enabled {
  199. return nil
  200. }
  201. recipients := normalizeEmails(emailCfg.Report.Recipients)
  202. type reportDef struct {
  203. enabled bool
  204. name string
  205. kind string
  206. timeRange time.Duration
  207. schedule string
  208. }
  209. defs := []reportDef{
  210. {enabled: emailCfg.Report.DailySummaryEnabled, name: "日报", kind: "daily_summary", timeRange: 24 * time.Hour, schedule: emailCfg.Report.DailySummarySchedule},
  211. {enabled: emailCfg.Report.WeeklySummaryEnabled, name: "周报", kind: "weekly_summary", timeRange: 7 * 24 * time.Hour, schedule: emailCfg.Report.WeeklySummarySchedule},
  212. {enabled: emailCfg.Report.ErrorDigestEnabled, name: "错误摘要", kind: "error_digest", timeRange: 24 * time.Hour, schedule: emailCfg.Report.ErrorDigestSchedule},
  213. {enabled: emailCfg.Report.AccountHealthEnabled, name: "账号健康", kind: "account_health", timeRange: 24 * time.Hour, schedule: emailCfg.Report.AccountHealthSchedule},
  214. }
  215. out := make([]*opsScheduledReport, 0, len(defs))
  216. for _, d := range defs {
  217. if !d.enabled {
  218. continue
  219. }
  220. spec := strings.TrimSpace(d.schedule)
  221. if spec == "" {
  222. continue
  223. }
  224. sched, err := opsScheduledReportCronParser.Parse(spec)
  225. if err != nil {
  226. log.Printf("[OpsScheduledReport] invalid cron spec=%q for report=%s: %v", spec, d.kind, err)
  227. continue
  228. }
  229. lastRun := s.getLastRunAt(ctx, d.kind)
  230. base := lastRun
  231. if base.IsZero() {
  232. // Allow a schedule matching the current minute to trigger right after startup.
  233. base = now.Add(-1 * time.Minute)
  234. }
  235. next := sched.Next(base)
  236. if next.IsZero() {
  237. continue
  238. }
  239. var lastRunPtr *time.Time
  240. if !lastRun.IsZero() {
  241. lastCopy := lastRun
  242. lastRunPtr = &lastCopy
  243. }
  244. out = append(out, &opsScheduledReport{
  245. Name: d.name,
  246. ReportType: d.kind,
  247. Schedule: spec,
  248. Enabled: true,
  249. TimeRange: d.timeRange,
  250. Recipients: recipients,
  251. ErrorDigestMinCount: emailCfg.Report.ErrorDigestMinCount,
  252. AccountHealthErrorRateThreshold: emailCfg.Report.AccountHealthErrorRateThreshold,
  253. LastRunAt: lastRunPtr,
  254. NextRunAt: next,
  255. })
  256. }
  257. return out
  258. }
  259. func (s *OpsScheduledReportService) runReport(ctx context.Context, report *opsScheduledReport, now time.Time) (int, error) {
  260. if s == nil || s.opsService == nil || s.emailService == nil || report == nil {
  261. return 0, nil
  262. }
  263. if ctx == nil {
  264. ctx = context.Background()
  265. }
  266. // Mark as "run" up-front so a broken SMTP config doesn't spam retries every minute.
  267. s.setLastRunAt(ctx, report.ReportType, now)
  268. content, err := s.generateReportContent(ctx, report, now)
  269. if err != nil {
  270. return 0, err
  271. }
  272. if strings.TrimSpace(content.html) == "" {
  273. // Skip sending when the report decides not to emit content (e.g., digest below min count).
  274. return 0, nil
  275. }
  276. recipients := report.Recipients
  277. if len(recipients) == 0 && s.userService != nil {
  278. admin, err := s.userService.GetFirstAdmin(ctx)
  279. if err == nil && admin != nil && strings.TrimSpace(admin.Email) != "" {
  280. recipients = []string{strings.TrimSpace(admin.Email)}
  281. }
  282. }
  283. if len(recipients) == 0 {
  284. return 0, nil
  285. }
  286. attempts := 0
  287. for _, to := range recipients {
  288. addr := strings.TrimSpace(to)
  289. if addr == "" {
  290. continue
  291. }
  292. attempts++
  293. locale := ""
  294. if s.emailService.notificationEmailService != nil {
  295. locale = s.emailService.notificationEmailService.ResolveRecipientLocale(ctx, 0, addr)
  296. templateVariables := opsScheduledReportLocalizedEmailVariables(report, now, locale)
  297. rawHTMLVariables := map[string]string{"report_html": content.html}
  298. if isOpsSummaryReport(report) {
  299. templateVariables = opsSummaryReportEmailVariables(report, now, content.overview, locale)
  300. }
  301. if err := s.emailService.notificationEmailService.Send(ctx, NotificationEmailSendInput{
  302. Event: NotificationEmailEventOpsScheduledReport,
  303. Locale: locale,
  304. RecipientEmail: addr,
  305. RecipientName: emailRecipientName(addr),
  306. SourceType: "ops_scheduled_report",
  307. SourceID: opsScheduledReportDeliverySourceID(report),
  308. ReminderKey: now.UTC().Format("2006-01-02T15:04"),
  309. Variables: templateVariables,
  310. RawHTMLVariables: rawHTMLVariables,
  311. }); err == nil {
  312. continue
  313. } else if !shouldFallbackNotificationEmail(err) {
  314. continue
  315. }
  316. }
  317. subjectName := strings.TrimSpace(report.Name)
  318. if locale != "" {
  319. subjectName = opsScheduledReportLocalizedName(report, locale)
  320. }
  321. subjectPrefix := "[Ops Report]"
  322. if strings.HasPrefix(strings.ToLower(locale), "zh") {
  323. subjectPrefix = "[运维报表]"
  324. }
  325. subject := fmt.Sprintf("%s %s", subjectPrefix, subjectName)
  326. if err := s.emailService.SendEmail(ctx, addr, subject, content.html); err != nil {
  327. // Ignore per-recipient failures; continue best-effort.
  328. continue
  329. }
  330. }
  331. return attempts, nil
  332. }
  333. func opsScheduledReportDeliverySourceID(report *opsScheduledReport) string {
  334. if report == nil {
  335. return "scheduled_report"
  336. }
  337. parts := []string{
  338. strings.TrimSpace(report.ReportType),
  339. strings.TrimSpace(report.Name),
  340. strings.TrimSpace(report.Schedule),
  341. }
  342. joined := strings.Trim(strings.Join(parts, ":"), ":")
  343. if joined == "" {
  344. return "scheduled_report"
  345. }
  346. return joined
  347. }
  348. func opsScheduledReportEmailVariables(report *opsScheduledReport, now time.Time) map[string]string {
  349. end := now.UTC()
  350. start := end
  351. name := "Ops report"
  352. reportType := "scheduled_report"
  353. if report != nil {
  354. if strings.TrimSpace(report.Name) != "" {
  355. name = strings.TrimSpace(report.Name)
  356. }
  357. if strings.TrimSpace(report.ReportType) != "" {
  358. reportType = strings.TrimSpace(report.ReportType)
  359. }
  360. if report.TimeRange > 0 {
  361. start = end.Add(-report.TimeRange)
  362. }
  363. }
  364. return map[string]string{
  365. "report_name": name,
  366. "report_type": reportType,
  367. "report_start_time": start.Format(time.RFC3339),
  368. "report_end_time": end.Format(time.RFC3339),
  369. }
  370. }
  371. func opsScheduledReportLocalizedEmailVariables(report *opsScheduledReport, now time.Time, locale string) map[string]string {
  372. variables := opsScheduledReportEmailVariables(report, now)
  373. variables["report_html"] = ""
  374. variables["report_detail_display"] = "block"
  375. for _, placeholder := range notificationEmailOpsSummaryPlaceholders {
  376. variables[placeholder] = "-"
  377. }
  378. variables["report_summary_display"] = "none"
  379. if name := opsScheduledReportLocalizedName(report, locale); name != "" {
  380. variables["report_name"] = name
  381. }
  382. return variables
  383. }
  384. func opsScheduledReportLocalizedName(report *opsScheduledReport, locale string) string {
  385. if report == nil {
  386. return "Ops report"
  387. }
  388. chinese := strings.HasPrefix(strings.ToLower(strings.TrimSpace(locale)), "zh")
  389. switch strings.TrimSpace(report.ReportType) {
  390. case "daily_summary":
  391. if chinese {
  392. return "日报"
  393. }
  394. return "Daily summary"
  395. case "weekly_summary":
  396. if chinese {
  397. return "周报"
  398. }
  399. return "Weekly summary"
  400. case "error_digest":
  401. if chinese {
  402. return "错误摘要"
  403. }
  404. return "Error digest"
  405. case "account_health":
  406. if chinese {
  407. return "账号健康"
  408. }
  409. return "Account health"
  410. default:
  411. return strings.TrimSpace(report.Name)
  412. }
  413. }
  414. func isOpsSummaryReport(report *opsScheduledReport) bool {
  415. if report == nil {
  416. return false
  417. }
  418. switch strings.TrimSpace(report.ReportType) {
  419. case "daily_summary", "weekly_summary":
  420. return true
  421. default:
  422. return false
  423. }
  424. }
  425. func opsSummaryReportEmailVariables(report *opsScheduledReport, now time.Time, overview *OpsDashboardOverview, locale string) map[string]string {
  426. variables := opsScheduledReportLocalizedEmailVariables(report, now, locale)
  427. variables["report_detail_display"] = "none"
  428. if overview == nil {
  429. for _, placeholder := range notificationEmailOpsSummaryPlaceholders {
  430. if placeholder == "report_summary_display" {
  431. continue
  432. }
  433. variables[placeholder] = "-"
  434. }
  435. variables["report_summary_display"] = "block"
  436. return variables
  437. }
  438. variables["report_summary_display"] = "block"
  439. variables["report_total_requests"] = formatOpsReportInteger(overview.RequestCountTotal)
  440. variables["report_success_count"] = formatOpsReportInteger(overview.SuccessCount)
  441. variables["report_sla_error_count"] = formatOpsReportInteger(overview.ErrorCountSLA)
  442. variables["report_business_limited_count"] = formatOpsReportInteger(overview.BusinessLimitedCount)
  443. variables["report_sla"] = fmt.Sprintf("%.2f%%", overview.SLA*100)
  444. variables["report_error_rate"] = fmt.Sprintf("%.2f%%", overview.ErrorRate*100)
  445. variables["report_upstream_error_rate"] = fmt.Sprintf("%.2f%%", overview.UpstreamErrorRate*100)
  446. variables["report_upstream_error_count_excl_429_529"] = formatOpsReportInteger(overview.UpstreamErrorCountExcl429529)
  447. variables["report_upstream_429_count"] = formatOpsReportInteger(overview.Upstream429Count)
  448. variables["report_upstream_529_count"] = formatOpsReportInteger(overview.Upstream529Count)
  449. variables["report_latency_p50"] = formatOpsReportMilliseconds(overview.Duration.P50)
  450. variables["report_latency_p99"] = formatOpsReportMilliseconds(overview.Duration.P99)
  451. variables["report_ttft_p50"] = formatOpsReportMilliseconds(overview.TTFT.P50)
  452. variables["report_ttft_p99"] = formatOpsReportMilliseconds(overview.TTFT.P99)
  453. variables["report_tokens"] = formatOpsReportInteger(overview.TokenConsumed)
  454. variables["report_qps_current"] = fmt.Sprintf("%.1f", overview.QPS.Current)
  455. variables["report_qps_peak"] = fmt.Sprintf("%.1f", overview.QPS.Peak)
  456. variables["report_qps_avg"] = fmt.Sprintf("%.1f", overview.QPS.Avg)
  457. variables["report_tps_current"] = fmt.Sprintf("%.1f", overview.TPS.Current)
  458. variables["report_tps_peak"] = fmt.Sprintf("%.1f", overview.TPS.Peak)
  459. variables["report_tps_avg"] = fmt.Sprintf("%.1f", overview.TPS.Avg)
  460. return variables
  461. }
  462. func formatOpsReportInteger(value int64) string {
  463. raw := strconv.FormatInt(value, 10)
  464. start := 0
  465. if strings.HasPrefix(raw, "-") {
  466. start = 1
  467. }
  468. if len(raw)-start <= 3 {
  469. return raw
  470. }
  471. var builder strings.Builder
  472. builder.Grow(len(raw) + (len(raw)-start-1)/3)
  473. _, _ = builder.WriteString(raw[:start])
  474. digitLen := len(raw) - start
  475. for offset := 0; offset < digitLen; offset++ {
  476. if offset > 0 && (digitLen-offset)%3 == 0 {
  477. _ = builder.WriteByte(',')
  478. }
  479. _ = builder.WriteByte(raw[start+offset])
  480. }
  481. return builder.String()
  482. }
  483. func formatOpsReportMilliseconds(value *int) string {
  484. if value == nil {
  485. return "-"
  486. }
  487. return fmt.Sprintf("%s ms", formatOpsReportInteger(int64(*value)))
  488. }
  489. func (s *OpsScheduledReportService) generateReportContent(ctx context.Context, report *opsScheduledReport, now time.Time) (opsScheduledReportContent, error) {
  490. if s == nil || s.opsService == nil || report == nil {
  491. return opsScheduledReportContent{}, fmt.Errorf("service not initialized")
  492. }
  493. if report.TimeRange <= 0 {
  494. return opsScheduledReportContent{}, fmt.Errorf("invalid time range")
  495. }
  496. end := now.UTC()
  497. start := end.Add(-report.TimeRange)
  498. switch strings.TrimSpace(report.ReportType) {
  499. case "daily_summary", "weekly_summary":
  500. overview, err := s.opsService.GetDashboardOverview(ctx, &OpsDashboardFilter{
  501. StartTime: start,
  502. EndTime: end,
  503. Platform: "",
  504. GroupID: nil,
  505. QueryMode: OpsQueryModeAuto,
  506. })
  507. if err != nil {
  508. // If pre-aggregation isn't ready but the report is requested, fall back to raw.
  509. if strings.TrimSpace(report.ReportType) == "daily_summary" || strings.TrimSpace(report.ReportType) == "weekly_summary" {
  510. overview, err = s.opsService.GetDashboardOverview(ctx, &OpsDashboardFilter{
  511. StartTime: start,
  512. EndTime: end,
  513. Platform: "",
  514. GroupID: nil,
  515. QueryMode: OpsQueryModeRaw,
  516. })
  517. }
  518. if err != nil {
  519. return opsScheduledReportContent{}, err
  520. }
  521. }
  522. return opsScheduledReportContent{
  523. html: buildOpsSummaryEmailHTML(report.Name, start, end, overview),
  524. overview: overview,
  525. }, nil
  526. case "error_digest":
  527. // Lightweight digest: list recent errors (status>=400) and breakdown by type.
  528. startTime := start
  529. endTime := end
  530. filter := &OpsErrorLogFilter{
  531. StartTime: &startTime,
  532. EndTime: &endTime,
  533. Page: 1,
  534. PageSize: 100,
  535. }
  536. out, err := s.opsService.GetErrorLogs(ctx, filter)
  537. if err != nil {
  538. return opsScheduledReportContent{}, err
  539. }
  540. if report.ErrorDigestMinCount > 0 && out != nil && out.Total < report.ErrorDigestMinCount {
  541. return opsScheduledReportContent{}, nil
  542. }
  543. return opsScheduledReportContent{html: buildOpsErrorDigestEmailHTML(report.Name, start, end, out)}, nil
  544. case "account_health":
  545. // Best-effort: use account availability (not error rate yet).
  546. avail, err := s.opsService.GetAccountAvailability(ctx, "", nil)
  547. if err != nil {
  548. return opsScheduledReportContent{}, err
  549. }
  550. _ = report.AccountHealthErrorRateThreshold // reserved for future per-account error rate report
  551. return opsScheduledReportContent{html: buildOpsAccountHealthEmailHTML(report.Name, start, end, avail)}, nil
  552. default:
  553. return opsScheduledReportContent{}, fmt.Errorf("unknown report type: %s", report.ReportType)
  554. }
  555. }
  556. func buildOpsSummaryEmailHTML(title string, start, end time.Time, overview *OpsDashboardOverview) string {
  557. if overview == nil {
  558. return fmt.Sprintf("<h2>%s</h2><p>No data.</p>", htmlEscape(title))
  559. }
  560. latP50 := "-"
  561. latP99 := "-"
  562. if overview.Duration.P50 != nil {
  563. latP50 = fmt.Sprintf("%dms", *overview.Duration.P50)
  564. }
  565. if overview.Duration.P99 != nil {
  566. latP99 = fmt.Sprintf("%dms", *overview.Duration.P99)
  567. }
  568. ttftP50 := "-"
  569. ttftP99 := "-"
  570. if overview.TTFT.P50 != nil {
  571. ttftP50 = fmt.Sprintf("%dms", *overview.TTFT.P50)
  572. }
  573. if overview.TTFT.P99 != nil {
  574. ttftP99 = fmt.Sprintf("%dms", *overview.TTFT.P99)
  575. }
  576. return fmt.Sprintf(`
  577. <h2>%s</h2>
  578. <p><b>Period</b>: %s ~ %s (UTC)</p>
  579. <ul>
  580. <li><b>Total Requests</b>: %d</li>
  581. <li><b>Success</b>: %d</li>
  582. <li><b>Errors (SLA)</b>: %d</li>
  583. <li><b>Business Limited</b>: %d</li>
  584. <li><b>SLA</b>: %.2f%%</li>
  585. <li><b>Error Rate</b>: %.2f%%</li>
  586. <li><b>Upstream Error Rate (excl 429/529)</b>: %.2f%%</li>
  587. <li><b>Upstream Errors</b>: excl429/529=%d, 429=%d, 529=%d</li>
  588. <li><b>Latency</b>: p50=%s, p99=%s</li>
  589. <li><b>TTFT</b>: p50=%s, p99=%s</li>
  590. <li><b>Tokens</b>: %d</li>
  591. <li><b>QPS</b>: current=%.1f, peak=%.1f, avg=%.1f</li>
  592. <li><b>TPS</b>: current=%.1f, peak=%.1f, avg=%.1f</li>
  593. </ul>
  594. `,
  595. htmlEscape(strings.TrimSpace(title)),
  596. htmlEscape(start.UTC().Format(time.RFC3339)),
  597. htmlEscape(end.UTC().Format(time.RFC3339)),
  598. overview.RequestCountTotal,
  599. overview.SuccessCount,
  600. overview.ErrorCountSLA,
  601. overview.BusinessLimitedCount,
  602. overview.SLA*100,
  603. overview.ErrorRate*100,
  604. overview.UpstreamErrorRate*100,
  605. overview.UpstreamErrorCountExcl429529,
  606. overview.Upstream429Count,
  607. overview.Upstream529Count,
  608. htmlEscape(latP50),
  609. htmlEscape(latP99),
  610. htmlEscape(ttftP50),
  611. htmlEscape(ttftP99),
  612. overview.TokenConsumed,
  613. overview.QPS.Current,
  614. overview.QPS.Peak,
  615. overview.QPS.Avg,
  616. overview.TPS.Current,
  617. overview.TPS.Peak,
  618. overview.TPS.Avg,
  619. )
  620. }
  621. func buildOpsErrorDigestEmailHTML(title string, start, end time.Time, list *OpsErrorLogList) string {
  622. total := 0
  623. recent := []*OpsErrorLog{}
  624. if list != nil {
  625. total = list.Total
  626. recent = list.Errors
  627. }
  628. if len(recent) > 10 {
  629. recent = recent[:10]
  630. }
  631. rows := ""
  632. for _, item := range recent {
  633. if item == nil {
  634. continue
  635. }
  636. rows += fmt.Sprintf(
  637. "<tr><td>%s</td><td>%s</td><td>%d</td><td>%s</td></tr>",
  638. htmlEscape(item.CreatedAt.UTC().Format(time.RFC3339)),
  639. htmlEscape(item.Platform),
  640. item.StatusCode,
  641. htmlEscape(truncateString(item.Message, 180)),
  642. )
  643. }
  644. if rows == "" {
  645. rows = "<tr><td colspan=\"4\">No recent errors.</td></tr>"
  646. }
  647. return fmt.Sprintf(`
  648. <h2>%s</h2>
  649. <p><b>Period</b>: %s ~ %s (UTC)</p>
  650. <p><b>Total Errors</b>: %d</p>
  651. <h3>Recent</h3>
  652. <table border="1" cellpadding="6" cellspacing="0" style="border-collapse:collapse;">
  653. <thead><tr><th>Time</th><th>Platform</th><th>Status</th><th>Message</th></tr></thead>
  654. <tbody>%s</tbody>
  655. </table>
  656. `,
  657. htmlEscape(strings.TrimSpace(title)),
  658. htmlEscape(start.UTC().Format(time.RFC3339)),
  659. htmlEscape(end.UTC().Format(time.RFC3339)),
  660. total,
  661. rows,
  662. )
  663. }
  664. func buildOpsAccountHealthEmailHTML(title string, start, end time.Time, avail *OpsAccountAvailability) string {
  665. total := 0
  666. available := 0
  667. rateLimited := 0
  668. hasError := 0
  669. if avail != nil && avail.Accounts != nil {
  670. for _, a := range avail.Accounts {
  671. if a == nil {
  672. continue
  673. }
  674. total++
  675. if a.IsAvailable {
  676. available++
  677. }
  678. if a.IsRateLimited {
  679. rateLimited++
  680. }
  681. if a.HasError {
  682. hasError++
  683. }
  684. }
  685. }
  686. return fmt.Sprintf(`
  687. <h2>%s</h2>
  688. <p><b>Period</b>: %s ~ %s (UTC)</p>
  689. <ul>
  690. <li><b>Total Accounts</b>: %d</li>
  691. <li><b>Available</b>: %d</li>
  692. <li><b>Rate Limited</b>: %d</li>
  693. <li><b>Error</b>: %d</li>
  694. </ul>
  695. <p>Note: This report currently reflects account availability status only.</p>
  696. `,
  697. htmlEscape(strings.TrimSpace(title)),
  698. htmlEscape(start.UTC().Format(time.RFC3339)),
  699. htmlEscape(end.UTC().Format(time.RFC3339)),
  700. total,
  701. available,
  702. rateLimited,
  703. hasError,
  704. )
  705. }
  706. func (s *OpsScheduledReportService) tryAcquireLeaderLock(ctx context.Context) (func(), bool) {
  707. if s == nil || !s.distributedLockOn {
  708. return nil, true
  709. }
  710. if s.redisClient == nil {
  711. s.warnNoRedisOnce.Do(func() {
  712. log.Printf("[OpsScheduledReport] redis not configured; running without distributed lock")
  713. })
  714. return nil, true
  715. }
  716. if ctx == nil {
  717. ctx = context.Background()
  718. }
  719. key := opsScheduledReportLeaderLockKeyDefault
  720. ttl := opsScheduledReportLeaderLockTTLDefault
  721. if strings.TrimSpace(key) == "" {
  722. key = "ops:scheduled_reports:leader"
  723. }
  724. if ttl <= 0 {
  725. ttl = 5 * time.Minute
  726. }
  727. ok, err := s.redisClient.SetNX(ctx, key, s.instanceID, ttl).Result()
  728. if err != nil {
  729. // Prefer fail-closed to avoid duplicate report sends when Redis is flaky.
  730. log.Printf("[OpsScheduledReport] leader lock SetNX failed; skipping this cycle: %v", err)
  731. return nil, false
  732. }
  733. if !ok {
  734. return nil, false
  735. }
  736. return func() {
  737. _, _ = opsScheduledReportReleaseScript.Run(ctx, s.redisClient, []string{key}, s.instanceID).Result()
  738. }, true
  739. }
  740. func (s *OpsScheduledReportService) getLastRunAt(ctx context.Context, reportType string) time.Time {
  741. if s == nil || s.redisClient == nil {
  742. return time.Time{}
  743. }
  744. kind := strings.TrimSpace(reportType)
  745. if kind == "" {
  746. return time.Time{}
  747. }
  748. key := opsScheduledReportLastRunKeyPrefix + kind
  749. raw, err := s.redisClient.Get(ctx, key).Result()
  750. if err != nil || strings.TrimSpace(raw) == "" {
  751. return time.Time{}
  752. }
  753. sec, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
  754. if err != nil || sec <= 0 {
  755. return time.Time{}
  756. }
  757. last := time.Unix(sec, 0)
  758. // Cron schedules are interpreted in the configured timezone (s.loc). Ensure the base time
  759. // passed into cron.Next() uses the same location; otherwise the job will drift by timezone
  760. // offset (e.g. Asia/Shanghai default would run 8h later after the first execution).
  761. if s.loc != nil {
  762. return last.In(s.loc)
  763. }
  764. return last.UTC()
  765. }
  766. func (s *OpsScheduledReportService) setLastRunAt(ctx context.Context, reportType string, t time.Time) {
  767. if s == nil || s.redisClient == nil {
  768. return
  769. }
  770. kind := strings.TrimSpace(reportType)
  771. if kind == "" {
  772. return
  773. }
  774. if t.IsZero() {
  775. t = time.Now().UTC()
  776. }
  777. key := opsScheduledReportLastRunKeyPrefix + kind
  778. _ = s.redisClient.Set(ctx, key, strconv.FormatInt(t.UTC().Unix(), 10), 14*24*time.Hour).Err()
  779. }
  780. func (s *OpsScheduledReportService) recordHeartbeatSuccess(runAt time.Time, duration time.Duration, result string) {
  781. if s == nil || s.opsService == nil || s.opsService.opsRepo == nil {
  782. return
  783. }
  784. now := time.Now().UTC()
  785. durMs := duration.Milliseconds()
  786. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  787. defer cancel()
  788. msg := strings.TrimSpace(result)
  789. if msg == "" {
  790. msg = "ok"
  791. }
  792. msg = truncateString(msg, 2048)
  793. _ = s.opsService.opsRepo.UpsertJobHeartbeat(ctx, &OpsUpsertJobHeartbeatInput{
  794. JobName: opsScheduledReportJobName,
  795. LastRunAt: &runAt,
  796. LastSuccessAt: &now,
  797. LastDurationMs: &durMs,
  798. LastResult: &msg,
  799. })
  800. }
  801. func (s *OpsScheduledReportService) recordHeartbeatError(runAt time.Time, duration time.Duration, err error) {
  802. if s == nil || s.opsService == nil || s.opsService.opsRepo == nil || err == nil {
  803. return
  804. }
  805. now := time.Now().UTC()
  806. durMs := duration.Milliseconds()
  807. msg := truncateString(err.Error(), 2048)
  808. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  809. defer cancel()
  810. _ = s.opsService.opsRepo.UpsertJobHeartbeat(ctx, &OpsUpsertJobHeartbeatInput{
  811. JobName: opsScheduledReportJobName,
  812. LastRunAt: &runAt,
  813. LastErrorAt: &now,
  814. LastError: &msg,
  815. LastDurationMs: &durMs,
  816. })
  817. }
  818. func normalizeEmails(in []string) []string {
  819. if len(in) == 0 {
  820. return nil
  821. }
  822. seen := make(map[string]struct{}, len(in))
  823. out := make([]string, 0, len(in))
  824. for _, raw := range in {
  825. addr := strings.ToLower(strings.TrimSpace(raw))
  826. if addr == "" {
  827. continue
  828. }
  829. if _, ok := seen[addr]; ok {
  830. continue
  831. }
  832. seen[addr] = struct{}{}
  833. out = append(out, addr)
  834. }
  835. return out
  836. }