ops_settings.go 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "strings"
  7. "time"
  8. "github.com/Wei-Shaw/sub2api/internal/config"
  9. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  10. )
  11. const (
  12. opsAlertEvaluatorLeaderLockKeyDefault = "ops:alert:evaluator:leader"
  13. opsAlertEvaluatorLeaderLockTTLDefault = 30 * time.Second
  14. )
  15. // =========================
  16. // Email notification config
  17. // =========================
  18. func (s *OpsService) GetEmailNotificationConfig(ctx context.Context) (*OpsEmailNotificationConfig, error) {
  19. defaultCfg := defaultOpsEmailNotificationConfig()
  20. if s == nil || s.settingRepo == nil {
  21. return defaultCfg, nil
  22. }
  23. if ctx == nil {
  24. ctx = context.Background()
  25. }
  26. raw, err := s.settingRepo.GetValue(ctx, SettingKeyOpsEmailNotificationConfig)
  27. if err != nil {
  28. if errors.Is(err, ErrSettingNotFound) {
  29. // Initialize defaults on first read (best-effort).
  30. if b, mErr := json.Marshal(defaultCfg); mErr == nil {
  31. _ = s.settingRepo.Set(ctx, SettingKeyOpsEmailNotificationConfig, string(b))
  32. }
  33. return defaultCfg, nil
  34. }
  35. return nil, err
  36. }
  37. cfg := &OpsEmailNotificationConfig{}
  38. if err := json.Unmarshal([]byte(raw), cfg); err != nil {
  39. // Corrupted JSON should not break ops UI; fall back to defaults.
  40. return defaultCfg, nil
  41. }
  42. normalizeOpsEmailNotificationConfig(cfg)
  43. return cfg, nil
  44. }
  45. func (s *OpsService) UpdateEmailNotificationConfig(ctx context.Context, req *OpsEmailNotificationConfigUpdateRequest) (*OpsEmailNotificationConfig, error) {
  46. if s == nil || s.settingRepo == nil {
  47. return nil, errors.New("setting repository not initialized")
  48. }
  49. if ctx == nil {
  50. ctx = context.Background()
  51. }
  52. if req == nil {
  53. return nil, errors.New("invalid request")
  54. }
  55. cfg, err := s.GetEmailNotificationConfig(ctx)
  56. if err != nil {
  57. return nil, err
  58. }
  59. if req.Alert != nil {
  60. cfg.Alert.Enabled = req.Alert.Enabled
  61. if req.Alert.Recipients != nil {
  62. cfg.Alert.Recipients = req.Alert.Recipients
  63. }
  64. cfg.Alert.MinSeverity = strings.TrimSpace(req.Alert.MinSeverity)
  65. cfg.Alert.RateLimitPerHour = req.Alert.RateLimitPerHour
  66. cfg.Alert.BatchingWindowSeconds = req.Alert.BatchingWindowSeconds
  67. cfg.Alert.IncludeResolvedAlerts = req.Alert.IncludeResolvedAlerts
  68. }
  69. if req.Report != nil {
  70. cfg.Report.Enabled = req.Report.Enabled
  71. if req.Report.Recipients != nil {
  72. cfg.Report.Recipients = req.Report.Recipients
  73. }
  74. cfg.Report.DailySummaryEnabled = req.Report.DailySummaryEnabled
  75. cfg.Report.DailySummarySchedule = strings.TrimSpace(req.Report.DailySummarySchedule)
  76. cfg.Report.WeeklySummaryEnabled = req.Report.WeeklySummaryEnabled
  77. cfg.Report.WeeklySummarySchedule = strings.TrimSpace(req.Report.WeeklySummarySchedule)
  78. cfg.Report.ErrorDigestEnabled = req.Report.ErrorDigestEnabled
  79. cfg.Report.ErrorDigestSchedule = strings.TrimSpace(req.Report.ErrorDigestSchedule)
  80. cfg.Report.ErrorDigestMinCount = req.Report.ErrorDigestMinCount
  81. cfg.Report.AccountHealthEnabled = req.Report.AccountHealthEnabled
  82. cfg.Report.AccountHealthSchedule = strings.TrimSpace(req.Report.AccountHealthSchedule)
  83. cfg.Report.AccountHealthErrorRateThreshold = req.Report.AccountHealthErrorRateThreshold
  84. }
  85. if err := validateOpsEmailNotificationConfig(cfg); err != nil {
  86. return nil, err
  87. }
  88. normalizeOpsEmailNotificationConfig(cfg)
  89. raw, err := json.Marshal(cfg)
  90. if err != nil {
  91. return nil, err
  92. }
  93. if err := s.settingRepo.Set(ctx, SettingKeyOpsEmailNotificationConfig, string(raw)); err != nil {
  94. return nil, err
  95. }
  96. return cfg, nil
  97. }
  98. func defaultOpsEmailNotificationConfig() *OpsEmailNotificationConfig {
  99. return &OpsEmailNotificationConfig{
  100. Alert: OpsEmailAlertConfig{
  101. Enabled: true,
  102. Recipients: []string{},
  103. MinSeverity: "",
  104. RateLimitPerHour: 0,
  105. BatchingWindowSeconds: 0,
  106. IncludeResolvedAlerts: false,
  107. },
  108. Report: OpsEmailReportConfig{
  109. Enabled: false,
  110. Recipients: []string{},
  111. DailySummaryEnabled: false,
  112. DailySummarySchedule: "0 9 * * *",
  113. WeeklySummaryEnabled: false,
  114. WeeklySummarySchedule: "0 9 * * 1",
  115. ErrorDigestEnabled: false,
  116. ErrorDigestSchedule: "0 9 * * *",
  117. ErrorDigestMinCount: 10,
  118. AccountHealthEnabled: false,
  119. AccountHealthSchedule: "0 9 * * *",
  120. AccountHealthErrorRateThreshold: 10.0,
  121. },
  122. }
  123. }
  124. func normalizeOpsEmailNotificationConfig(cfg *OpsEmailNotificationConfig) {
  125. if cfg == nil {
  126. return
  127. }
  128. if cfg.Alert.Recipients == nil {
  129. cfg.Alert.Recipients = []string{}
  130. }
  131. if cfg.Report.Recipients == nil {
  132. cfg.Report.Recipients = []string{}
  133. }
  134. cfg.Alert.MinSeverity = strings.TrimSpace(cfg.Alert.MinSeverity)
  135. cfg.Report.DailySummarySchedule = strings.TrimSpace(cfg.Report.DailySummarySchedule)
  136. cfg.Report.WeeklySummarySchedule = strings.TrimSpace(cfg.Report.WeeklySummarySchedule)
  137. cfg.Report.ErrorDigestSchedule = strings.TrimSpace(cfg.Report.ErrorDigestSchedule)
  138. cfg.Report.AccountHealthSchedule = strings.TrimSpace(cfg.Report.AccountHealthSchedule)
  139. // Fill missing schedules with defaults to avoid breaking cron logic if clients send empty strings.
  140. if cfg.Report.DailySummarySchedule == "" {
  141. cfg.Report.DailySummarySchedule = "0 9 * * *"
  142. }
  143. if cfg.Report.WeeklySummarySchedule == "" {
  144. cfg.Report.WeeklySummarySchedule = "0 9 * * 1"
  145. }
  146. if cfg.Report.ErrorDigestSchedule == "" {
  147. cfg.Report.ErrorDigestSchedule = "0 9 * * *"
  148. }
  149. if cfg.Report.AccountHealthSchedule == "" {
  150. cfg.Report.AccountHealthSchedule = "0 9 * * *"
  151. }
  152. }
  153. func validateOpsEmailNotificationConfig(cfg *OpsEmailNotificationConfig) error {
  154. if cfg == nil {
  155. return errors.New("invalid config")
  156. }
  157. if cfg.Alert.RateLimitPerHour < 0 {
  158. return errors.New("alert.rate_limit_per_hour must be >= 0")
  159. }
  160. if cfg.Alert.BatchingWindowSeconds < 0 {
  161. return errors.New("alert.batching_window_seconds must be >= 0")
  162. }
  163. switch strings.TrimSpace(cfg.Alert.MinSeverity) {
  164. case "", "critical", "warning", "info":
  165. default:
  166. return errors.New("alert.min_severity must be one of: critical, warning, info, or empty")
  167. }
  168. if cfg.Report.ErrorDigestMinCount < 0 {
  169. return errors.New("report.error_digest_min_count must be >= 0")
  170. }
  171. if cfg.Report.AccountHealthErrorRateThreshold < 0 || cfg.Report.AccountHealthErrorRateThreshold > 100 {
  172. return errors.New("report.account_health_error_rate_threshold must be between 0 and 100")
  173. }
  174. return nil
  175. }
  176. // =========================
  177. // Alert runtime settings
  178. // =========================
  179. func defaultOpsAlertRuntimeSettings() *OpsAlertRuntimeSettings {
  180. return &OpsAlertRuntimeSettings{
  181. EvaluationIntervalSeconds: 60,
  182. DistributedLock: OpsDistributedLockSettings{
  183. Enabled: true,
  184. Key: opsAlertEvaluatorLeaderLockKeyDefault,
  185. TTLSeconds: int(opsAlertEvaluatorLeaderLockTTLDefault.Seconds()),
  186. },
  187. Silencing: OpsAlertSilencingSettings{
  188. Enabled: false,
  189. GlobalUntilRFC3339: "",
  190. GlobalReason: "",
  191. Entries: []OpsAlertSilenceEntry{},
  192. },
  193. }
  194. }
  195. func normalizeOpsDistributedLockSettings(s *OpsDistributedLockSettings, defaultKey string, defaultTTLSeconds int) {
  196. if s == nil {
  197. return
  198. }
  199. s.Key = strings.TrimSpace(s.Key)
  200. if s.Key == "" {
  201. s.Key = defaultKey
  202. }
  203. if s.TTLSeconds <= 0 {
  204. s.TTLSeconds = defaultTTLSeconds
  205. }
  206. }
  207. func normalizeOpsAlertSilencingSettings(s *OpsAlertSilencingSettings) {
  208. if s == nil {
  209. return
  210. }
  211. s.GlobalUntilRFC3339 = strings.TrimSpace(s.GlobalUntilRFC3339)
  212. s.GlobalReason = strings.TrimSpace(s.GlobalReason)
  213. if s.Entries == nil {
  214. s.Entries = []OpsAlertSilenceEntry{}
  215. }
  216. for i := range s.Entries {
  217. s.Entries[i].UntilRFC3339 = strings.TrimSpace(s.Entries[i].UntilRFC3339)
  218. s.Entries[i].Reason = strings.TrimSpace(s.Entries[i].Reason)
  219. }
  220. }
  221. func validateOpsDistributedLockSettings(s OpsDistributedLockSettings) error {
  222. if strings.TrimSpace(s.Key) == "" {
  223. return errors.New("distributed_lock.key is required")
  224. }
  225. if s.TTLSeconds <= 0 || s.TTLSeconds > int((24*time.Hour).Seconds()) {
  226. return errors.New("distributed_lock.ttl_seconds must be between 1 and 86400")
  227. }
  228. return nil
  229. }
  230. func validateOpsAlertSilencingSettings(s OpsAlertSilencingSettings) error {
  231. parse := func(raw string) error {
  232. if strings.TrimSpace(raw) == "" {
  233. return nil
  234. }
  235. if _, err := time.Parse(time.RFC3339, raw); err != nil {
  236. return errors.New("silencing time must be RFC3339")
  237. }
  238. return nil
  239. }
  240. if err := parse(s.GlobalUntilRFC3339); err != nil {
  241. return err
  242. }
  243. for _, entry := range s.Entries {
  244. if strings.TrimSpace(entry.UntilRFC3339) == "" {
  245. return errors.New("silencing.entries.until_rfc3339 is required")
  246. }
  247. if _, err := time.Parse(time.RFC3339, entry.UntilRFC3339); err != nil {
  248. return errors.New("silencing.entries.until_rfc3339 must be RFC3339")
  249. }
  250. }
  251. return nil
  252. }
  253. func (s *OpsService) GetOpsAlertRuntimeSettings(ctx context.Context) (*OpsAlertRuntimeSettings, error) {
  254. defaultCfg := defaultOpsAlertRuntimeSettings()
  255. if s == nil || s.settingRepo == nil {
  256. return defaultCfg, nil
  257. }
  258. if ctx == nil {
  259. ctx = context.Background()
  260. }
  261. raw, err := s.settingRepo.GetValue(ctx, SettingKeyOpsAlertRuntimeSettings)
  262. if err != nil {
  263. if errors.Is(err, ErrSettingNotFound) {
  264. if b, mErr := json.Marshal(defaultCfg); mErr == nil {
  265. _ = s.settingRepo.Set(ctx, SettingKeyOpsAlertRuntimeSettings, string(b))
  266. }
  267. return defaultCfg, nil
  268. }
  269. return nil, err
  270. }
  271. cfg := &OpsAlertRuntimeSettings{}
  272. if err := json.Unmarshal([]byte(raw), cfg); err != nil {
  273. return defaultCfg, nil
  274. }
  275. if cfg.EvaluationIntervalSeconds <= 0 {
  276. cfg.EvaluationIntervalSeconds = defaultCfg.EvaluationIntervalSeconds
  277. }
  278. normalizeOpsDistributedLockSettings(&cfg.DistributedLock, opsAlertEvaluatorLeaderLockKeyDefault, defaultCfg.DistributedLock.TTLSeconds)
  279. normalizeOpsAlertSilencingSettings(&cfg.Silencing)
  280. return cfg, nil
  281. }
  282. func (s *OpsService) UpdateOpsAlertRuntimeSettings(ctx context.Context, cfg *OpsAlertRuntimeSettings) (*OpsAlertRuntimeSettings, error) {
  283. if s == nil || s.settingRepo == nil {
  284. return nil, errors.New("setting repository not initialized")
  285. }
  286. if ctx == nil {
  287. ctx = context.Background()
  288. }
  289. if cfg == nil {
  290. return nil, errors.New("invalid config")
  291. }
  292. if cfg.EvaluationIntervalSeconds < 1 || cfg.EvaluationIntervalSeconds > int((24*time.Hour).Seconds()) {
  293. return nil, errors.New("evaluation_interval_seconds must be between 1 and 86400")
  294. }
  295. if cfg.DistributedLock.Enabled {
  296. if err := validateOpsDistributedLockSettings(cfg.DistributedLock); err != nil {
  297. return nil, err
  298. }
  299. }
  300. if cfg.Silencing.Enabled {
  301. if err := validateOpsAlertSilencingSettings(cfg.Silencing); err != nil {
  302. return nil, err
  303. }
  304. }
  305. defaultCfg := defaultOpsAlertRuntimeSettings()
  306. normalizeOpsDistributedLockSettings(&cfg.DistributedLock, opsAlertEvaluatorLeaderLockKeyDefault, defaultCfg.DistributedLock.TTLSeconds)
  307. normalizeOpsAlertSilencingSettings(&cfg.Silencing)
  308. raw, err := json.Marshal(cfg)
  309. if err != nil {
  310. return nil, err
  311. }
  312. if err := s.settingRepo.Set(ctx, SettingKeyOpsAlertRuntimeSettings, string(raw)); err != nil {
  313. return nil, err
  314. }
  315. // Return a fresh copy (avoid callers holding pointers into internal slices that may be mutated).
  316. updated := &OpsAlertRuntimeSettings{}
  317. _ = json.Unmarshal(raw, updated)
  318. return updated, nil
  319. }
  320. // =========================
  321. // Advanced settings
  322. // =========================
  323. func defaultOpsAdvancedSettings() *OpsAdvancedSettings {
  324. return &OpsAdvancedSettings{
  325. DataRetention: OpsDataRetentionSettings{
  326. // Keep the DB-backed default aligned with ops.cleanup.enabled so a
  327. // first settings read cannot silently disable configured retention.
  328. CleanupEnabled: true,
  329. CleanupSchedule: opsCleanupDefaultSchedule,
  330. ErrorLogRetentionDays: 30,
  331. MinuteMetricsRetentionDays: 30,
  332. HourlyMetricsRetentionDays: 30,
  333. },
  334. Aggregation: OpsAggregationSettings{
  335. AggregationEnabled: false,
  336. },
  337. OpenAIAccountQuotaAutoPause: OpsOpenAIAccountQuotaAutoPauseSettings{},
  338. IgnoreCountTokensErrors: true, // count_tokens 404 是预期行为,默认忽略
  339. IgnoreContextCanceled: true, // Default to true - client disconnects are not errors
  340. IgnoreNoAvailableAccounts: false, // Default to false - this is a real routing issue
  341. IgnoreInvalidApiKeyErrors: true, // Legacy compatibility field; admission rejects are always excluded.
  342. IgnoreInsufficientBalanceErrors: false, // 默认不忽略,余额不足可能需要关注
  343. DisplayOpenAITokenStats: false,
  344. DisplayAlertEvents: true,
  345. AutoRefreshEnabled: false,
  346. AutoRefreshIntervalSec: 30,
  347. }
  348. }
  349. func defaultOpsAdvancedSettingsForConfig(cfg *config.Config) *OpsAdvancedSettings {
  350. defaults := defaultOpsAdvancedSettings()
  351. if cfg != nil {
  352. defaults.DataRetention.CleanupEnabled = cfg.Ops.Cleanup.Enabled
  353. }
  354. return defaults
  355. }
  356. func normalizeOpsAdvancedSettings(cfg *OpsAdvancedSettings) {
  357. if cfg == nil {
  358. return
  359. }
  360. // Admission rejects are a security/traffic concern, not an operational
  361. // request-error category. Keep the legacy field true for old clients but do
  362. // not allow it to re-enable those rows.
  363. cfg.IgnoreInvalidApiKeyErrors = true
  364. cfg.OpenAIAccountQuotaAutoPause.DefaultThreshold5h = clampOpsQuotaAutoPauseThreshold(cfg.OpenAIAccountQuotaAutoPause.DefaultThreshold5h)
  365. cfg.OpenAIAccountQuotaAutoPause.DefaultThreshold7d = clampOpsQuotaAutoPauseThreshold(cfg.OpenAIAccountQuotaAutoPause.DefaultThreshold7d)
  366. cfg.DataRetention.CleanupSchedule = strings.TrimSpace(cfg.DataRetention.CleanupSchedule)
  367. if cfg.DataRetention.CleanupSchedule == "" {
  368. cfg.DataRetention.CleanupSchedule = opsCleanupDefaultSchedule
  369. }
  370. // 保留天数:0 表示每次定时清理全部(清空所有),> 0 表示按天数保留;
  371. // 仅在拿到非法的负数时回填默认值,避免覆盖用户主动设的 0。
  372. if cfg.DataRetention.ErrorLogRetentionDays < 0 {
  373. cfg.DataRetention.ErrorLogRetentionDays = 30
  374. }
  375. if cfg.DataRetention.MinuteMetricsRetentionDays < 0 {
  376. cfg.DataRetention.MinuteMetricsRetentionDays = 30
  377. }
  378. if cfg.DataRetention.HourlyMetricsRetentionDays < 0 {
  379. cfg.DataRetention.HourlyMetricsRetentionDays = 30
  380. }
  381. // Normalize auto refresh interval (default 30 seconds)
  382. if cfg.AutoRefreshIntervalSec <= 0 {
  383. cfg.AutoRefreshIntervalSec = 30
  384. }
  385. }
  386. func clampOpsQuotaAutoPauseThreshold(value float64) float64 {
  387. if value <= 0 {
  388. return 0
  389. }
  390. if value > 1 {
  391. return 1
  392. }
  393. return value
  394. }
  395. func validateOpsAdvancedSettings(cfg *OpsAdvancedSettings) error {
  396. if cfg == nil {
  397. return errors.New("invalid config")
  398. }
  399. // 保留天数:0 表示每次清理全部,1-365 表示按天数保留。
  400. if cfg.DataRetention.ErrorLogRetentionDays < 0 || cfg.DataRetention.ErrorLogRetentionDays > 365 {
  401. return errors.New("error_log_retention_days must be between 0 and 365")
  402. }
  403. if cfg.DataRetention.MinuteMetricsRetentionDays < 0 || cfg.DataRetention.MinuteMetricsRetentionDays > 365 {
  404. return errors.New("minute_metrics_retention_days must be between 0 and 365")
  405. }
  406. if cfg.DataRetention.HourlyMetricsRetentionDays < 0 || cfg.DataRetention.HourlyMetricsRetentionDays > 365 {
  407. return errors.New("hourly_metrics_retention_days must be between 0 and 365")
  408. }
  409. if cfg.AutoRefreshIntervalSec < 15 || cfg.AutoRefreshIntervalSec > 300 {
  410. return errors.New("auto_refresh_interval_seconds must be between 15 and 300")
  411. }
  412. return nil
  413. }
  414. func (s *OpsService) GetOpsAdvancedSettings(ctx context.Context) (*OpsAdvancedSettings, error) {
  415. _ = ctx
  416. cfg := s.OpsAdvancedSettingsSnapshot()
  417. return &cfg, nil
  418. }
  419. // OpsAdvancedSettingsSnapshot returns a value copy for request hot paths. It
  420. // avoids both repository I/O and pointer escape/allocation.
  421. func (s *OpsService) OpsAdvancedSettingsSnapshot() OpsAdvancedSettings {
  422. if s != nil {
  423. if snapshot := s.runtimeSettings.Load(); snapshot != nil {
  424. return snapshot.advanced
  425. }
  426. return *defaultOpsAdvancedSettingsForConfig(s.cfg)
  427. }
  428. return *defaultOpsAdvancedSettings()
  429. }
  430. func (s *OpsService) UpdateOpsAdvancedSettings(ctx context.Context, cfg *OpsAdvancedSettings) (*OpsAdvancedSettings, error) {
  431. if s == nil || s.settingRepo == nil {
  432. return nil, errors.New("setting repository not initialized")
  433. }
  434. if ctx == nil {
  435. ctx = context.Background()
  436. }
  437. if cfg == nil {
  438. return nil, errors.New("invalid config")
  439. }
  440. if err := validateOpsAdvancedSettings(cfg); err != nil {
  441. return nil, err
  442. }
  443. normalizeOpsAdvancedSettings(cfg)
  444. raw, err := json.Marshal(cfg)
  445. if err != nil {
  446. return nil, err
  447. }
  448. if err := s.settingRepo.Set(ctx, SettingKeyOpsAdvancedSettings, string(raw)); err != nil {
  449. return nil, err
  450. }
  451. s.storeAdvancedSettingsSnapshot(cfg)
  452. // Push the new quota auto-pause settings straight into the in-memory cache that
  453. // the OpenAI scheduling hot path reads, so the next request observes the new value
  454. // without waiting for the background refresher's TTL.
  455. if s.quotaAutoPauseSink != nil {
  456. s.quotaAutoPauseSink(cfg.OpenAIAccountQuotaAutoPause)
  457. }
  458. // notify cleanup service to reload schedule/enabled.
  459. if s.cleanupReloader != nil {
  460. if rerr := s.cleanupReloader.Reload(ctx); rerr != nil {
  461. logger.LegacyPrintf("service.ops_settings",
  462. "[OpsSettings] cleanup reload after advanced-settings update failed: %v", rerr)
  463. }
  464. }
  465. updated := &OpsAdvancedSettings{}
  466. _ = json.Unmarshal(raw, updated)
  467. return updated, nil
  468. }
  469. // =========================
  470. // Metric thresholds
  471. // =========================
  472. const SettingKeyOpsMetricThresholds = "ops_metric_thresholds"
  473. func defaultOpsMetricThresholds() *OpsMetricThresholds {
  474. slaMin := 99.5
  475. ttftMax := 500.0
  476. reqErrMax := 5.0
  477. upstreamErrMax := 5.0
  478. return &OpsMetricThresholds{
  479. SLAPercentMin: &slaMin,
  480. TTFTp99MsMax: &ttftMax,
  481. RequestErrorRatePercentMax: &reqErrMax,
  482. UpstreamErrorRatePercentMax: &upstreamErrMax,
  483. }
  484. }
  485. func (s *OpsService) GetMetricThresholds(ctx context.Context) (*OpsMetricThresholds, error) {
  486. defaultCfg := defaultOpsMetricThresholds()
  487. if s == nil || s.settingRepo == nil {
  488. return defaultCfg, nil
  489. }
  490. if ctx == nil {
  491. ctx = context.Background()
  492. }
  493. raw, err := s.settingRepo.GetValue(ctx, SettingKeyOpsMetricThresholds)
  494. if err != nil {
  495. if errors.Is(err, ErrSettingNotFound) {
  496. if b, mErr := json.Marshal(defaultCfg); mErr == nil {
  497. _ = s.settingRepo.Set(ctx, SettingKeyOpsMetricThresholds, string(b))
  498. }
  499. return defaultCfg, nil
  500. }
  501. return nil, err
  502. }
  503. cfg := &OpsMetricThresholds{}
  504. if err := json.Unmarshal([]byte(raw), cfg); err != nil {
  505. return defaultCfg, nil
  506. }
  507. return cfg, nil
  508. }
  509. func (s *OpsService) UpdateMetricThresholds(ctx context.Context, cfg *OpsMetricThresholds) (*OpsMetricThresholds, error) {
  510. if s == nil || s.settingRepo == nil {
  511. return nil, errors.New("setting repository not initialized")
  512. }
  513. if ctx == nil {
  514. ctx = context.Background()
  515. }
  516. if cfg == nil {
  517. return nil, errors.New("invalid config")
  518. }
  519. // Validate thresholds
  520. if cfg.SLAPercentMin != nil && (*cfg.SLAPercentMin < 0 || *cfg.SLAPercentMin > 100) {
  521. return nil, errors.New("sla_percent_min must be between 0 and 100")
  522. }
  523. if cfg.TTFTp99MsMax != nil && *cfg.TTFTp99MsMax < 0 {
  524. return nil, errors.New("ttft_p99_ms_max must be >= 0")
  525. }
  526. if cfg.RequestErrorRatePercentMax != nil && (*cfg.RequestErrorRatePercentMax < 0 || *cfg.RequestErrorRatePercentMax > 100) {
  527. return nil, errors.New("request_error_rate_percent_max must be between 0 and 100")
  528. }
  529. if cfg.UpstreamErrorRatePercentMax != nil && (*cfg.UpstreamErrorRatePercentMax < 0 || *cfg.UpstreamErrorRatePercentMax > 100) {
  530. return nil, errors.New("upstream_error_rate_percent_max must be between 0 and 100")
  531. }
  532. raw, err := json.Marshal(cfg)
  533. if err != nil {
  534. return nil, err
  535. }
  536. if err := s.settingRepo.Set(ctx, SettingKeyOpsMetricThresholds, string(raw)); err != nil {
  537. return nil, err
  538. }
  539. updated := &OpsMetricThresholds{}
  540. _ = json.Unmarshal(raw, updated)
  541. return updated, nil
  542. }