ops_repo_preagg.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373
  1. package repository
  2. import (
  3. "context"
  4. "database/sql"
  5. "fmt"
  6. "time"
  7. )
  8. func (r *opsRepository) UpsertHourlyMetrics(ctx context.Context, startTime, endTime time.Time) error {
  9. if r == nil || r.db == nil {
  10. return fmt.Errorf("nil ops repository")
  11. }
  12. if startTime.IsZero() || endTime.IsZero() || !endTime.After(startTime) {
  13. return nil
  14. }
  15. start := startTime.UTC()
  16. end := endTime.UTC()
  17. // NOTE:
  18. // - We aggregate usage_logs + ops_error_logs into ops_metrics_hourly.
  19. // - We emit three dimension granularities via GROUPING SETS:
  20. // 1) overall: (bucket_start)
  21. // 2) platform: (bucket_start, platform)
  22. // 3) group: (bucket_start, platform, group_id)
  23. //
  24. // IMPORTANT: Postgres UNIQUE treats NULLs as distinct, so the table uses a COALESCE-based
  25. // unique index; our ON CONFLICT target must match that expression set.
  26. q := `
  27. WITH usage_base AS (
  28. SELECT
  29. date_trunc('hour', ul.created_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' AS bucket_start,
  30. g.platform AS platform,
  31. ul.group_id AS group_id,
  32. ul.duration_ms AS duration_ms,
  33. ul.first_token_ms AS first_token_ms,
  34. (ul.input_tokens + ul.output_tokens + ul.cache_creation_tokens + ul.cache_read_tokens) AS tokens
  35. FROM usage_logs ul
  36. JOIN groups g ON g.id = ul.group_id
  37. WHERE ul.created_at >= $1 AND ul.created_at < $2
  38. ),
  39. usage_agg AS (
  40. SELECT
  41. bucket_start,
  42. CASE WHEN GROUPING(platform) = 1 THEN NULL ELSE platform END AS platform,
  43. CASE WHEN GROUPING(group_id) = 1 THEN NULL ELSE group_id END AS group_id,
  44. COUNT(*) AS success_count,
  45. COUNT(*) FILTER (WHERE first_token_ms IS NOT NULL) AS ttft_sample_count,
  46. COALESCE(SUM(tokens), 0) AS token_consumed,
  47. percentile_cont(0.50) WITHIN GROUP (ORDER BY duration_ms) FILTER (WHERE duration_ms IS NOT NULL) AS duration_p50_ms,
  48. percentile_cont(0.90) WITHIN GROUP (ORDER BY duration_ms) FILTER (WHERE duration_ms IS NOT NULL) AS duration_p90_ms,
  49. percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms) FILTER (WHERE duration_ms IS NOT NULL) AS duration_p95_ms,
  50. percentile_cont(0.99) WITHIN GROUP (ORDER BY duration_ms) FILTER (WHERE duration_ms IS NOT NULL) AS duration_p99_ms,
  51. AVG(duration_ms) FILTER (WHERE duration_ms IS NOT NULL) AS duration_avg_ms,
  52. MAX(duration_ms) AS duration_max_ms,
  53. percentile_cont(0.50) WITHIN GROUP (ORDER BY first_token_ms) FILTER (WHERE first_token_ms IS NOT NULL) AS ttft_p50_ms,
  54. percentile_cont(0.90) WITHIN GROUP (ORDER BY first_token_ms) FILTER (WHERE first_token_ms IS NOT NULL) AS ttft_p90_ms,
  55. percentile_cont(0.95) WITHIN GROUP (ORDER BY first_token_ms) FILTER (WHERE first_token_ms IS NOT NULL) AS ttft_p95_ms,
  56. percentile_cont(0.99) WITHIN GROUP (ORDER BY first_token_ms) FILTER (WHERE first_token_ms IS NOT NULL) AS ttft_p99_ms,
  57. AVG(first_token_ms) FILTER (WHERE first_token_ms IS NOT NULL) AS ttft_avg_ms,
  58. MAX(first_token_ms) AS ttft_max_ms
  59. FROM usage_base
  60. GROUP BY GROUPING SETS (
  61. (bucket_start),
  62. (bucket_start, platform),
  63. (bucket_start, platform, group_id)
  64. )
  65. ),
  66. error_base AS (
  67. SELECT
  68. date_trunc('hour', created_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' AS bucket_start,
  69. -- platform is NULL for some early-phase errors (e.g. before routing); map to a sentinel
  70. -- value so platform-level GROUPING SETS don't collide with the overall (platform=NULL) row.
  71. COALESCE(platform, 'unknown') AS platform,
  72. group_id AS group_id,
  73. is_business_limited AS is_business_limited,
  74. error_owner AS error_owner,
  75. status_code AS client_status_code,
  76. COALESCE(upstream_status_code, status_code, 0) AS effective_status_code
  77. FROM ops_error_logs
  78. -- Exclude count_tokens requests from error metrics as they are informational probes
  79. WHERE created_at >= $1 AND created_at < $2
  80. AND is_count_tokens = FALSE
  81. ),
  82. error_agg AS (
  83. SELECT
  84. bucket_start,
  85. CASE WHEN GROUPING(platform) = 1 THEN NULL ELSE platform END AS platform,
  86. CASE WHEN GROUPING(group_id) = 1 THEN NULL ELSE group_id END AS group_id,
  87. COUNT(*) FILTER (WHERE COALESCE(client_status_code, 0) >= 400) AS error_count_total,
  88. COUNT(*) FILTER (WHERE COALESCE(client_status_code, 0) >= 400 AND is_business_limited) AS business_limited_count,
  89. COUNT(*) FILTER (WHERE COALESCE(client_status_code, 0) >= 400 AND NOT is_business_limited) AS error_count_sla,
  90. COUNT(*) FILTER (WHERE error_owner = 'provider' AND NOT is_business_limited AND COALESCE(effective_status_code, 0) NOT IN (429, 529)) AS upstream_error_count_excl_429_529,
  91. COUNT(*) FILTER (WHERE error_owner = 'provider' AND NOT is_business_limited AND COALESCE(effective_status_code, 0) = 429) AS upstream_429_count,
  92. COUNT(*) FILTER (WHERE error_owner = 'provider' AND NOT is_business_limited AND COALESCE(effective_status_code, 0) = 529) AS upstream_529_count
  93. FROM error_base
  94. GROUP BY GROUPING SETS (
  95. (bucket_start),
  96. (bucket_start, platform),
  97. (bucket_start, platform, group_id)
  98. )
  99. HAVING GROUPING(group_id) = 1 OR group_id IS NOT NULL
  100. ),
  101. combined AS (
  102. SELECT
  103. COALESCE(u.bucket_start, e.bucket_start) AS bucket_start,
  104. COALESCE(u.platform, e.platform) AS platform,
  105. COALESCE(u.group_id, e.group_id) AS group_id,
  106. COALESCE(u.success_count, 0) AS success_count,
  107. COALESCE(u.ttft_sample_count, 0) AS ttft_sample_count,
  108. COALESCE(e.error_count_total, 0) AS error_count_total,
  109. COALESCE(e.business_limited_count, 0) AS business_limited_count,
  110. COALESCE(e.error_count_sla, 0) AS error_count_sla,
  111. COALESCE(e.upstream_error_count_excl_429_529, 0) AS upstream_error_count_excl_429_529,
  112. COALESCE(e.upstream_429_count, 0) AS upstream_429_count,
  113. COALESCE(e.upstream_529_count, 0) AS upstream_529_count,
  114. COALESCE(u.token_consumed, 0) AS token_consumed,
  115. u.duration_p50_ms,
  116. u.duration_p90_ms,
  117. u.duration_p95_ms,
  118. u.duration_p99_ms,
  119. u.duration_avg_ms,
  120. u.duration_max_ms,
  121. u.ttft_p50_ms,
  122. u.ttft_p90_ms,
  123. u.ttft_p95_ms,
  124. u.ttft_p99_ms,
  125. u.ttft_avg_ms,
  126. u.ttft_max_ms
  127. FROM usage_agg u
  128. FULL OUTER JOIN error_agg e
  129. ON u.bucket_start = e.bucket_start
  130. AND COALESCE(u.platform, '') = COALESCE(e.platform, '')
  131. AND COALESCE(u.group_id, 0) = COALESCE(e.group_id, 0)
  132. )
  133. INSERT INTO ops_metrics_hourly (
  134. bucket_start,
  135. platform,
  136. group_id,
  137. success_count,
  138. ttft_sample_count,
  139. error_count_total,
  140. business_limited_count,
  141. error_count_sla,
  142. upstream_error_count_excl_429_529,
  143. upstream_429_count,
  144. upstream_529_count,
  145. token_consumed,
  146. duration_p50_ms,
  147. duration_p90_ms,
  148. duration_p95_ms,
  149. duration_p99_ms,
  150. duration_avg_ms,
  151. duration_max_ms,
  152. ttft_p50_ms,
  153. ttft_p90_ms,
  154. ttft_p95_ms,
  155. ttft_p99_ms,
  156. ttft_avg_ms,
  157. ttft_max_ms,
  158. computed_at
  159. )
  160. SELECT
  161. bucket_start,
  162. NULLIF(platform, '') AS platform,
  163. group_id,
  164. success_count,
  165. ttft_sample_count,
  166. error_count_total,
  167. business_limited_count,
  168. error_count_sla,
  169. upstream_error_count_excl_429_529,
  170. upstream_429_count,
  171. upstream_529_count,
  172. token_consumed,
  173. duration_p50_ms::int,
  174. duration_p90_ms::int,
  175. duration_p95_ms::int,
  176. duration_p99_ms::int,
  177. duration_avg_ms,
  178. duration_max_ms::int,
  179. ttft_p50_ms::int,
  180. ttft_p90_ms::int,
  181. ttft_p95_ms::int,
  182. ttft_p99_ms::int,
  183. ttft_avg_ms,
  184. ttft_max_ms::int,
  185. NOW()
  186. FROM combined
  187. WHERE bucket_start IS NOT NULL
  188. AND (platform IS NULL OR platform <> '')
  189. ON CONFLICT (bucket_start, COALESCE(platform, ''), COALESCE(group_id, 0)) DO UPDATE SET
  190. success_count = EXCLUDED.success_count,
  191. ttft_sample_count = EXCLUDED.ttft_sample_count,
  192. error_count_total = EXCLUDED.error_count_total,
  193. business_limited_count = EXCLUDED.business_limited_count,
  194. error_count_sla = EXCLUDED.error_count_sla,
  195. upstream_error_count_excl_429_529 = EXCLUDED.upstream_error_count_excl_429_529,
  196. upstream_429_count = EXCLUDED.upstream_429_count,
  197. upstream_529_count = EXCLUDED.upstream_529_count,
  198. token_consumed = EXCLUDED.token_consumed,
  199. duration_p50_ms = EXCLUDED.duration_p50_ms,
  200. duration_p90_ms = EXCLUDED.duration_p90_ms,
  201. duration_p95_ms = EXCLUDED.duration_p95_ms,
  202. duration_p99_ms = EXCLUDED.duration_p99_ms,
  203. duration_avg_ms = EXCLUDED.duration_avg_ms,
  204. duration_max_ms = EXCLUDED.duration_max_ms,
  205. ttft_p50_ms = EXCLUDED.ttft_p50_ms,
  206. ttft_p90_ms = EXCLUDED.ttft_p90_ms,
  207. ttft_p95_ms = EXCLUDED.ttft_p95_ms,
  208. ttft_p99_ms = EXCLUDED.ttft_p99_ms,
  209. ttft_avg_ms = EXCLUDED.ttft_avg_ms,
  210. ttft_max_ms = EXCLUDED.ttft_max_ms,
  211. computed_at = NOW()
  212. `
  213. _, err := r.db.ExecContext(ctx, q, start, end)
  214. return err
  215. }
  216. func (r *opsRepository) UpsertDailyMetrics(ctx context.Context, startTime, endTime time.Time) error {
  217. if r == nil || r.db == nil {
  218. return fmt.Errorf("nil ops repository")
  219. }
  220. if startTime.IsZero() || endTime.IsZero() || !endTime.After(startTime) {
  221. return nil
  222. }
  223. start := startTime.UTC()
  224. end := endTime.UTC()
  225. q := `
  226. INSERT INTO ops_metrics_daily (
  227. bucket_date,
  228. platform,
  229. group_id,
  230. success_count,
  231. ttft_sample_count,
  232. error_count_total,
  233. business_limited_count,
  234. error_count_sla,
  235. upstream_error_count_excl_429_529,
  236. upstream_429_count,
  237. upstream_529_count,
  238. token_consumed,
  239. duration_p50_ms,
  240. duration_p90_ms,
  241. duration_p95_ms,
  242. duration_p99_ms,
  243. duration_avg_ms,
  244. duration_max_ms,
  245. ttft_p50_ms,
  246. ttft_p90_ms,
  247. ttft_p95_ms,
  248. ttft_p99_ms,
  249. ttft_avg_ms,
  250. ttft_max_ms,
  251. computed_at
  252. )
  253. SELECT
  254. (bucket_start AT TIME ZONE 'UTC')::date AS bucket_date,
  255. platform,
  256. group_id,
  257. COALESCE(SUM(success_count), 0) AS success_count,
  258. COALESCE(SUM(ttft_sample_count), 0) AS ttft_sample_count,
  259. COALESCE(SUM(error_count_total), 0) AS error_count_total,
  260. COALESCE(SUM(business_limited_count), 0) AS business_limited_count,
  261. COALESCE(SUM(error_count_sla), 0) AS error_count_sla,
  262. COALESCE(SUM(upstream_error_count_excl_429_529), 0) AS upstream_error_count_excl_429_529,
  263. COALESCE(SUM(upstream_429_count), 0) AS upstream_429_count,
  264. COALESCE(SUM(upstream_529_count), 0) AS upstream_529_count,
  265. COALESCE(SUM(token_consumed), 0) AS token_consumed,
  266. -- Approximation: weighted average for p50/p90, max for p95/p99 (conservative tail).
  267. ROUND(SUM(duration_p50_ms::double precision * success_count) FILTER (WHERE duration_p50_ms IS NOT NULL)
  268. / NULLIF(SUM(success_count) FILTER (WHERE duration_p50_ms IS NOT NULL), 0))::int AS duration_p50_ms,
  269. ROUND(SUM(duration_p90_ms::double precision * success_count) FILTER (WHERE duration_p90_ms IS NOT NULL)
  270. / NULLIF(SUM(success_count) FILTER (WHERE duration_p90_ms IS NOT NULL), 0))::int AS duration_p90_ms,
  271. MAX(duration_p95_ms) AS duration_p95_ms,
  272. MAX(duration_p99_ms) AS duration_p99_ms,
  273. SUM(duration_avg_ms * success_count) FILTER (WHERE duration_avg_ms IS NOT NULL)
  274. / NULLIF(SUM(success_count) FILTER (WHERE duration_avg_ms IS NOT NULL), 0) AS duration_avg_ms,
  275. MAX(duration_max_ms) AS duration_max_ms,
  276. -- TTFT is weighted by ttft_sample_count (streaming rows only), NOT success_count,
  277. -- because first_token_ms is recorded only for streaming requests.
  278. ROUND(SUM(ttft_p50_ms::double precision * ttft_sample_count) FILTER (WHERE ttft_p50_ms IS NOT NULL)
  279. / NULLIF(SUM(ttft_sample_count) FILTER (WHERE ttft_p50_ms IS NOT NULL), 0))::int AS ttft_p50_ms,
  280. ROUND(SUM(ttft_p90_ms::double precision * ttft_sample_count) FILTER (WHERE ttft_p90_ms IS NOT NULL)
  281. / NULLIF(SUM(ttft_sample_count) FILTER (WHERE ttft_p90_ms IS NOT NULL), 0))::int AS ttft_p90_ms,
  282. MAX(ttft_p95_ms) AS ttft_p95_ms,
  283. MAX(ttft_p99_ms) AS ttft_p99_ms,
  284. SUM(ttft_avg_ms * ttft_sample_count) FILTER (WHERE ttft_avg_ms IS NOT NULL)
  285. / NULLIF(SUM(ttft_sample_count) FILTER (WHERE ttft_avg_ms IS NOT NULL), 0) AS ttft_avg_ms,
  286. MAX(ttft_max_ms) AS ttft_max_ms,
  287. NOW()
  288. FROM ops_metrics_hourly
  289. WHERE bucket_start >= $1 AND bucket_start < $2
  290. GROUP BY 1, 2, 3
  291. ON CONFLICT (bucket_date, COALESCE(platform, ''), COALESCE(group_id, 0)) DO UPDATE SET
  292. success_count = EXCLUDED.success_count,
  293. ttft_sample_count = EXCLUDED.ttft_sample_count,
  294. error_count_total = EXCLUDED.error_count_total,
  295. business_limited_count = EXCLUDED.business_limited_count,
  296. error_count_sla = EXCLUDED.error_count_sla,
  297. upstream_error_count_excl_429_529 = EXCLUDED.upstream_error_count_excl_429_529,
  298. upstream_429_count = EXCLUDED.upstream_429_count,
  299. upstream_529_count = EXCLUDED.upstream_529_count,
  300. token_consumed = EXCLUDED.token_consumed,
  301. duration_p50_ms = EXCLUDED.duration_p50_ms,
  302. duration_p90_ms = EXCLUDED.duration_p90_ms,
  303. duration_p95_ms = EXCLUDED.duration_p95_ms,
  304. duration_p99_ms = EXCLUDED.duration_p99_ms,
  305. duration_avg_ms = EXCLUDED.duration_avg_ms,
  306. duration_max_ms = EXCLUDED.duration_max_ms,
  307. ttft_p50_ms = EXCLUDED.ttft_p50_ms,
  308. ttft_p90_ms = EXCLUDED.ttft_p90_ms,
  309. ttft_p95_ms = EXCLUDED.ttft_p95_ms,
  310. ttft_p99_ms = EXCLUDED.ttft_p99_ms,
  311. ttft_avg_ms = EXCLUDED.ttft_avg_ms,
  312. ttft_max_ms = EXCLUDED.ttft_max_ms,
  313. computed_at = NOW()
  314. `
  315. _, err := r.db.ExecContext(ctx, q, start, end)
  316. return err
  317. }
  318. func (r *opsRepository) GetLatestHourlyBucketStart(ctx context.Context) (time.Time, bool, error) {
  319. if r == nil || r.db == nil {
  320. return time.Time{}, false, fmt.Errorf("nil ops repository")
  321. }
  322. var value sql.NullTime
  323. if err := r.db.QueryRowContext(ctx, `SELECT MAX(bucket_start) FROM ops_metrics_hourly`).Scan(&value); err != nil {
  324. return time.Time{}, false, err
  325. }
  326. if !value.Valid {
  327. return time.Time{}, false, nil
  328. }
  329. return value.Time.UTC(), true, nil
  330. }
  331. func (r *opsRepository) GetLatestDailyBucketDate(ctx context.Context) (time.Time, bool, error) {
  332. if r == nil || r.db == nil {
  333. return time.Time{}, false, fmt.Errorf("nil ops repository")
  334. }
  335. var value sql.NullTime
  336. if err := r.db.QueryRowContext(ctx, `SELECT MAX(bucket_date) FROM ops_metrics_daily`).Scan(&value); err != nil {
  337. return time.Time{}, false, err
  338. }
  339. if !value.Valid {
  340. return time.Time{}, false, nil
  341. }
  342. t := value.Time.UTC()
  343. return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC), true, nil
  344. }