ops_metrics_collector.go 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970
  1. package service
  2. import (
  3. "context"
  4. "database/sql"
  5. "errors"
  6. "fmt"
  7. "log"
  8. "math"
  9. "os"
  10. "runtime"
  11. "strconv"
  12. "strings"
  13. "sync"
  14. "time"
  15. "unicode/utf8"
  16. "github.com/Wei-Shaw/sub2api/internal/config"
  17. "github.com/google/uuid"
  18. "github.com/redis/go-redis/v9"
  19. "github.com/shirou/gopsutil/v4/cpu"
  20. "github.com/shirou/gopsutil/v4/mem"
  21. )
  22. const (
  23. opsMetricsCollectorJobName = "ops_metrics_collector"
  24. opsMetricsCollectorMinInterval = 60 * time.Second
  25. opsMetricsCollectorMaxInterval = 1 * time.Hour
  26. opsMetricsCollectorTimeout = 10 * time.Second
  27. opsMetricsCollectorLeaderLockKey = "ops:metrics:collector:leader"
  28. opsMetricsCollectorLeaderLockTTL = 90 * time.Second
  29. opsMetricsCollectorHeartbeatTimeout = 2 * time.Second
  30. bytesPerMB = 1024 * 1024
  31. )
  32. var opsMetricsCollectorAdvisoryLockID = hashAdvisoryLockID(opsMetricsCollectorLeaderLockKey)
  33. type opsSchedulableAccountLoadRepository interface {
  34. ListSchedulableAccountLoads(ctx context.Context) ([]AccountWithConcurrency, error)
  35. }
  36. type OpsMetricsCollector struct {
  37. opsRepo OpsRepository
  38. settingRepo SettingRepository
  39. cfg *config.Config
  40. accountRepo AccountRepository
  41. concurrencyService *ConcurrencyService
  42. db *sql.DB
  43. redisClient *redis.Client
  44. instanceID string
  45. lastCgroupCPUUsageNanos uint64
  46. lastCgroupCPUSampleAt time.Time
  47. stopCh chan struct{}
  48. startOnce sync.Once
  49. stopOnce sync.Once
  50. skipLogMu sync.Mutex
  51. skipLogAt time.Time
  52. }
  53. func NewOpsMetricsCollector(
  54. opsRepo OpsRepository,
  55. settingRepo SettingRepository,
  56. accountRepo AccountRepository,
  57. concurrencyService *ConcurrencyService,
  58. db *sql.DB,
  59. redisClient *redis.Client,
  60. cfg *config.Config,
  61. ) *OpsMetricsCollector {
  62. return &OpsMetricsCollector{
  63. opsRepo: opsRepo,
  64. settingRepo: settingRepo,
  65. cfg: cfg,
  66. accountRepo: accountRepo,
  67. concurrencyService: concurrencyService,
  68. db: db,
  69. redisClient: redisClient,
  70. instanceID: uuid.NewString(),
  71. }
  72. }
  73. func (c *OpsMetricsCollector) Start() {
  74. if c == nil {
  75. return
  76. }
  77. c.startOnce.Do(func() {
  78. if c.stopCh == nil {
  79. c.stopCh = make(chan struct{})
  80. }
  81. go c.run()
  82. })
  83. }
  84. func (c *OpsMetricsCollector) Stop() {
  85. if c == nil {
  86. return
  87. }
  88. c.stopOnce.Do(func() {
  89. if c.stopCh != nil {
  90. close(c.stopCh)
  91. }
  92. })
  93. }
  94. func (c *OpsMetricsCollector) run() {
  95. // First run immediately so the dashboard has data soon after startup.
  96. c.collectOnce()
  97. for {
  98. interval := c.getInterval()
  99. timer := time.NewTimer(interval)
  100. select {
  101. case <-timer.C:
  102. c.collectOnce()
  103. case <-c.stopCh:
  104. timer.Stop()
  105. return
  106. }
  107. }
  108. }
  109. func (c *OpsMetricsCollector) getInterval() time.Duration {
  110. interval := opsMetricsCollectorMinInterval
  111. if c.settingRepo == nil {
  112. return interval
  113. }
  114. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  115. defer cancel()
  116. raw, err := c.settingRepo.GetValue(ctx, SettingKeyOpsMetricsIntervalSeconds)
  117. if err != nil {
  118. return interval
  119. }
  120. raw = strings.TrimSpace(raw)
  121. if raw == "" {
  122. return interval
  123. }
  124. seconds, err := strconv.Atoi(raw)
  125. if err != nil {
  126. return interval
  127. }
  128. if seconds < int(opsMetricsCollectorMinInterval.Seconds()) {
  129. seconds = int(opsMetricsCollectorMinInterval.Seconds())
  130. }
  131. if seconds > int(opsMetricsCollectorMaxInterval.Seconds()) {
  132. seconds = int(opsMetricsCollectorMaxInterval.Seconds())
  133. }
  134. return time.Duration(seconds) * time.Second
  135. }
  136. func (c *OpsMetricsCollector) collectOnce() {
  137. if c == nil {
  138. return
  139. }
  140. if c.cfg != nil && !c.cfg.Ops.Enabled {
  141. return
  142. }
  143. if c.opsRepo == nil {
  144. return
  145. }
  146. if c.db == nil {
  147. return
  148. }
  149. ctx, cancel := context.WithTimeout(context.Background(), opsMetricsCollectorTimeout)
  150. defer cancel()
  151. if !c.isMonitoringEnabled(ctx) {
  152. return
  153. }
  154. release, ok := c.tryAcquireLeaderLock(ctx)
  155. if !ok {
  156. return
  157. }
  158. if release != nil {
  159. defer release()
  160. }
  161. startedAt := time.Now().UTC()
  162. err := c.collectAndPersist(ctx)
  163. finishedAt := time.Now().UTC()
  164. durationMs := finishedAt.Sub(startedAt).Milliseconds()
  165. dur := durationMs
  166. runAt := startedAt
  167. if err != nil {
  168. msg := truncateString(err.Error(), 2048)
  169. errAt := finishedAt
  170. hbCtx, hbCancel := context.WithTimeout(context.Background(), opsMetricsCollectorHeartbeatTimeout)
  171. defer hbCancel()
  172. _ = c.opsRepo.UpsertJobHeartbeat(hbCtx, &OpsUpsertJobHeartbeatInput{
  173. JobName: opsMetricsCollectorJobName,
  174. LastRunAt: &runAt,
  175. LastErrorAt: &errAt,
  176. LastError: &msg,
  177. LastDurationMs: &dur,
  178. })
  179. log.Printf("[OpsMetricsCollector] collect failed: %v", err)
  180. return
  181. }
  182. successAt := finishedAt
  183. hbCtx, hbCancel := context.WithTimeout(context.Background(), opsMetricsCollectorHeartbeatTimeout)
  184. defer hbCancel()
  185. _ = c.opsRepo.UpsertJobHeartbeat(hbCtx, &OpsUpsertJobHeartbeatInput{
  186. JobName: opsMetricsCollectorJobName,
  187. LastRunAt: &runAt,
  188. LastSuccessAt: &successAt,
  189. LastDurationMs: &dur,
  190. })
  191. }
  192. func (c *OpsMetricsCollector) isMonitoringEnabled(ctx context.Context) bool {
  193. if c == nil {
  194. return false
  195. }
  196. if c.cfg != nil && !c.cfg.Ops.Enabled {
  197. return false
  198. }
  199. if c.settingRepo == nil {
  200. return true
  201. }
  202. if ctx == nil {
  203. ctx = context.Background()
  204. }
  205. value, err := c.settingRepo.GetValue(ctx, SettingKeyOpsMonitoringEnabled)
  206. if err != nil {
  207. if errors.Is(err, ErrSettingNotFound) {
  208. return true
  209. }
  210. // Fail-open: collector should not become a hard dependency.
  211. return true
  212. }
  213. switch strings.ToLower(strings.TrimSpace(value)) {
  214. case "false", "0", "off", "disabled":
  215. return false
  216. default:
  217. return true
  218. }
  219. }
  220. func (c *OpsMetricsCollector) collectAndPersist(ctx context.Context) error {
  221. if ctx == nil {
  222. ctx = context.Background()
  223. }
  224. // Align to stable minute boundaries to avoid partial buckets and to maximize cache hits.
  225. now := time.Now().UTC()
  226. windowEnd := now.Truncate(time.Minute)
  227. windowStart := windowEnd.Add(-1 * time.Minute)
  228. sys, err := c.collectSystemStats(ctx)
  229. if err != nil {
  230. // Continue; system stats are best-effort.
  231. log.Printf("[OpsMetricsCollector] system stats error: %v", err)
  232. }
  233. dbOK := c.checkDB(ctx)
  234. redisOK := c.checkRedis(ctx)
  235. active, idle := c.dbPoolStats()
  236. redisTotal, redisIdle, redisStatsOK := c.redisPoolStats()
  237. successCount, tokenConsumed, err := c.queryUsageCounts(ctx, windowStart, windowEnd)
  238. if err != nil {
  239. return fmt.Errorf("query usage counts: %w", err)
  240. }
  241. duration, ttft, err := c.queryUsageLatency(ctx, windowStart, windowEnd)
  242. if err != nil {
  243. return fmt.Errorf("query usage latency: %w", err)
  244. }
  245. errorTotal, businessLimited, errorSLA, upstreamExcl, upstream429, upstream529, err := c.queryErrorCounts(ctx, windowStart, windowEnd)
  246. if err != nil {
  247. return fmt.Errorf("query error counts: %w", err)
  248. }
  249. accountSwitchCount, err := c.queryAccountSwitchCount(ctx, windowStart, windowEnd)
  250. if err != nil {
  251. return fmt.Errorf("query account switch counts: %w", err)
  252. }
  253. windowSeconds := windowEnd.Sub(windowStart).Seconds()
  254. if windowSeconds <= 0 {
  255. windowSeconds = 60
  256. }
  257. requestTotal := successCount + errorTotal
  258. qps := float64(requestTotal) / windowSeconds
  259. tps := float64(tokenConsumed) / windowSeconds
  260. goroutines := runtime.NumGoroutine()
  261. concurrencyQueueDepth := c.collectConcurrencyQueueDepth(ctx)
  262. input := &OpsInsertSystemMetricsInput{
  263. CreatedAt: windowEnd,
  264. WindowMinutes: 1,
  265. SuccessCount: successCount,
  266. ErrorCountTotal: errorTotal,
  267. BusinessLimitedCount: businessLimited,
  268. ErrorCountSLA: errorSLA,
  269. UpstreamErrorCountExcl429529: upstreamExcl,
  270. Upstream429Count: upstream429,
  271. Upstream529Count: upstream529,
  272. TokenConsumed: tokenConsumed,
  273. AccountSwitchCount: accountSwitchCount,
  274. QPS: float64Ptr(roundTo1DP(qps)),
  275. TPS: float64Ptr(roundTo1DP(tps)),
  276. DurationP50Ms: duration.p50,
  277. DurationP90Ms: duration.p90,
  278. DurationP95Ms: duration.p95,
  279. DurationP99Ms: duration.p99,
  280. DurationAvgMs: duration.avg,
  281. DurationMaxMs: duration.max,
  282. TTFTP50Ms: ttft.p50,
  283. TTFTP90Ms: ttft.p90,
  284. TTFTP95Ms: ttft.p95,
  285. TTFTP99Ms: ttft.p99,
  286. TTFTAvgMs: ttft.avg,
  287. TTFTMaxMs: ttft.max,
  288. CPUUsagePercent: sys.cpuUsagePercent,
  289. MemoryUsedMB: sys.memoryUsedMB,
  290. MemoryTotalMB: sys.memoryTotalMB,
  291. MemoryUsagePercent: sys.memoryUsagePercent,
  292. DBOK: boolPtr(dbOK),
  293. RedisOK: boolPtr(redisOK),
  294. RedisConnTotal: func() *int {
  295. if !redisStatsOK {
  296. return nil
  297. }
  298. return intPtr(redisTotal)
  299. }(),
  300. RedisConnIdle: func() *int {
  301. if !redisStatsOK {
  302. return nil
  303. }
  304. return intPtr(redisIdle)
  305. }(),
  306. DBConnActive: intPtr(active),
  307. DBConnIdle: intPtr(idle),
  308. GoroutineCount: intPtr(goroutines),
  309. ConcurrencyQueueDepth: concurrencyQueueDepth,
  310. }
  311. return c.opsRepo.InsertSystemMetrics(ctx, input)
  312. }
  313. func (c *OpsMetricsCollector) collectConcurrencyQueueDepth(parentCtx context.Context) *int {
  314. if c == nil || c.accountRepo == nil || c.concurrencyService == nil {
  315. return nil
  316. }
  317. if parentCtx == nil {
  318. parentCtx = context.Background()
  319. }
  320. // Best-effort: never let concurrency sampling break the metrics collector.
  321. ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
  322. defer cancel()
  323. accountLoads, err := c.listSchedulableAccountLoads(ctx)
  324. if err != nil {
  325. return nil
  326. }
  327. if len(accountLoads) == 0 {
  328. zero := 0
  329. return &zero
  330. }
  331. loadMap, err := c.concurrencyService.GetAccountsLoadBatch(ctx, accountLoads)
  332. if err != nil {
  333. return nil
  334. }
  335. var total int64
  336. for _, info := range loadMap {
  337. if info == nil || info.WaitingCount <= 0 {
  338. continue
  339. }
  340. total += int64(info.WaitingCount)
  341. }
  342. if total < 0 {
  343. total = 0
  344. }
  345. maxInt := int64(^uint(0) >> 1)
  346. if total > maxInt {
  347. total = maxInt
  348. }
  349. v := int(total)
  350. return &v
  351. }
  352. func (c *OpsMetricsCollector) listSchedulableAccountLoads(ctx context.Context) ([]AccountWithConcurrency, error) {
  353. if repo, ok := c.accountRepo.(opsSchedulableAccountLoadRepository); ok {
  354. return repo.ListSchedulableAccountLoads(ctx)
  355. }
  356. accounts, err := c.accountRepo.ListSchedulable(ctx)
  357. if err != nil {
  358. return nil, err
  359. }
  360. loads := make([]AccountWithConcurrency, 0, len(accounts))
  361. for _, account := range accounts {
  362. if account.ID <= 0 {
  363. continue
  364. }
  365. loads = append(loads, AccountWithConcurrency{
  366. ID: account.ID,
  367. MaxConcurrency: account.EffectiveLoadFactor(),
  368. })
  369. }
  370. return loads, nil
  371. }
  372. type opsCollectedPercentiles struct {
  373. p50 *int
  374. p90 *int
  375. p95 *int
  376. p99 *int
  377. avg *float64
  378. max *int
  379. }
  380. func (c *OpsMetricsCollector) queryUsageCounts(ctx context.Context, start, end time.Time) (successCount int64, tokenConsumed int64, err error) {
  381. q := `
  382. SELECT
  383. COALESCE(COUNT(*), 0) AS success_count,
  384. COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) AS token_consumed
  385. FROM usage_logs
  386. WHERE created_at >= $1 AND created_at < $2`
  387. var tokens sql.NullInt64
  388. if err := c.db.QueryRowContext(ctx, q, start, end).Scan(&successCount, &tokens); err != nil {
  389. return 0, 0, err
  390. }
  391. if tokens.Valid {
  392. tokenConsumed = tokens.Int64
  393. }
  394. return successCount, tokenConsumed, nil
  395. }
  396. func (c *OpsMetricsCollector) queryUsageLatency(ctx context.Context, start, end time.Time) (duration opsCollectedPercentiles, ttft opsCollectedPercentiles, err error) {
  397. {
  398. q := `
  399. SELECT
  400. percentile_cont(0.50) WITHIN GROUP (ORDER BY duration_ms) AS p50,
  401. percentile_cont(0.90) WITHIN GROUP (ORDER BY duration_ms) AS p90,
  402. percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms) AS p95,
  403. percentile_cont(0.99) WITHIN GROUP (ORDER BY duration_ms) AS p99,
  404. AVG(duration_ms) AS avg_ms,
  405. MAX(duration_ms) AS max_ms
  406. FROM usage_logs
  407. WHERE created_at >= $1 AND created_at < $2
  408. AND duration_ms IS NOT NULL`
  409. var p50, p90, p95, p99 sql.NullFloat64
  410. var avg sql.NullFloat64
  411. var max sql.NullInt64
  412. if err := c.db.QueryRowContext(ctx, q, start, end).Scan(&p50, &p90, &p95, &p99, &avg, &max); err != nil {
  413. return opsCollectedPercentiles{}, opsCollectedPercentiles{}, err
  414. }
  415. duration.p50 = floatToIntPtr(p50)
  416. duration.p90 = floatToIntPtr(p90)
  417. duration.p95 = floatToIntPtr(p95)
  418. duration.p99 = floatToIntPtr(p99)
  419. if avg.Valid {
  420. v := roundTo1DP(avg.Float64)
  421. duration.avg = &v
  422. }
  423. if max.Valid {
  424. v := int(max.Int64)
  425. duration.max = &v
  426. }
  427. }
  428. {
  429. q := `
  430. SELECT
  431. percentile_cont(0.50) WITHIN GROUP (ORDER BY first_token_ms) AS p50,
  432. percentile_cont(0.90) WITHIN GROUP (ORDER BY first_token_ms) AS p90,
  433. percentile_cont(0.95) WITHIN GROUP (ORDER BY first_token_ms) AS p95,
  434. percentile_cont(0.99) WITHIN GROUP (ORDER BY first_token_ms) AS p99,
  435. AVG(first_token_ms) AS avg_ms,
  436. MAX(first_token_ms) AS max_ms
  437. FROM usage_logs
  438. WHERE created_at >= $1 AND created_at < $2
  439. AND first_token_ms IS NOT NULL`
  440. var p50, p90, p95, p99 sql.NullFloat64
  441. var avg sql.NullFloat64
  442. var max sql.NullInt64
  443. if err := c.db.QueryRowContext(ctx, q, start, end).Scan(&p50, &p90, &p95, &p99, &avg, &max); err != nil {
  444. return opsCollectedPercentiles{}, opsCollectedPercentiles{}, err
  445. }
  446. ttft.p50 = floatToIntPtr(p50)
  447. ttft.p90 = floatToIntPtr(p90)
  448. ttft.p95 = floatToIntPtr(p95)
  449. ttft.p99 = floatToIntPtr(p99)
  450. if avg.Valid {
  451. v := roundTo1DP(avg.Float64)
  452. ttft.avg = &v
  453. }
  454. if max.Valid {
  455. v := int(max.Int64)
  456. ttft.max = &v
  457. }
  458. }
  459. return duration, ttft, nil
  460. }
  461. func (c *OpsMetricsCollector) queryErrorCounts(ctx context.Context, start, end time.Time) (
  462. errorTotal int64,
  463. businessLimited int64,
  464. errorSLA int64,
  465. upstreamExcl429529 int64,
  466. upstream429 int64,
  467. upstream529 int64,
  468. err error,
  469. ) {
  470. q := `
  471. SELECT
  472. COALESCE(COUNT(*) FILTER (WHERE COALESCE(status_code, 0) >= 400), 0) AS error_total,
  473. COALESCE(COUNT(*) FILTER (WHERE COALESCE(status_code, 0) >= 400 AND is_business_limited), 0) AS business_limited,
  474. COALESCE(COUNT(*) FILTER (WHERE COALESCE(status_code, 0) >= 400 AND NOT is_business_limited), 0) AS error_sla,
  475. COALESCE(COUNT(*) FILTER (WHERE error_owner = 'provider' AND NOT is_business_limited AND COALESCE(upstream_status_code, status_code, 0) NOT IN (429, 529)), 0) AS upstream_excl,
  476. COALESCE(COUNT(*) FILTER (WHERE error_owner = 'provider' AND NOT is_business_limited AND COALESCE(upstream_status_code, status_code, 0) = 429), 0) AS upstream_429,
  477. COALESCE(COUNT(*) FILTER (WHERE error_owner = 'provider' AND NOT is_business_limited AND COALESCE(upstream_status_code, status_code, 0) = 529), 0) AS upstream_529
  478. FROM ops_error_logs
  479. WHERE created_at >= $1 AND created_at < $2
  480. AND is_count_tokens = FALSE`
  481. if err := c.db.QueryRowContext(ctx, q, start, end).Scan(
  482. &errorTotal,
  483. &businessLimited,
  484. &errorSLA,
  485. &upstreamExcl429529,
  486. &upstream429,
  487. &upstream529,
  488. ); err != nil {
  489. return 0, 0, 0, 0, 0, 0, err
  490. }
  491. return errorTotal, businessLimited, errorSLA, upstreamExcl429529, upstream429, upstream529, nil
  492. }
  493. func (c *OpsMetricsCollector) queryAccountSwitchCount(ctx context.Context, start, end time.Time) (int64, error) {
  494. q := `
  495. SELECT
  496. COALESCE(SUM(CASE
  497. WHEN split_part(ev->>'kind', ':', 1) IN ('failover', 'retry_exhausted_failover', 'failover_on_400') THEN 1
  498. ELSE 0
  499. END), 0) AS switch_count
  500. FROM ops_error_logs o
  501. CROSS JOIN LATERAL jsonb_array_elements(
  502. COALESCE(NULLIF(o.upstream_errors, 'null'::jsonb), '[]'::jsonb)
  503. ) AS ev
  504. WHERE o.created_at >= $1 AND o.created_at < $2
  505. AND o.is_count_tokens = FALSE`
  506. var count int64
  507. if err := c.db.QueryRowContext(ctx, q, start, end).Scan(&count); err != nil {
  508. return 0, err
  509. }
  510. return count, nil
  511. }
  512. type opsCollectedSystemStats struct {
  513. cpuUsagePercent *float64
  514. memoryUsedMB *int64
  515. memoryTotalMB *int64
  516. memoryUsagePercent *float64
  517. }
  518. func (c *OpsMetricsCollector) collectSystemStats(ctx context.Context) (*opsCollectedSystemStats, error) {
  519. out := &opsCollectedSystemStats{}
  520. if ctx == nil {
  521. ctx = context.Background()
  522. }
  523. sampleAt := time.Now().UTC()
  524. // CPU: prefer cgroup (container) metrics, falling back to host metrics when
  525. // cgroup CPU accounting is unavailable.
  526. if cpuPct := c.tryCgroupCPUPercent(sampleAt); cpuPct != nil {
  527. out.cpuUsagePercent = cpuPct
  528. }
  529. if out.cpuUsagePercent == nil {
  530. if cpuPercents, err := cpu.PercentWithContext(ctx, 0, false); err == nil && len(cpuPercents) > 0 {
  531. v := roundTo1DP(cpuPercents[0])
  532. out.cpuUsagePercent = &v
  533. }
  534. }
  535. // Memory: prefer cgroup (container) metrics, but only when the cgroup exposes
  536. // BOTH a current usage and a concrete limit (memory.max != "max"). When the
  537. // limit is missing or the cgroup data is otherwise incomplete, fall back
  538. // ENTIRELY to host metrics. Never mix a container "used" with a host "total":
  539. // doing so reports a misleadingly tiny percentage (e.g. 60MB / 23GB ~= 0.3%).
  540. cgroupUsed, cgroupTotal, cgroupOK := readCgroupMemoryBytes()
  541. var host *mem.VirtualMemoryStat
  542. if !cgroupOK || cgroupTotal == 0 {
  543. if vm, err := mem.VirtualMemoryWithContext(ctx); err == nil {
  544. host = vm
  545. }
  546. }
  547. out.memoryUsedMB, out.memoryTotalMB, out.memoryUsagePercent = resolveMemoryStats(cgroupUsed, cgroupTotal, cgroupOK, host)
  548. return out, nil
  549. }
  550. // resolveMemoryStats picks a single, self-consistent (used, total, percent)
  551. // memory trio from either cgroup (container) or host metrics — never a mix of
  552. // the two.
  553. //
  554. // cgroup metrics are preferred, but only when the cgroup reports BOTH a current
  555. // usage and a concrete limit (i.e. memory.max is a number, not "max", so
  556. // cgroupTotal > 0). If the limit is absent or the cgroup data is otherwise
  557. // incomplete, all three values fall back to the host reading. This avoids the
  558. // classic pitfall of dividing a container "used" by a host "total", which
  559. // wildly understates memory usage (e.g. 60MB / 23GB ~= 0.3%).
  560. func resolveMemoryStats(cgroupUsed, cgroupTotal uint64, cgroupOK bool, host *mem.VirtualMemoryStat) (usedMB *int64, totalMB *int64, usagePercent *float64) {
  561. if cgroupOK && cgroupTotal > 0 {
  562. u := int64(cgroupUsed / bytesPerMB)
  563. t := int64(cgroupTotal / bytesPerMB)
  564. p := roundTo1DP(float64(cgroupUsed) / float64(cgroupTotal) * 100)
  565. return &u, &t, &p
  566. }
  567. if host == nil {
  568. return nil, nil, nil
  569. }
  570. u := int64(host.Used / bytesPerMB)
  571. usedMB = &u
  572. if host.Total > 0 {
  573. t := int64(host.Total / bytesPerMB)
  574. totalMB = &t
  575. p := roundTo1DP(float64(host.Used) / float64(host.Total) * 100)
  576. usagePercent = &p
  577. } else {
  578. // Degenerate: host reported no total. Preserve gopsutil's own percentage.
  579. p := roundTo1DP(host.UsedPercent)
  580. usagePercent = &p
  581. }
  582. return usedMB, totalMB, usagePercent
  583. }
  584. func (c *OpsMetricsCollector) tryCgroupCPUPercent(now time.Time) *float64 {
  585. usageNanos, ok := readCgroupCPUUsageNanos()
  586. if !ok {
  587. return nil
  588. }
  589. // Initialize baseline sample.
  590. if c.lastCgroupCPUSampleAt.IsZero() {
  591. c.lastCgroupCPUUsageNanos = usageNanos
  592. c.lastCgroupCPUSampleAt = now
  593. return nil
  594. }
  595. elapsed := now.Sub(c.lastCgroupCPUSampleAt)
  596. if elapsed <= 0 {
  597. c.lastCgroupCPUUsageNanos = usageNanos
  598. c.lastCgroupCPUSampleAt = now
  599. return nil
  600. }
  601. prev := c.lastCgroupCPUUsageNanos
  602. c.lastCgroupCPUUsageNanos = usageNanos
  603. c.lastCgroupCPUSampleAt = now
  604. if usageNanos < prev {
  605. // Counter reset (container restarted).
  606. return nil
  607. }
  608. deltaUsageSec := float64(usageNanos-prev) / 1e9
  609. elapsedSec := elapsed.Seconds()
  610. if elapsedSec <= 0 {
  611. return nil
  612. }
  613. cores := readCgroupCPULimitCores()
  614. if cores <= 0 {
  615. // Can't reliably normalize; skip and fall back to gopsutil.
  616. return nil
  617. }
  618. pct := (deltaUsageSec / (elapsedSec * cores)) * 100
  619. if pct < 0 {
  620. pct = 0
  621. }
  622. // Clamp to avoid noise/jitter showing impossible values.
  623. if pct > 100 {
  624. pct = 100
  625. }
  626. v := roundTo1DP(pct)
  627. return &v
  628. }
  629. func readCgroupMemoryBytes() (usedBytes uint64, totalBytes uint64, ok bool) {
  630. // cgroup v2 (most common in modern containers)
  631. if used, ok1 := readUintFile("/sys/fs/cgroup/memory.current"); ok1 {
  632. usedBytes = used
  633. rawMax, err := os.ReadFile("/sys/fs/cgroup/memory.max")
  634. if err == nil {
  635. s := strings.TrimSpace(string(rawMax))
  636. if s != "" && s != "max" {
  637. if v, err := strconv.ParseUint(s, 10, 64); err == nil {
  638. totalBytes = v
  639. }
  640. }
  641. }
  642. return usedBytes, totalBytes, true
  643. }
  644. // cgroup v1 fallback
  645. if used, ok1 := readUintFile("/sys/fs/cgroup/memory/memory.usage_in_bytes"); ok1 {
  646. usedBytes = used
  647. if limit, ok2 := readUintFile("/sys/fs/cgroup/memory/memory.limit_in_bytes"); ok2 {
  648. // Some environments report a very large number when unlimited.
  649. if limit > 0 && limit < (1<<60) {
  650. totalBytes = limit
  651. }
  652. }
  653. return usedBytes, totalBytes, true
  654. }
  655. return 0, 0, false
  656. }
  657. func readCgroupCPUUsageNanos() (usageNanos uint64, ok bool) {
  658. // cgroup v2: cpu.stat has usage_usec
  659. if raw, err := os.ReadFile("/sys/fs/cgroup/cpu.stat"); err == nil {
  660. lines := strings.Split(string(raw), "\n")
  661. for _, line := range lines {
  662. fields := strings.Fields(line)
  663. if len(fields) != 2 {
  664. continue
  665. }
  666. if fields[0] != "usage_usec" {
  667. continue
  668. }
  669. v, err := strconv.ParseUint(fields[1], 10, 64)
  670. if err != nil {
  671. continue
  672. }
  673. return v * 1000, true
  674. }
  675. }
  676. // cgroup v1: cpuacct.usage is in nanoseconds
  677. if v, ok := readUintFile("/sys/fs/cgroup/cpuacct/cpuacct.usage"); ok {
  678. return v, true
  679. }
  680. return 0, false
  681. }
  682. func readCgroupCPULimitCores() float64 {
  683. // cgroup v2: cpu.max => "<quota> <period>" or "max <period>"
  684. if raw, err := os.ReadFile("/sys/fs/cgroup/cpu.max"); err == nil {
  685. fields := strings.Fields(string(raw))
  686. if len(fields) >= 2 && fields[0] != "max" {
  687. quota, err1 := strconv.ParseFloat(fields[0], 64)
  688. period, err2 := strconv.ParseFloat(fields[1], 64)
  689. if err1 == nil && err2 == nil && quota > 0 && period > 0 {
  690. return quota / period
  691. }
  692. }
  693. }
  694. // cgroup v1: cpu.cfs_quota_us / cpu.cfs_period_us
  695. quota, okQuota := readIntFile("/sys/fs/cgroup/cpu/cpu.cfs_quota_us")
  696. period, okPeriod := readIntFile("/sys/fs/cgroup/cpu/cpu.cfs_period_us")
  697. if okQuota && okPeriod && quota > 0 && period > 0 {
  698. return float64(quota) / float64(period)
  699. }
  700. return 0
  701. }
  702. func readUintFile(path string) (uint64, bool) {
  703. raw, err := os.ReadFile(path)
  704. if err != nil {
  705. return 0, false
  706. }
  707. s := strings.TrimSpace(string(raw))
  708. if s == "" {
  709. return 0, false
  710. }
  711. v, err := strconv.ParseUint(s, 10, 64)
  712. if err != nil {
  713. return 0, false
  714. }
  715. return v, true
  716. }
  717. func readIntFile(path string) (int64, bool) {
  718. raw, err := os.ReadFile(path)
  719. if err != nil {
  720. return 0, false
  721. }
  722. s := strings.TrimSpace(string(raw))
  723. if s == "" {
  724. return 0, false
  725. }
  726. v, err := strconv.ParseInt(s, 10, 64)
  727. if err != nil {
  728. return 0, false
  729. }
  730. return v, true
  731. }
  732. func (c *OpsMetricsCollector) checkDB(ctx context.Context) bool {
  733. if c == nil || c.db == nil {
  734. return false
  735. }
  736. if ctx == nil {
  737. ctx = context.Background()
  738. }
  739. var one int
  740. if err := c.db.QueryRowContext(ctx, "SELECT 1").Scan(&one); err != nil {
  741. return false
  742. }
  743. return one == 1
  744. }
  745. func (c *OpsMetricsCollector) checkRedis(ctx context.Context) bool {
  746. if c == nil || c.redisClient == nil {
  747. return false
  748. }
  749. if ctx == nil {
  750. ctx = context.Background()
  751. }
  752. return c.redisClient.Ping(ctx).Err() == nil
  753. }
  754. func (c *OpsMetricsCollector) redisPoolStats() (total int, idle int, ok bool) {
  755. if c == nil || c.redisClient == nil {
  756. return 0, 0, false
  757. }
  758. stats := c.redisClient.PoolStats()
  759. if stats == nil {
  760. return 0, 0, false
  761. }
  762. return int(stats.TotalConns), int(stats.IdleConns), true
  763. }
  764. func (c *OpsMetricsCollector) dbPoolStats() (active int, idle int) {
  765. if c == nil || c.db == nil {
  766. return 0, 0
  767. }
  768. stats := c.db.Stats()
  769. return stats.InUse, stats.Idle
  770. }
  771. var opsMetricsCollectorReleaseScript = redis.NewScript(`
  772. if redis.call("GET", KEYS[1]) == ARGV[1] then
  773. return redis.call("DEL", KEYS[1])
  774. end
  775. return 0
  776. `)
  777. func (c *OpsMetricsCollector) tryAcquireLeaderLock(ctx context.Context) (func(), bool) {
  778. if c == nil || c.redisClient == nil {
  779. return nil, true
  780. }
  781. if ctx == nil {
  782. ctx = context.Background()
  783. }
  784. ok, err := c.redisClient.SetNX(ctx, opsMetricsCollectorLeaderLockKey, c.instanceID, opsMetricsCollectorLeaderLockTTL).Result()
  785. if err != nil {
  786. // Prefer fail-closed to avoid stampeding the database when Redis is flaky.
  787. // Fallback to a DB advisory lock when Redis is present but unavailable.
  788. release, ok := tryAcquireDBAdvisoryLock(ctx, c.db, opsMetricsCollectorAdvisoryLockID)
  789. if !ok {
  790. c.maybeLogSkip()
  791. return nil, false
  792. }
  793. return release, true
  794. }
  795. if !ok {
  796. c.maybeLogSkip()
  797. return nil, false
  798. }
  799. release := func() {
  800. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  801. defer cancel()
  802. _, _ = opsMetricsCollectorReleaseScript.Run(ctx, c.redisClient, []string{opsMetricsCollectorLeaderLockKey}, c.instanceID).Result()
  803. }
  804. return release, true
  805. }
  806. func (c *OpsMetricsCollector) maybeLogSkip() {
  807. c.skipLogMu.Lock()
  808. defer c.skipLogMu.Unlock()
  809. now := time.Now()
  810. if !c.skipLogAt.IsZero() && now.Sub(c.skipLogAt) < time.Minute {
  811. return
  812. }
  813. c.skipLogAt = now
  814. log.Printf("[OpsMetricsCollector] leader lock held by another instance; skipping")
  815. }
  816. func floatToIntPtr(v sql.NullFloat64) *int {
  817. if !v.Valid {
  818. return nil
  819. }
  820. n := int(math.Round(v.Float64))
  821. return &n
  822. }
  823. func roundTo1DP(v float64) float64 {
  824. return math.Round(v*10) / 10
  825. }
  826. func truncateString(s string, max int) string {
  827. if max <= 0 {
  828. return ""
  829. }
  830. if len(s) <= max {
  831. return s
  832. }
  833. cut := s[:max]
  834. for len(cut) > 0 && !utf8.ValidString(cut) {
  835. cut = cut[:len(cut)-1]
  836. }
  837. return cut
  838. }
  839. func boolPtr(v bool) *bool {
  840. out := v
  841. return &out
  842. }
  843. func intPtr(v int) *int {
  844. out := v
  845. return &out
  846. }
  847. func float64Ptr(v float64) *float64 {
  848. out := v
  849. return &out
  850. }