stat.ts 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. import { sql } from "drizzle-orm"
  2. export const UPSERT_CHUNK_SIZE = 500
  3. const DAY_MS = 86_400_000
  4. export type StatGrain = "day" | "week"
  5. export type StatBaseAggregate = {
  6. grain: StatGrain
  7. period_key: string
  8. dataset: string
  9. tier: string
  10. sessions: number
  11. requests: number
  12. input_tokens: number
  13. output_tokens: number
  14. reasoning_tokens: number
  15. cache_read_tokens: number
  16. total_tokens: number
  17. input_cost_microcents: number
  18. output_cost_microcents: number
  19. total_cost_microcents: number
  20. avg_duration_ms: number | null
  21. p50_duration_ms: number | null
  22. p95_duration_ms: number | null
  23. avg_ttfb_ms: number | null
  24. p50_ttfb_ms: number | null
  25. p95_ttfb_ms: number | null
  26. avg_output_tps: number | null
  27. success_count: number
  28. error_count: number
  29. sample_count: number
  30. }
  31. export type StatBaseRow = {
  32. grain: string
  33. period_key: string
  34. dataset?: string
  35. tier?: string
  36. client?: string
  37. source?: string
  38. sessions?: number
  39. requests?: number
  40. input_tokens?: number
  41. output_tokens?: number
  42. reasoning_tokens?: number
  43. cache_read_tokens?: number
  44. total_tokens?: number
  45. input_cost_microcents?: number
  46. output_cost_microcents?: number
  47. total_cost_microcents?: number
  48. avg_duration_ms?: number | null
  49. p50_duration_ms?: number | null
  50. p95_duration_ms?: number | null
  51. avg_ttfb_ms?: number | null
  52. p50_ttfb_ms?: number | null
  53. p95_ttfb_ms?: number | null
  54. avg_output_tps?: number | null
  55. success_count?: number
  56. error_count?: number
  57. sample_count?: number
  58. }
  59. export function toStatBaseRow(data: StatBaseAggregate) {
  60. return {
  61. grain: data.grain,
  62. period_key: data.period_key,
  63. dataset: data.dataset,
  64. tier: data.tier,
  65. client: "all",
  66. source: "all",
  67. sessions: data.sessions,
  68. requests: data.requests,
  69. input_tokens: data.input_tokens,
  70. output_tokens: data.output_tokens,
  71. reasoning_tokens: data.reasoning_tokens,
  72. cache_read_tokens: data.cache_read_tokens,
  73. total_tokens: data.total_tokens,
  74. input_cost_microcents: data.input_cost_microcents,
  75. output_cost_microcents: data.output_cost_microcents,
  76. total_cost_microcents: data.total_cost_microcents,
  77. avg_duration_ms: data.avg_duration_ms,
  78. p50_duration_ms: data.p50_duration_ms,
  79. p95_duration_ms: data.p95_duration_ms,
  80. avg_ttfb_ms: data.avg_ttfb_ms,
  81. p50_ttfb_ms: data.p50_ttfb_ms,
  82. p95_ttfb_ms: data.p95_ttfb_ms,
  83. avg_output_tps: data.avg_output_tps,
  84. success_count: data.success_count,
  85. error_count: data.error_count,
  86. sample_count: data.sample_count,
  87. }
  88. }
  89. export function synthesizeAllTierRows<T extends StatBaseRow>(rows: T[], dimensionKey: (row: T) => string) {
  90. return [
  91. ...rows,
  92. ...Object.values(
  93. rows.reduce<Record<string, T>>((result, row) => {
  94. const key = [row.grain, row.period_key, row.dataset, row.client, row.source, dimensionKey(row)].join("\u0000")
  95. result[key] = result[key] ? combineRows(result[key], row) : { ...row, tier: "all" }
  96. return result
  97. }, {}),
  98. ),
  99. ]
  100. }
  101. export function collapseRows<T extends StatBaseRow>(rows: T[], dimensionKey: (row: T) => string) {
  102. return Object.values(
  103. rows.reduce<Record<string, T>>((result, row) => {
  104. const key = [row.grain, row.period_key, row.dataset, row.tier, row.client, row.source, dimensionKey(row)].join(
  105. "\u0000",
  106. )
  107. result[key] = result[key] ? combineRows(result[key], row) : row
  108. return result
  109. }, {}),
  110. )
  111. }
  112. export function combineRows<T extends StatBaseRow>(left: T, right: T): T {
  113. return {
  114. ...left,
  115. sessions: (left.sessions ?? 0) + (right.sessions ?? 0),
  116. requests: (left.requests ?? 0) + (right.requests ?? 0),
  117. input_tokens: (left.input_tokens ?? 0) + (right.input_tokens ?? 0),
  118. output_tokens: (left.output_tokens ?? 0) + (right.output_tokens ?? 0),
  119. reasoning_tokens: (left.reasoning_tokens ?? 0) + (right.reasoning_tokens ?? 0),
  120. cache_read_tokens: (left.cache_read_tokens ?? 0) + (right.cache_read_tokens ?? 0),
  121. total_tokens: (left.total_tokens ?? 0) + (right.total_tokens ?? 0),
  122. input_cost_microcents: (left.input_cost_microcents ?? 0) + (right.input_cost_microcents ?? 0),
  123. output_cost_microcents: (left.output_cost_microcents ?? 0) + (right.output_cost_microcents ?? 0),
  124. total_cost_microcents: (left.total_cost_microcents ?? 0) + (right.total_cost_microcents ?? 0),
  125. avg_duration_ms: weightedAverage(left.avg_duration_ms, left.requests, right.avg_duration_ms, right.requests),
  126. p50_duration_ms: null,
  127. p95_duration_ms: null,
  128. avg_ttfb_ms: weightedAverage(left.avg_ttfb_ms, left.requests, right.avg_ttfb_ms, right.requests),
  129. p50_ttfb_ms: null,
  130. p95_ttfb_ms: null,
  131. avg_output_tps: weightedAverage(left.avg_output_tps, left.requests, right.avg_output_tps, right.requests),
  132. success_count: (left.success_count ?? 0) + (right.success_count ?? 0),
  133. error_count: (left.error_count ?? 0) + (right.error_count ?? 0),
  134. sample_count: (left.sample_count ?? 0) + (right.sample_count ?? 0),
  135. }
  136. }
  137. export function statPeriodKey(row: StatBaseRow) {
  138. return [row.grain, row.period_key, row.dataset, row.tier, row.client, row.source].join("\u0000")
  139. }
  140. export function statRowScope(rows: StatBaseRow[]) {
  141. if (rows.length === 0) return
  142. return {
  143. grains: unique(rows.map((row) => row.grain)),
  144. periodKeys: unique(rows.map((row) => row.period_key)),
  145. datasets: unique(rows.map((row) => row.dataset ?? "all")),
  146. clients: unique(rows.map((row) => row.client ?? "all")),
  147. sources: unique(rows.map((row) => row.source ?? "all")),
  148. }
  149. }
  150. export function periodKeyFor(grain: StatGrain, periodStart: Date) {
  151. if (grain === "week") return isoWeekId(periodStart)
  152. return utcDateId(periodStart)
  153. }
  154. export function startOfUtcDay(value: Date) {
  155. return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()))
  156. }
  157. export function startOfIsoWeek(value: Date) {
  158. return new Date(
  159. Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate() - (value.getUTCDay() || 7) + 1),
  160. )
  161. }
  162. export function isoWeekId(value: Date) {
  163. const thursday = new Date(
  164. Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate() + 4 - (value.getUTCDay() || 7)),
  165. )
  166. return `${thursday.getUTCFullYear()}-W${String(Math.ceil(((thursday.getTime() - Date.UTC(thursday.getUTCFullYear(), 0, 1)) / DAY_MS + 1) / 7)).padStart(2, "0")}`
  167. }
  168. function utcDateId(value: Date) {
  169. return `${value.getUTCFullYear()}-${String(value.getUTCMonth() + 1).padStart(2, "0")}-${String(value.getUTCDate()).padStart(2, "0")}`
  170. }
  171. export function rankBy<T extends StatBaseRow>(rows: T[], value: (row: T) => number) {
  172. return new Map(rows.toSorted((a, b) => value(b) - value(a)).map((row, index) => [row, index + 1]))
  173. }
  174. export function rankRowsWithMarketShare<T extends StatBaseRow>(
  175. rows: T[],
  176. groupKey: (row: T) => string = statPeriodKey,
  177. ) {
  178. return Object.values(
  179. rows.reduce<Record<string, T[]>>((result, row) => {
  180. const key = groupKey(row)
  181. result[key] = [...(result[key] ?? []), row]
  182. return result
  183. }, {}),
  184. ).flatMap((group) => {
  185. const tokens = group.reduce((sum, row) => sum + (row.total_tokens ?? 0), 0)
  186. const requests = group.reduce((sum, row) => sum + (row.requests ?? 0), 0)
  187. const sessions = group.reduce((sum, row) => sum + (row.sessions ?? 0), 0)
  188. const tokenRanks = rankBy(group, (row) => row.total_tokens ?? 0)
  189. const requestRanks = rankBy(group, (row) => row.requests ?? 0)
  190. const sessionRanks = rankBy(group, (row) => row.sessions ?? 0)
  191. const costRanks = rankBy(group, (row) => row.total_cost_microcents ?? 0)
  192. return group.map((row) => ({
  193. ...row,
  194. market_share_tokens: share(row.total_tokens, tokens),
  195. market_share_requests: share(row.requests, requests),
  196. market_share_sessions: share(row.sessions, sessions),
  197. rank_by_tokens: tokenRanks.get(row) ?? null,
  198. rank_by_requests: requestRanks.get(row) ?? null,
  199. rank_by_sessions: sessionRanks.get(row) ?? null,
  200. rank_by_cost: costRanks.get(row) ?? null,
  201. }))
  202. })
  203. }
  204. export function share(value: number | null | undefined, total: number) {
  205. if (total <= 0) return null
  206. return Number(((value ?? 0) / total).toFixed(6))
  207. }
  208. export function chunks<T>(items: T[], size: number) {
  209. return Array.from({ length: Math.ceil(items.length / size) }, (_, index) =>
  210. items.slice(index * size, (index + 1) * size),
  211. )
  212. }
  213. function unique(values: string[]) {
  214. return [...new Set(values)]
  215. }
  216. export function inserted(column: string) {
  217. return sql.raw(`values(\`${column}\`)`)
  218. }
  219. export function weightedAverage(
  220. left: number | null | undefined,
  221. leftWeight = 0,
  222. right: number | null | undefined,
  223. rightWeight = 0,
  224. ) {
  225. const totalWeight =
  226. (left === null || left === undefined ? 0 : leftWeight) + (right === null || right === undefined ? 0 : rightWeight)
  227. if (totalWeight === 0) return null
  228. return Number((((left ?? 0) * leftWeight + (right ?? 0) * rightWeight) / totalWeight).toFixed(2))
  229. }
  230. export function normalizeTier(value: string) {
  231. if (value === "Paid") return "Zen"
  232. return value
  233. }
  234. export function normalizeCountry(value: string | undefined) {
  235. if (!value || value.length !== 2) return "ZZ"
  236. return value.toUpperCase()
  237. }