ops_repo_trends.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606
  1. package repository
  2. import (
  3. "context"
  4. "database/sql"
  5. "fmt"
  6. "strings"
  7. "time"
  8. "github.com/Wei-Shaw/sub2api/internal/service"
  9. )
  10. func (r *opsRepository) GetThroughputTrend(ctx context.Context, filter *service.OpsDashboardFilter, bucketSeconds int) (*service.OpsThroughputTrendResponse, error) {
  11. if r == nil || r.db == nil {
  12. return nil, fmt.Errorf("nil ops repository")
  13. }
  14. if filter == nil {
  15. return nil, fmt.Errorf("nil filter")
  16. }
  17. if filter.StartTime.IsZero() || filter.EndTime.IsZero() {
  18. return nil, fmt.Errorf("start_time/end_time required")
  19. }
  20. if bucketSeconds <= 0 {
  21. bucketSeconds = 60
  22. }
  23. if bucketSeconds != 60 && bucketSeconds != 300 && bucketSeconds != 3600 {
  24. // Keep a small, predictable set of supported buckets for now.
  25. bucketSeconds = 60
  26. }
  27. start := filter.StartTime.UTC()
  28. end := filter.EndTime.UTC()
  29. usageJoin, usageWhere, usageArgs, next := buildUsageWhere(filter, start, end, 1)
  30. errorWhere, errorArgs, _ := buildErrorWhere(filter, start, end, next)
  31. usageBucketExpr := opsBucketExprForUsage(bucketSeconds)
  32. errorBucketExpr := opsBucketExprForError(bucketSeconds)
  33. q := `
  34. WITH usage_buckets AS (
  35. SELECT ` + usageBucketExpr + ` AS bucket,
  36. COUNT(*) AS success_count,
  37. COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) AS token_consumed
  38. FROM usage_logs ul
  39. ` + usageJoin + `
  40. ` + usageWhere + `
  41. GROUP BY 1
  42. ),
  43. error_buckets AS (
  44. SELECT ` + errorBucketExpr + ` AS bucket,
  45. COUNT(*) AS error_count
  46. FROM ops_error_logs
  47. ` + errorWhere + `
  48. AND COALESCE(status_code, 0) >= 400
  49. GROUP BY 1
  50. ),
  51. switch_buckets AS (
  52. SELECT ` + errorBucketExpr + ` AS bucket,
  53. COALESCE(SUM(CASE
  54. WHEN split_part(ev->>'kind', ':', 1) IN ('failover', 'retry_exhausted_failover', 'failover_on_400') THEN 1
  55. ELSE 0
  56. END), 0) AS switch_count
  57. FROM ops_error_logs
  58. CROSS JOIN LATERAL jsonb_array_elements(
  59. COALESCE(NULLIF(upstream_errors, 'null'::jsonb), '[]'::jsonb)
  60. ) AS ev
  61. ` + errorWhere + `
  62. AND upstream_errors IS NOT NULL
  63. GROUP BY 1
  64. ),
  65. combined AS (
  66. SELECT
  67. bucket,
  68. SUM(success_count) AS success_count,
  69. SUM(error_count) AS error_count,
  70. SUM(token_consumed) AS token_consumed,
  71. SUM(switch_count) AS switch_count
  72. FROM (
  73. SELECT bucket, success_count, 0 AS error_count, token_consumed, 0 AS switch_count
  74. FROM usage_buckets
  75. UNION ALL
  76. SELECT bucket, 0, error_count, 0, 0
  77. FROM error_buckets
  78. UNION ALL
  79. SELECT bucket, 0, 0, 0, switch_count
  80. FROM switch_buckets
  81. ) t
  82. GROUP BY bucket
  83. )
  84. SELECT
  85. bucket,
  86. (success_count + error_count) AS request_count,
  87. token_consumed,
  88. switch_count
  89. FROM combined
  90. ORDER BY bucket ASC`
  91. args := append(usageArgs, errorArgs...)
  92. rows, err := r.db.QueryContext(ctx, q, args...)
  93. if err != nil {
  94. return nil, err
  95. }
  96. defer func() { _ = rows.Close() }()
  97. points := make([]*service.OpsThroughputTrendPoint, 0, 256)
  98. for rows.Next() {
  99. var bucket time.Time
  100. var requests int64
  101. var tokens sql.NullInt64
  102. var switches sql.NullInt64
  103. if err := rows.Scan(&bucket, &requests, &tokens, &switches); err != nil {
  104. return nil, err
  105. }
  106. tokenConsumed := int64(0)
  107. if tokens.Valid {
  108. tokenConsumed = tokens.Int64
  109. }
  110. switchCount := int64(0)
  111. if switches.Valid {
  112. switchCount = switches.Int64
  113. }
  114. denom := float64(bucketSeconds)
  115. if denom <= 0 {
  116. denom = 60
  117. }
  118. qps := roundTo1DP(float64(requests) / denom)
  119. tps := roundTo1DP(float64(tokenConsumed) / denom)
  120. points = append(points, &service.OpsThroughputTrendPoint{
  121. BucketStart: bucket.UTC(),
  122. RequestCount: requests,
  123. TokenConsumed: tokenConsumed,
  124. SwitchCount: switchCount,
  125. QPS: qps,
  126. TPS: tps,
  127. })
  128. }
  129. if err := rows.Err(); err != nil {
  130. return nil, err
  131. }
  132. // Fill missing buckets with zeros so charts render continuous timelines.
  133. points = fillOpsThroughputBuckets(start, end, bucketSeconds, points)
  134. var byPlatform []*service.OpsThroughputPlatformBreakdownItem
  135. var topGroups []*service.OpsThroughputGroupBreakdownItem
  136. platform := ""
  137. if filter != nil {
  138. platform = strings.TrimSpace(strings.ToLower(filter.Platform))
  139. }
  140. groupID := (*int64)(nil)
  141. if filter != nil {
  142. groupID = filter.GroupID
  143. }
  144. // Drilldown helpers:
  145. // - No platform/group: totals by platform
  146. // - Platform selected but no group: top groups in that platform
  147. if platform == "" && (groupID == nil || *groupID <= 0) {
  148. items, err := r.getThroughputBreakdownByPlatform(ctx, start, end)
  149. if err != nil {
  150. return nil, err
  151. }
  152. byPlatform = items
  153. } else if platform != "" && (groupID == nil || *groupID <= 0) {
  154. items, err := r.getThroughputTopGroupsByPlatform(ctx, start, end, platform, 10)
  155. if err != nil {
  156. return nil, err
  157. }
  158. topGroups = items
  159. }
  160. return &service.OpsThroughputTrendResponse{
  161. Bucket: opsBucketLabel(bucketSeconds),
  162. Points: points,
  163. ByPlatform: byPlatform,
  164. TopGroups: topGroups,
  165. }, nil
  166. }
  167. func (r *opsRepository) getThroughputBreakdownByPlatform(ctx context.Context, start, end time.Time) ([]*service.OpsThroughputPlatformBreakdownItem, error) {
  168. q := `
  169. WITH usage_totals AS (
  170. SELECT COALESCE(NULLIF(g.platform,''), a.platform) AS platform,
  171. COUNT(*) AS success_count,
  172. COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) AS token_consumed
  173. FROM usage_logs ul
  174. LEFT JOIN groups g ON g.id = ul.group_id
  175. LEFT JOIN accounts a ON a.id = ul.account_id
  176. WHERE ul.created_at >= $1 AND ul.created_at < $2
  177. GROUP BY 1
  178. ),
  179. error_totals AS (
  180. SELECT platform,
  181. COUNT(*) AS error_count
  182. FROM ops_error_logs
  183. WHERE created_at >= $1 AND created_at < $2
  184. AND COALESCE(status_code, 0) >= 400
  185. AND is_count_tokens = FALSE -- 排除 count_tokens 请求的错误
  186. GROUP BY 1
  187. ),
  188. combined AS (
  189. SELECT COALESCE(u.platform, e.platform) AS platform,
  190. COALESCE(u.success_count, 0) AS success_count,
  191. COALESCE(e.error_count, 0) AS error_count,
  192. COALESCE(u.token_consumed, 0) AS token_consumed
  193. FROM usage_totals u
  194. FULL OUTER JOIN error_totals e ON u.platform = e.platform
  195. )
  196. SELECT platform, (success_count + error_count) AS request_count, token_consumed
  197. FROM combined
  198. WHERE platform IS NOT NULL AND platform <> ''
  199. ORDER BY request_count DESC`
  200. rows, err := r.db.QueryContext(ctx, q, start, end)
  201. if err != nil {
  202. return nil, err
  203. }
  204. defer func() { _ = rows.Close() }()
  205. items := make([]*service.OpsThroughputPlatformBreakdownItem, 0, 8)
  206. for rows.Next() {
  207. var platform string
  208. var requests int64
  209. var tokens sql.NullInt64
  210. if err := rows.Scan(&platform, &requests, &tokens); err != nil {
  211. return nil, err
  212. }
  213. tokenConsumed := int64(0)
  214. if tokens.Valid {
  215. tokenConsumed = tokens.Int64
  216. }
  217. items = append(items, &service.OpsThroughputPlatformBreakdownItem{
  218. Platform: platform,
  219. RequestCount: requests,
  220. TokenConsumed: tokenConsumed,
  221. })
  222. }
  223. if err := rows.Err(); err != nil {
  224. return nil, err
  225. }
  226. return items, nil
  227. }
  228. func (r *opsRepository) getThroughputTopGroupsByPlatform(ctx context.Context, start, end time.Time, platform string, limit int) ([]*service.OpsThroughputGroupBreakdownItem, error) {
  229. if strings.TrimSpace(platform) == "" {
  230. return nil, nil
  231. }
  232. if limit <= 0 || limit > 100 {
  233. limit = 10
  234. }
  235. q := `
  236. WITH usage_totals AS (
  237. SELECT ul.group_id AS group_id,
  238. g.name AS group_name,
  239. COUNT(*) AS success_count,
  240. COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) AS token_consumed
  241. FROM usage_logs ul
  242. JOIN groups g ON g.id = ul.group_id
  243. WHERE ul.created_at >= $1 AND ul.created_at < $2
  244. AND g.platform = $3
  245. GROUP BY 1, 2
  246. ),
  247. error_totals AS (
  248. SELECT group_id,
  249. COUNT(*) AS error_count
  250. FROM ops_error_logs
  251. WHERE created_at >= $1 AND created_at < $2
  252. AND platform = $3
  253. AND group_id IS NOT NULL
  254. AND COALESCE(status_code, 0) >= 400
  255. AND is_count_tokens = FALSE -- 排除 count_tokens 请求的错误
  256. GROUP BY 1
  257. ),
  258. combined AS (
  259. SELECT COALESCE(u.group_id, e.group_id) AS group_id,
  260. COALESCE(u.group_name, g2.name, '') AS group_name,
  261. COALESCE(u.success_count, 0) AS success_count,
  262. COALESCE(e.error_count, 0) AS error_count,
  263. COALESCE(u.token_consumed, 0) AS token_consumed
  264. FROM usage_totals u
  265. FULL OUTER JOIN error_totals e ON u.group_id = e.group_id
  266. LEFT JOIN groups g2 ON g2.id = COALESCE(u.group_id, e.group_id)
  267. )
  268. SELECT group_id, group_name, (success_count + error_count) AS request_count, token_consumed
  269. FROM combined
  270. WHERE group_id IS NOT NULL
  271. ORDER BY request_count DESC
  272. LIMIT $4`
  273. rows, err := r.db.QueryContext(ctx, q, start, end, platform, limit)
  274. if err != nil {
  275. return nil, err
  276. }
  277. defer func() { _ = rows.Close() }()
  278. items := make([]*service.OpsThroughputGroupBreakdownItem, 0, limit)
  279. for rows.Next() {
  280. var groupID int64
  281. var groupName sql.NullString
  282. var requests int64
  283. var tokens sql.NullInt64
  284. if err := rows.Scan(&groupID, &groupName, &requests, &tokens); err != nil {
  285. return nil, err
  286. }
  287. tokenConsumed := int64(0)
  288. if tokens.Valid {
  289. tokenConsumed = tokens.Int64
  290. }
  291. name := ""
  292. if groupName.Valid {
  293. name = groupName.String
  294. }
  295. items = append(items, &service.OpsThroughputGroupBreakdownItem{
  296. GroupID: groupID,
  297. GroupName: name,
  298. RequestCount: requests,
  299. TokenConsumed: tokenConsumed,
  300. })
  301. }
  302. if err := rows.Err(); err != nil {
  303. return nil, err
  304. }
  305. return items, nil
  306. }
  307. func opsBucketExprForUsage(bucketSeconds int) string {
  308. switch bucketSeconds {
  309. case 3600:
  310. return "date_trunc('hour', ul.created_at)"
  311. case 300:
  312. // 5-minute buckets in UTC.
  313. return "to_timestamp(floor(extract(epoch from ul.created_at) / 300) * 300)"
  314. default:
  315. return "date_trunc('minute', ul.created_at)"
  316. }
  317. }
  318. func opsBucketExprForError(bucketSeconds int) string {
  319. switch bucketSeconds {
  320. case 3600:
  321. return "date_trunc('hour', created_at)"
  322. case 300:
  323. return "to_timestamp(floor(extract(epoch from created_at) / 300) * 300)"
  324. default:
  325. return "date_trunc('minute', created_at)"
  326. }
  327. }
  328. func opsBucketLabel(bucketSeconds int) string {
  329. if bucketSeconds <= 0 {
  330. return "1m"
  331. }
  332. if bucketSeconds%3600 == 0 {
  333. h := bucketSeconds / 3600
  334. if h <= 0 {
  335. h = 1
  336. }
  337. return fmt.Sprintf("%dh", h)
  338. }
  339. m := bucketSeconds / 60
  340. if m <= 0 {
  341. m = 1
  342. }
  343. return fmt.Sprintf("%dm", m)
  344. }
  345. func opsFloorToBucketStart(t time.Time, bucketSeconds int) time.Time {
  346. t = t.UTC()
  347. if bucketSeconds <= 0 {
  348. bucketSeconds = 60
  349. }
  350. secs := t.Unix()
  351. floored := secs - (secs % int64(bucketSeconds))
  352. return time.Unix(floored, 0).UTC()
  353. }
  354. func fillOpsThroughputBuckets(start, end time.Time, bucketSeconds int, points []*service.OpsThroughputTrendPoint) []*service.OpsThroughputTrendPoint {
  355. if bucketSeconds <= 0 {
  356. bucketSeconds = 60
  357. }
  358. if !start.Before(end) {
  359. return points
  360. }
  361. endMinus := end.Add(-time.Nanosecond)
  362. if endMinus.Before(start) {
  363. return points
  364. }
  365. first := opsFloorToBucketStart(start, bucketSeconds)
  366. last := opsFloorToBucketStart(endMinus, bucketSeconds)
  367. step := time.Duration(bucketSeconds) * time.Second
  368. existing := make(map[int64]*service.OpsThroughputTrendPoint, len(points))
  369. for _, p := range points {
  370. if p == nil {
  371. continue
  372. }
  373. existing[p.BucketStart.UTC().Unix()] = p
  374. }
  375. out := make([]*service.OpsThroughputTrendPoint, 0, int(last.Sub(first)/step)+1)
  376. for cursor := first; !cursor.After(last); cursor = cursor.Add(step) {
  377. if p, ok := existing[cursor.Unix()]; ok && p != nil {
  378. out = append(out, p)
  379. continue
  380. }
  381. out = append(out, &service.OpsThroughputTrendPoint{
  382. BucketStart: cursor,
  383. RequestCount: 0,
  384. TokenConsumed: 0,
  385. SwitchCount: 0,
  386. QPS: 0,
  387. TPS: 0,
  388. })
  389. }
  390. return out
  391. }
  392. func (r *opsRepository) GetErrorTrend(ctx context.Context, filter *service.OpsDashboardFilter, bucketSeconds int) (*service.OpsErrorTrendResponse, error) {
  393. if r == nil || r.db == nil {
  394. return nil, fmt.Errorf("nil ops repository")
  395. }
  396. if filter == nil {
  397. return nil, fmt.Errorf("nil filter")
  398. }
  399. if filter.StartTime.IsZero() || filter.EndTime.IsZero() {
  400. return nil, fmt.Errorf("start_time/end_time required")
  401. }
  402. if bucketSeconds <= 0 {
  403. bucketSeconds = 60
  404. }
  405. if bucketSeconds != 60 && bucketSeconds != 300 && bucketSeconds != 3600 {
  406. bucketSeconds = 60
  407. }
  408. start := filter.StartTime.UTC()
  409. end := filter.EndTime.UTC()
  410. where, args, _ := buildErrorWhere(filter, start, end, 1)
  411. bucketExpr := opsBucketExprForError(bucketSeconds)
  412. q := `
  413. SELECT
  414. ` + bucketExpr + ` AS bucket,
  415. COUNT(*) FILTER (WHERE COALESCE(status_code, 0) >= 400) AS error_total,
  416. COUNT(*) FILTER (WHERE COALESCE(status_code, 0) >= 400 AND is_business_limited) AS business_limited,
  417. COUNT(*) FILTER (WHERE COALESCE(status_code, 0) >= 400 AND NOT is_business_limited) AS error_sla,
  418. COUNT(*) FILTER (WHERE error_owner = 'provider' AND NOT is_business_limited AND COALESCE(upstream_status_code, status_code, 0) NOT IN (429, 529)) AS upstream_excl,
  419. COUNT(*) FILTER (WHERE error_owner = 'provider' AND NOT is_business_limited AND COALESCE(upstream_status_code, status_code, 0) = 429) AS upstream_429,
  420. COUNT(*) FILTER (WHERE error_owner = 'provider' AND NOT is_business_limited AND COALESCE(upstream_status_code, status_code, 0) = 529) AS upstream_529
  421. FROM ops_error_logs
  422. ` + where + `
  423. GROUP BY 1
  424. ORDER BY 1 ASC`
  425. rows, err := r.db.QueryContext(ctx, q, args...)
  426. if err != nil {
  427. return nil, err
  428. }
  429. defer func() { _ = rows.Close() }()
  430. points := make([]*service.OpsErrorTrendPoint, 0, 256)
  431. for rows.Next() {
  432. var bucket time.Time
  433. var total, businessLimited, sla, upstreamExcl, upstream429, upstream529 int64
  434. if err := rows.Scan(&bucket, &total, &businessLimited, &sla, &upstreamExcl, &upstream429, &upstream529); err != nil {
  435. return nil, err
  436. }
  437. points = append(points, &service.OpsErrorTrendPoint{
  438. BucketStart: bucket.UTC(),
  439. ErrorCountTotal: total,
  440. BusinessLimitedCount: businessLimited,
  441. ErrorCountSLA: sla,
  442. UpstreamErrorCountExcl429529: upstreamExcl,
  443. Upstream429Count: upstream429,
  444. Upstream529Count: upstream529,
  445. })
  446. }
  447. if err := rows.Err(); err != nil {
  448. return nil, err
  449. }
  450. points = fillOpsErrorTrendBuckets(start, end, bucketSeconds, points)
  451. return &service.OpsErrorTrendResponse{
  452. Bucket: opsBucketLabel(bucketSeconds),
  453. Points: points,
  454. }, nil
  455. }
  456. func fillOpsErrorTrendBuckets(start, end time.Time, bucketSeconds int, points []*service.OpsErrorTrendPoint) []*service.OpsErrorTrendPoint {
  457. if bucketSeconds <= 0 {
  458. bucketSeconds = 60
  459. }
  460. if !start.Before(end) {
  461. return points
  462. }
  463. endMinus := end.Add(-time.Nanosecond)
  464. if endMinus.Before(start) {
  465. return points
  466. }
  467. first := opsFloorToBucketStart(start, bucketSeconds)
  468. last := opsFloorToBucketStart(endMinus, bucketSeconds)
  469. step := time.Duration(bucketSeconds) * time.Second
  470. existing := make(map[int64]*service.OpsErrorTrendPoint, len(points))
  471. for _, p := range points {
  472. if p == nil {
  473. continue
  474. }
  475. existing[p.BucketStart.UTC().Unix()] = p
  476. }
  477. out := make([]*service.OpsErrorTrendPoint, 0, int(last.Sub(first)/step)+1)
  478. for cursor := first; !cursor.After(last); cursor = cursor.Add(step) {
  479. if p, ok := existing[cursor.Unix()]; ok && p != nil {
  480. out = append(out, p)
  481. continue
  482. }
  483. out = append(out, &service.OpsErrorTrendPoint{
  484. BucketStart: cursor,
  485. ErrorCountTotal: 0,
  486. BusinessLimitedCount: 0,
  487. ErrorCountSLA: 0,
  488. UpstreamErrorCountExcl429529: 0,
  489. Upstream429Count: 0,
  490. Upstream529Count: 0,
  491. })
  492. }
  493. return out
  494. }
  495. func (r *opsRepository) GetErrorDistribution(ctx context.Context, filter *service.OpsDashboardFilter) (*service.OpsErrorDistributionResponse, error) {
  496. if r == nil || r.db == nil {
  497. return nil, fmt.Errorf("nil ops repository")
  498. }
  499. if filter == nil {
  500. return nil, fmt.Errorf("nil filter")
  501. }
  502. if filter.StartTime.IsZero() || filter.EndTime.IsZero() {
  503. return nil, fmt.Errorf("start_time/end_time required")
  504. }
  505. start := filter.StartTime.UTC()
  506. end := filter.EndTime.UTC()
  507. where, args, _ := buildErrorWhere(filter, start, end, 1)
  508. q := `
  509. SELECT
  510. COALESCE(upstream_status_code, status_code, 0) AS status_code,
  511. COUNT(*) AS total,
  512. COUNT(*) FILTER (WHERE NOT is_business_limited) AS sla,
  513. COUNT(*) FILTER (WHERE is_business_limited) AS business_limited
  514. FROM ops_error_logs
  515. ` + where + `
  516. AND COALESCE(status_code, 0) >= 400
  517. GROUP BY 1
  518. ORDER BY total DESC
  519. LIMIT 20`
  520. rows, err := r.db.QueryContext(ctx, q, args...)
  521. if err != nil {
  522. return nil, err
  523. }
  524. defer func() { _ = rows.Close() }()
  525. items := make([]*service.OpsErrorDistributionItem, 0, 16)
  526. var total int64
  527. for rows.Next() {
  528. var statusCode int
  529. var cntTotal, cntSLA, cntBiz int64
  530. if err := rows.Scan(&statusCode, &cntTotal, &cntSLA, &cntBiz); err != nil {
  531. return nil, err
  532. }
  533. total += cntTotal
  534. items = append(items, &service.OpsErrorDistributionItem{
  535. StatusCode: statusCode,
  536. Total: cntTotal,
  537. SLA: cntSLA,
  538. BusinessLimited: cntBiz,
  539. })
  540. }
  541. if err := rows.Err(); err != nil {
  542. return nil, err
  543. }
  544. return &service.OpsErrorDistributionResponse{
  545. Total: total,
  546. Items: items,
  547. }, nil
  548. }