diff --git a/bun.lock b/bun.lock index c0958972cb..a216ef036f 100644 --- a/bun.lock +++ b/bun.lock @@ -627,6 +627,7 @@ "name": "@opencode-ai/stats-core", "version": "1.14.50", "dependencies": { + "@planetscale/database": "1.19.0", "drizzle-orm": "catalog:", "effect": "catalog:", }, diff --git a/infra/stats.ts b/infra/stats.ts index bf7ef734ba..bafe85dce5 100644 --- a/infra/stats.ts +++ b/infra/stats.ts @@ -87,7 +87,7 @@ export const app = new sst.aws.SolidStart("Stats", { //////////////// export const statSync = new sst.aws.Cron("StatsSync", { - schedule: "rate(1 hour)", + schedule: "rate(1 minute)", function: { handler: "packages/stats/core/src/cron/stat.handler", runtime: "nodejs22.x", diff --git a/packages/stats/app/src/routes/rankings.css b/packages/stats/app/src/routes/rankings.css index 2231427c19..c7985a3b65 100644 --- a/packages/stats/app/src/routes/rankings.css +++ b/packages/stats/app/src/routes/rankings.css @@ -295,6 +295,30 @@ display: grid; } +[data-page="rankings"] [data-component="empty-state"] { + display: grid; + place-content: center; + gap: 12px; + min-height: 280px; + padding: 32px; + background: var(--rankings-layer); + border: 1px solid var(--rankings-line); + color: var(--rankings-muted); + text-align: center; +} + +[data-page="rankings"] [data-component="empty-state"] strong { + color: var(--rankings-text); + font-weight: 600; +} + +[data-page="rankings"] [data-component="empty-state"] p { + max-width: 34rem; + color: var(--rankings-muted); + font-size: 13px; + line-height: 1.5; +} + [data-page="rankings"] [data-component="usage-chart"] svg, [data-page="rankings"] [data-component="country-map"] svg { display: block; diff --git a/packages/stats/app/src/routes/rankings.tsx b/packages/stats/app/src/routes/rankings.tsx index 534661f68b..5d21808db7 100644 --- a/packages/stats/app/src/routes/rankings.tsx +++ b/packages/stats/app/src/routes/rankings.tsx @@ -1,191 +1,75 @@ import "./rankings.css" import { Meta, Title } from "@solidjs/meta" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { + getRankingsData as fetchRankingsData, + type LeaderboardEntry, + type MarketDay, + type RankingsData, + type SessionCostEntry, + type TokenCostEntry, + type UsagePoint, +} from "@opencode-ai/stats-core/domain/ranking" +import { runtime } from "@opencode-ai/stats-core/runtime" +import { createAsync, query } from "@solidjs/router" import { scaleBand, scaleLinear } from "d3-scale" import { createMemo, createSignal, For, Show, type JSX } from "solid-js" +import { getRequestEvent } from "solid-js/web" const products = ["All Users", "Zen", "Go", "Enterprise"] as const const tokenProducts = ["Zen", "Go", "Enterprise"] as const const ranges = ["1D", "1W", "1M", "3M", "YTD", "ALL"] as const const usageColors = ["#ff5d64", "#ff8a00", "#8bef00", "#12c8b3", "#18c7dc", "#6c7dff", "#9d73f7"] const marketColors = ["#ed6aff", "#a684ff", "#7c86ff", "#51a2ff", "#00d3f2", "#00d5be", "#00bc7d", "#9ae600", "#ffb900"] -const usageModels = [ - "minimax-m2.5-free", - "big-pickle", - "kimi-k2.5", - "gpt-5-nano", - "nemotron-3-super-free", - "claude-opus-4-6", - "Other", -] as const type UsageProduct = (typeof products)[number] type TokenProduct = (typeof tokenProducts)[number] type UsageRange = (typeof ranges)[number] -type UsagePoint = { date: string; segments: { model: string; value: number }[] } -type MarketDay = { date: string; total: number; authors: { author: string; share: number; tokens: number }[] } -type LeaderboardEntry = { - model: string - author: string - tokens: number - change: number - products: readonly UsageProduct[] -} -const usageValues = [ - [0.42, 0.34, 0.22, 0.18, 0.16, 0.1, 0.58], - [0.76, 0.66, 0.5, 0.34, 0.27, 0.2, 1.18], - [0.92, 0.72, 0.48, 0.32, 0.26, 0.19, 1.11], - [0.58, 0.46, 0.35, 0.26, 0.22, 0.17, 0.76], - [1.8, 1.5, 0.27, 0.08, 0.23, 0.12, 0.75], - [1.74, 1.38, 1.02, 0.78, 0.68, 0.56, 1.34], - [1.94, 1.58, 1.18, 0.88, 0.73, 0.64, 1.48], -] as const - -const usageDates = { - "1D": ["12AM", "4AM", "8AM", "12PM", "4PM", "8PM", "NOW"], - "1W": ["MAR 6", "MAR 7", "MAR 8", "MAR 9", "MAR 10", "MAR 11", "MAR 12"], - "1M": ["FEB 14", "FEB 19", "FEB 24", "MAR 1", "MAR 6", "MAR 11", "MAR 16"], - "3M": ["DEC 16", "JAN 1", "JAN 17", "FEB 2", "FEB 18", "MAR 6", "MAR 16"], - YTD: ["JAN", "JAN", "FEB", "FEB", "MAR", "MAR", "NOW"], - ALL: ["2024", "Q3", "Q4", "JAN", "FEB", "MAR", "NOW"], -} satisfies Record - -const usageProductMultipliers = { - "All Users": 1, - Zen: 0.46, - Go: 0.34, - Enterprise: 0.22, -} satisfies Record - -const usageRangeMultipliers = { - "1D": 0.14, - "1W": 1, - "1M": 3.8, - "3M": 10.6, - YTD: 18.4, - ALL: 31.2, -} satisfies Record - -const marketTotals = { - "1D": [0.32, 0.61, 0.68, 0.47, 0.51, 0.84, 0.9], - "1W": [2, 3.9, 4, 2.8, 2.8, 7.5, 7.5], - "1M": [8.4, 10.6, 12.8, 11.9, 13.2, 15.7, 16.1], - "3M": [22.4, 28.1, 32.7, 30.4, 34.8, 39.9, 42.2], - YTD: [34.8, 43.1, 52.6, 58.2, 64.1, 72.8, 79.4], - ALL: [91.2, 118.4, 142.7, 166.3, 188.9, 221.6, 246.8], -} satisfies Record - -const leaderboard: readonly LeaderboardEntry[] = [ - { model: "GPT-5.4-mini", author: "OpenAI", tokens: 314, change: 17, products: ["All Users", "Zen", "Go"] }, - { model: "minimax-m2.5-free", author: "MiniMax", tokens: 286, change: 11, products: ["All Users", "Zen"] }, - { model: "kimi-k2.5", author: "Moonshot", tokens: 252, change: -3, products: ["All Users", "Go", "Enterprise"] }, - { model: "claude-sonnet-4-6", author: "Anthropic", tokens: 219, change: -2, products: ["All Users", "Enterprise"] }, - { model: "gemini-3-flash", author: "Google", tokens: 201, change: 6, products: ["All Users", "Go"] }, - { model: "glm-5", author: "Zhipu", tokens: 177, change: -8, products: ["All Users", "Enterprise"] }, - { model: "gpt-5.3-codex", author: "OpenAI", tokens: 152, change: 10, products: ["All Users", "Zen", "Enterprise"] }, - { model: "claude-haiku-4-5", author: "Anthropic", tokens: 130, change: 14, products: ["All Users", "Zen"] }, - { model: "nemotron-3-super-free", author: "Nvidia", tokens: 117, change: -5, products: ["All Users", "Go"] }, - { model: "gemini-3.1-pro", author: "Google", tokens: 96, change: 1, products: ["All Users", "Enterprise"] }, - { model: "minimax-m2.7", author: "MiniMax", tokens: 81, change: -4, products: ["All Users", "Go"] }, - { model: "gpt-5.4", author: "OpenAI", tokens: 64, change: 5, products: ["All Users", "Enterprise"] }, - { model: "claude-opus-4-6", author: "Anthropic", tokens: 52, change: 2, products: ["All Users", "Enterprise"] }, -] - -const market = [ - { author: "OpenCode", share: 23.1, tokens: "1.33T", values: [18, 19, 23, 22, 24, 23, 25] }, - { author: "Minimax", share: 19.4, tokens: "1.12T", values: [14, 18, 17, 20, 18, 21, 19] }, - { author: "Xiaomi", share: 13.2, tokens: "762B", values: [10, 12, 14, 13, 13, 15, 14] }, - { author: "Moonshot", share: 11.8, tokens: "681B", values: [13, 11, 12, 10, 12, 11, 12] }, - { author: "Nvidia", share: 9.7, tokens: "560B", values: [8, 9, 8, 10, 9, 9, 10] }, - { author: "OpenAI", share: 8.6, tokens: "496B", values: [12, 10, 9, 8, 9, 8, 8] }, - { author: "Anthropic", share: 6.9, tokens: "398B", values: [7, 6, 7, 7, 6, 7, 7] }, - { author: "Zhipu", share: 4.1, tokens: "236B", values: [4, 5, 4, 4, 4, 4, 4] }, - { author: "Other", share: 3.2, tokens: "184B", values: [14, 10, 6, 6, 5, 2, 1] }, -] - -const tokenCosts = [ - ["minimax-m2.5", 0.06], - ["minimax-m2.7", 0.09], - ["gemini-3-flash", 0.11], - ["kimi-k2.5", 0.16], - ["gpt-5.4-mini", 0.16], - ["claude-haiku-4-5", 0.2], - ["glm-5", 0.28], - ["gpt-5.3-codex", 0.41], - ["gemini-3.1-pro", 0.43], - ["gpt-5.4", 0.54], - ["claude-sonnet-4-6", 0.61], - ["claude-sonnet-4-5", 0.61], - ["claude-opus-4-6", 1.02], -] as const - -const sessionCosts = [ - ["gpt-5.4-nano", 0.0228, "212K"], - ["gpt-5.1-codex-mini", 0.0716, "534K"], - ["minimax-m2.5", 0.1058, "898K"], - ["claude-haiku-4-5", 0.1456, "734K"], - ["gpt-5.4-mini", 0.1817, "646K"], - ["minimax-m2.7", 0.2035, "715K"], - ["gpt-5.4", 0.2228, "488K"], - ["kimi-k2.5", 0.2646, "1.1M"], - ["gemini-3-flash", 0.273, "829K"], - ["glm-5", 0.3591, "925K"], - ["claude-sonnet-4-6", 0.7608, "1.4M"], - ["gpt-5.3-codex", 0.7784, "1.2M"], - ["claude-sonnet-4-5", 1.001, "1.6M"], - ["gemini-3.1-pro", 1.0831, "1.5M"], - ["claude-opus-4-6", 2.6844, "2.2M"], - ["claude-opus-4-5", 2.2732, "2.0M"], - ["gpt-5.4-pr", 5.186, "3.3M"], -] as const - -const countries = [ - ["United States", "520B", 30, 44, 28], - ["Canada", "130B", 24, 30, 16], - ["Brazil", "88B", 37, 70, 12], - ["Germany", "112B", 52, 39, 14], - ["India", "184B", 68, 58, 18], - ["Japan", "92B", 84, 50, 12], -] as const +const getData = query(async () => { + "use server" + return runtime.runPromise(fetchRankingsData()) +}, "getRankingsData") export default function Rankings() { + getRequestEvent()?.response.headers.set( + "Cache-Control", + "public, max-age=60, s-maxage=300, stale-while-revalidate=86400", + ) + const data = createAsync(() => getData()) + return (
Model Rankings | opencode
-
-
-

Model Rankings

-

- - OpenCode data · Showing 25% -

-
-

- See which models are winning real usage, how the mix shifts over time, and where momentum is moving each - week. -

-
- - - - - - }> - - - + }> + {(rankings) => ( + <> + + + + + + + + + + + + )} +
@@ -194,6 +78,39 @@ export default function Rankings() { ) } +function Hero(props: { updatedAt: string | null }) { + return ( +
+
+

Model Rankings

+

+ + OpenCode data ·{" "} + {props.updatedAt ? `Updated ${formatUpdatedAt(props.updatedAt)}` : "No rows yet"} +

+
+

+ See which models are winning real usage, how the mix shifts over time, and where momentum is moving each week. +

+
+ ) +} + +function RankingsLoading() { + return ( + <> + + + + + + ) +} + function ChartSection(props: { title: string; description?: string; controls?: JSX.Element; children: JSX.Element }) { return (
@@ -209,23 +126,41 @@ function ChartSection(props: { title: string; description?: string; controls?: J ) } -function Controls(props: { includeProducts?: boolean }) { +function EmptyState(props: { title: string; description: string }) { return ( -
- {props.includeProducts && } - +
+ {props.title} +

{props.description}

) } -function UsageSection() { +function formatUpdatedAt(value: string) { + const date = new Date(value) + if (Number.isNaN(date.getTime())) return "just now" + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + timeZone: "UTC", + timeZoneName: "short", + }).format(date) +} + +function UsageSection(props: { data: RankingsData["usage"] }) { const [product, setProduct] = createSignal("All Users") const [range, setRange] = createSignal("1W") - const data = createMemo(() => getUsageData(product(), range())) + const data = createMemo(() => props.data[product()][range()]) return ( - + usageTotal(item) > 0)} + fallback={} + > + +
@@ -285,26 +220,6 @@ function FilterPills(props: { ) } -function ProductPills(props: { live?: boolean }) { - return ( -
- - {(item, index) => } - -
- ) -} - -function RangePills() { - return ( -
- - {(item, index) => } - -
- ) -} - function UsageChart(props: { data: UsagePoint[] }) { const [activeIndex, setActiveIndex] = createSignal() const [activeSegment, setActiveSegment] = createSignal() @@ -312,7 +227,7 @@ function UsageChart(props: { data: UsagePoint[] }) { const width = 920 const headerOffset = 46 const segmentGap = 2 - const maxTotal = createMemo(() => Math.max(...props.data.map((item) => usageTotal(item))) * 1.02) + const maxTotal = createMemo(() => Math.max(1, Math.max(...props.data.map((item) => usageTotal(item))) * 1.02)) const activePoint = createMemo(() => props.data[activeIndex() ?? -1]) const y = createMemo(() => scaleLinear([0, maxTotal()], [height, 0])) const x = createMemo(() => @@ -454,25 +369,6 @@ function UsageChart(props: { data: UsagePoint[] }) { ) } -function getUsageData(product: UsageProduct, range: UsageRange) { - return usageDates[range].map((date, dayIndex) => ({ - date, - segments: (usageValues[(dayIndex + ranges.indexOf(range)) % usageValues.length] ?? []).map( - (value, segmentIndex) => ({ - model: usageModels[segmentIndex] ?? "Other", - value: Number( - ( - value * - usageProductMultipliers[product] * - usageRangeMultipliers[range] * - (1 + (((dayIndex + segmentIndex + products.indexOf(product) + ranges.indexOf(range)) % 5) - 2) * 0.055) - ).toFixed(2), - ), - }), - ), - })) -} - function getUsageTooltipStyle(barX: number, barWidth: number, width: number) { if (barX > width * 0.62) return { left: "auto", right: `${((width - barX + 12) / width) * 100}%` } return { left: `${((barX + barWidth + 12) / width) * 100}%`, right: "auto" } @@ -493,17 +389,22 @@ function formatTokens(value: number) { return `${Math.round(value * 1000)}B` } -function LeaderboardSection() { +function LeaderboardSection(props: { data: RankingsData["leaderboard"] }) { const [product, setProduct] = createSignal("All Users") const [range, setRange] = createSignal("1W") - const data = createMemo(() => getLeaderboardData(product(), range())) + const data = createMemo(() => props.data[product()][range()]) return ( - + 0} + fallback={} + > + +
@@ -511,7 +412,7 @@ function LeaderboardSection() { ) } -function Leaderboard(props: { data: (LeaderboardEntry & { rank: number })[] }) { +function Leaderboard(props: { data: LeaderboardEntry[] }) { return (
@@ -526,7 +427,7 @@ function Leaderboard(props: { data: (LeaderboardEntry & { rank: number })[] }) { ) } -function LeaderboardCard(props: { entry: LeaderboardEntry & { rank: number }; size: "featured" | "compact" }) { +function LeaderboardCard(props: { entry: LeaderboardEntry; size: "featured" | "compact" }) { return (
{String(props.entry.rank).padStart(2, "0")} @@ -550,18 +451,6 @@ function LeaderboardCard(props: { entry: LeaderboardEntry & { rank: number }; si ) } -function getLeaderboardData(product: UsageProduct, range: UsageRange) { - return leaderboard - .filter((entry) => entry.products.includes(product)) - .map((entry, index) => ({ - ...entry, - tokens: Math.round(entry.tokens * usageProductMultipliers[product] * usageRangeMultipliers[range]), - change: entry.change + (((index + products.indexOf(product) + ranges.indexOf(range)) % 5) - 2), - })) - .sort((a, b) => b.tokens - a.tokens) - .map((entry, index) => ({ ...entry, rank: index + 1 })) -} - function getProviderIconId(author: string) { if (author === "MiniMax") return "minimax" if (author === "Moonshot") return "moonshotai" @@ -579,20 +468,30 @@ function formatChange(value: number) { return `${value}%` } -function MarketShareSection() { +function MarketShareSection(props: { data: RankingsData["market"] }) { const [range, setRange] = createSignal("1W") const [activeIndex, setActiveIndex] = createSignal(2) - const data = createMemo(() => getMarketData(range())) - const activeDay = createMemo(() => data()[activeIndex()] ?? data()[0]) + const data = createMemo(() => props.data[range()]) + const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(data().length - 1, 0))) + const activeDay = createMemo(() => data()[selectedIndex()]) return ( - - + } + > + {(day) => ( + <> + + + + )} +

[*] - {activeDay().date} 2026 + {activeDay()?.date ?? "No data"}

@@ -662,44 +561,26 @@ function MarketShareList(props: { data: MarketDay["authors"] }) { ) } -function getMarketData(range: UsageRange) { - return usageDates[range].map((date, dayIndex) => { - const authors = market.map((item, authorIndex) => ({ - author: item.author, - share: Number( - Math.max( - 1.2, - item.values[dayIndex] + (((dayIndex + authorIndex + ranges.indexOf(range)) % 5) - 2) * 0.4, - ).toFixed(1), - ), - tokens: 0, - })) - const totalShare = authors.reduce((sum, item) => sum + item.share, 0) - return { - date, - total: marketTotals[range][dayIndex] ?? 0, - authors: authors.map((item) => ({ - ...item, - share: Number(((item.share / totalShare) * 100).toFixed(1)), - tokens: Number((((marketTotals[range][dayIndex] ?? 0) * item.share) / totalShare).toFixed(2)), - })), - } - }) -} - function formatTrillions(value: number) { return `${value.toFixed(value >= 10 ? 0 : 1)}T` } -function TokenCostSection() { +function TokenCostSection(props: { data: RankingsData["tokenCost"] }) { const [product, setProduct] = createSignal("Zen") - const [live, setLive] = createSignal(true) const [activeIndex, setActiveIndex] = createSignal(2) - const data = createMemo(() => getTokenCostData(product(), live())) + const data = createMemo(() => props.data[product()]) + const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(data().length - 1, 0))) return ( - + 0} + fallback={ + + } + > + +
-
) } function TokenCostChart(props: { - data: ReturnType + data: TokenCostEntry[] activeIndex: number onActiveIndexChange: (index: number) => void }) { - const max = createMemo(() => Math.max(...props.data.map((item) => item.total))) + const max = createMemo(() => Math.max(1, ...props.data.map((item) => item.total))) const active = createMemo(() => props.data[props.activeIndex] ?? props.data[0]) return ( @@ -768,20 +641,6 @@ function TokenCostChart(props: { ) } -function getTokenCostData(product: TokenProduct, live: boolean) { - return tokenCosts.map((item, index) => { - const multiplier = (product === "Zen" ? 1 : product === "Go" ? 0.88 : 1.18) * (live ? 1 : 0.94) - const total = Number((item[1] * multiplier).toFixed(2)) - return { - model: item[0], - total, - input: Number((total * (index < 3 ? 0.18 : 0.22)).toFixed(2)), - output: Number((total * (index < 3 ? 1.1 : 1.34)).toFixed(2)), - cached: Number((total * 0.1).toFixed(2)), - } - }) -} - function formatDollars(value: number) { return `$${value.toFixed(2)}` } @@ -789,21 +648,28 @@ function formatDollars(value: number) { function MetricBar(props: { value: number; max: number; active: boolean }) { return ( - + ) } -function SessionCostSection() { +function SessionCostSection(props: { data: RankingsData["sessionCost"] }) { const [product, setProduct] = createSignal("Zen") - const [live, setLive] = createSignal(true) const [activeIndex, setActiveIndex] = createSignal(2) - const data = createMemo(() => getSessionCostData(product(), live())) + const data = createMemo(() => props.data[product()]) + const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(data().length - 1, 0))) return ( - + 0} + fallback={ + + } + > + +
-
) } function SessionCostChart(props: { - data: ReturnType + data: SessionCostEntry[] activeIndex: number onActiveIndexChange: (index: number) => void }) { - const maxCost = createMemo(() => Math.max(...props.data.map((item) => item.cost))) - const maxTokens = createMemo(() => Math.max(...props.data.map((item) => item.tokens))) + const maxCost = createMemo(() => Math.max(1, ...props.data.map((item) => item.cost))) + const maxTokens = createMemo(() => Math.max(1, ...props.data.map((item) => item.tokens))) const active = createMemo(() => props.data[props.activeIndex] ?? props.data[0]) return ( @@ -880,22 +738,6 @@ function SessionCostChart(props: { ) } -function getSessionCostData(product: TokenProduct, live: boolean) { - return sessionCosts.map((item) => { - const multiplier = (product === "Zen" ? 1 : product === "Go" ? 0.9 : 1.2) * (live ? 1 : 0.94) - return { - model: item[0], - cost: Number((item[1] * multiplier).toFixed(4)), - tokens: Math.round(parseTokenCount(item[2]) * (product === "Enterprise" ? 1.12 : product === "Go" ? 0.92 : 1)), - } - }) -} - -function parseTokenCount(value: string) { - if (value.endsWith("M")) return Number(value.slice(0, -1)) * 1_000_000 - return Number(value.slice(0, -1)) * 1_000 -} - function formatTokenCount(value: number) { if (value >= 1_000_000) return `${Number((value / 1_000_000).toFixed(1))}M` return `${Math.round(value / 1_000)}K` @@ -905,35 +747,6 @@ function formatSessionCost(value: number) { return `$${value.toFixed(4)}` } -function CountryMap() { - return ( -
- - - - - - - - - {(item) => ( - - - - {item[0]} {item[1]} - - - )} - - -
- Canada - 130B -
-
- ) -} - function Newsletter() { return (
diff --git a/packages/stats/core/drizzle.config.ts b/packages/stats/core/drizzle.config.ts index 1b4837d8eb..1ba0dd1ea2 100644 --- a/packages/stats/core/drizzle.config.ts +++ b/packages/stats/core/drizzle.config.ts @@ -1,12 +1,21 @@ +import { Resource } from "sst" import { defineConfig } from "drizzle-kit" export default defineConfig({ dialect: "mysql", schema: ["./src/database/schema.ts"], - out: "./migrations", - dbCredentials: { - url: process.env.DATABASE_URL ?? "mysql://root:changeme@localhost:3306/opencode_stats", - }, + // schema: ["./src/**/*.sql.ts"], + out: "./migrations/", strict: true, verbose: true, + dbCredentials: { + database: Resource.StatsDatabase.database, + host: Resource.StatsDatabase.host, + user: Resource.StatsDatabase.username, + password: Resource.StatsDatabase.password, + port: Resource.StatsDatabase.port, + ssl: { + rejectUnauthorized: false, + }, + }, }) diff --git a/packages/stats/core/migrations/20260522121617_common_dust/migration.sql b/packages/stats/core/migrations/20260522121617_common_dust/migration.sql new file mode 100644 index 0000000000..0194c4878d --- /dev/null +++ b/packages/stats/core/migrations/20260522121617_common_dust/migration.sql @@ -0,0 +1,42 @@ +CREATE TABLE `stat` ( + `id` bigint AUTO_INCREMENT PRIMARY KEY, + `grain` varchar(16) NOT NULL, + `period_start` datetime NOT NULL, + `period_end` datetime NOT NULL, + `dataset` varchar(64) NOT NULL DEFAULT 'all', + `tier` varchar(64) NOT NULL DEFAULT 'all', + `client` varchar(64) NOT NULL DEFAULT 'all', + `source` varchar(64) NOT NULL DEFAULT 'all', + `provider` varchar(128) NOT NULL, + `model` varchar(256) NOT NULL, + `provider_model` varchar(256) NOT NULL DEFAULT '', + `sessions` bigint NOT NULL DEFAULT 0, + `requests` bigint NOT NULL DEFAULT 0, + `input_tokens` bigint NOT NULL DEFAULT 0, + `output_tokens` bigint NOT NULL DEFAULT 0, + `reasoning_tokens` bigint NOT NULL DEFAULT 0, + `cache_read_tokens` bigint NOT NULL DEFAULT 0, + `total_tokens` bigint NOT NULL DEFAULT 0, + `input_cost_microcents` bigint NOT NULL DEFAULT 0, + `output_cost_microcents` bigint NOT NULL DEFAULT 0, + `total_cost_microcents` bigint NOT NULL DEFAULT 0, + `avg_duration_ms` decimal(12,2), + `p50_duration_ms` int, + `p95_duration_ms` int, + `avg_ttfb_ms` decimal(12,2), + `p50_ttfb_ms` int, + `p95_ttfb_ms` int, + `avg_output_tps` decimal(12,4), + `success_count` bigint NOT NULL DEFAULT 0, + `error_count` bigint NOT NULL DEFAULT 0, + `sample_count` bigint NOT NULL DEFAULT 0, + `rank_by_tokens` int, + `rank_by_requests` int, + `rank_by_cost` int, + `created_at` datetime NOT NULL DEFAULT (now()), + `updated_at` datetime NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `uniq_model_period` UNIQUE INDEX(`grain`,`period_start`,`dataset`,`tier`,`client`,`source`,`provider`,`model`) +); +--> statement-breakpoint +CREATE INDEX `idx_leaderboard_tokens` ON `stat` (`grain`,`period_start`,`dataset`,`tier`,`total_tokens`);--> statement-breakpoint +CREATE INDEX `idx_model` ON `stat` (`model`,`grain`,`period_start`); \ No newline at end of file diff --git a/packages/stats/core/migrations/20260522121617_common_dust/snapshot.json b/packages/stats/core/migrations/20260522121617_common_dust/snapshot.json new file mode 100644 index 0000000000..6088cfa0c1 --- /dev/null +++ b/packages/stats/core/migrations/20260522121617_common_dust/snapshot.json @@ -0,0 +1,627 @@ +{ + "version": "6", + "dialect": "mysql", + "id": "72655266-65da-408e-bfd8-9f3a4ad817a5", + "prevIds": [ + "00000000-0000-0000-0000-000000000000" + ], + "ddl": [ + { + "name": "stat", + "entityType": "tables" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": true, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "stat" + }, + { + "type": "varchar(16)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "grain", + "entityType": "columns", + "table": "stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_start", + "entityType": "columns", + "table": "stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "period_end", + "entityType": "columns", + "table": "stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "dataset", + "entityType": "columns", + "table": "stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "tier", + "entityType": "columns", + "table": "stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "client", + "entityType": "columns", + "table": "stat" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": "'all'", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "stat" + }, + { + "type": "varchar(128)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "stat" + }, + { + "type": "varchar(256)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "stat" + }, + { + "type": "varchar(256)", + "notNull": true, + "autoIncrement": false, + "default": "''", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider_model", + "entityType": "columns", + "table": "stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sessions", + "entityType": "columns", + "table": "stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "requests", + "entityType": "columns", + "table": "stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_tokens", + "entityType": "columns", + "table": "stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_cost_microcents", + "entityType": "columns", + "table": "stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_cost_microcents", + "entityType": "columns", + "table": "stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "total_cost_microcents", + "entityType": "columns", + "table": "stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_duration_ms", + "entityType": "columns", + "table": "stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_duration_ms", + "entityType": "columns", + "table": "stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_duration_ms", + "entityType": "columns", + "table": "stat" + }, + { + "type": "decimal(12,2)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_ttfb_ms", + "entityType": "columns", + "table": "stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p50_ttfb_ms", + "entityType": "columns", + "table": "stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "p95_ttfb_ms", + "entityType": "columns", + "table": "stat" + }, + { + "type": "decimal(12,4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "avg_output_tps", + "entityType": "columns", + "table": "stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "success_count", + "entityType": "columns", + "table": "stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "error_count", + "entityType": "columns", + "table": "stat" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": "0", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "sample_count", + "entityType": "columns", + "table": "stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_tokens", + "entityType": "columns", + "table": "stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_requests", + "entityType": "columns", + "table": "stat" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rank_by_cost", + "entityType": "columns", + "table": "stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "stat" + }, + { + "type": "datetime", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": true, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "stat" + }, + { + "columns": [ + "id" + ], + "name": "PRIMARY", + "table": "stat", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "client", + "isExpression": false + }, + { + "value": "source", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "uniq_model_period", + "entityType": "indexes", + "table": "stat" + }, + { + "columns": [ + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + }, + { + "value": "dataset", + "isExpression": false + }, + { + "value": "tier", + "isExpression": false + }, + { + "value": "total_tokens", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_leaderboard_tokens", + "entityType": "indexes", + "table": "stat" + }, + { + "columns": [ + { + "value": "model", + "isExpression": false + }, + { + "value": "grain", + "isExpression": false + }, + { + "value": "period_start", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "idx_model", + "entityType": "indexes", + "table": "stat" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/stats/core/package.json b/packages/stats/core/package.json index 6d8c2a58fd..923f682864 100644 --- a/packages/stats/core/package.json +++ b/packages/stats/core/package.json @@ -15,11 +15,13 @@ }, "scripts": { "db:generate": "drizzle-kit generate --config=drizzle.config.ts", + "db:migrate": "bun src/migrate.ts", "db:push": "drizzle-kit push --config=drizzle.config.ts", "db:studio": "drizzle-kit studio --config=drizzle.config.ts", "typecheck": "tsgo --noEmit" }, "dependencies": { + "@planetscale/database": "1.19.0", "drizzle-orm": "catalog:", "effect": "catalog:" }, diff --git a/packages/stats/core/src/cron/stat.ts b/packages/stats/core/src/cron/stat.ts index 5247a15d38..5751e734ad 100644 --- a/packages/stats/core/src/cron/stat.ts +++ b/packages/stats/core/src/cron/stat.ts @@ -1,17 +1,500 @@ +import { createHash } from "node:crypto" +import { Client } from "@planetscale/database" +import { sql } from "drizzle-orm" +import { drizzle } from "drizzle-orm/planetscale-serverless" +import { DateTime, Effect, Option, Schema } from "effect" import { Resource } from "sst" +import { stat } from "../database/schema" -export async function handler() { - const startedAt = new Date().toISOString() +const HONEYCOMB_API_URL = "https://api.honeycomb.io" +const HONEYCOMB_DATASET = "zen" +const DAY_SECONDS = 86_400 +const MAX_POLL_ATTEMPTS = 15 +const UPSERT_CHUNK_SIZE = 500 - console.log("stats sync stub", { - startedAt, - stage: Resource.App.stage, - hasDatabaseUrl: Boolean(Resource.StatsDatabase.url), - hasHoneycombApiKey: Boolean(Resource.HONEYCOMB_API_KEY.value), +type HoneycombScalar = string | number | boolean | null +type HoneycombData = Record +type HoneycombQueryResult = { results: HoneycombData[]; series: { time: Date; data: HoneycombData }[] } +type StatRow = typeof stat.$inferInsert +type SyncResult = { ok: true; rows: number; startedAt: string; periodStart: string; periodEnd: string } +type SyncError = HoneycombApiError | HoneycombQueryTimeoutError | StatDatabaseError + +class HoneycombApiError extends Schema.TaggedErrorClass()("HoneycombApiError", { + message: Schema.String, + status: Schema.optional(Schema.Number), + cause: Schema.optional(Schema.Defect), +}) {} + +class HoneycombQueryTimeoutError extends Schema.TaggedErrorClass()( + "HoneycombQueryTimeoutError", + { + message: Schema.String, + resultId: Schema.String, + }, +) {} + +class StatDatabaseError extends Schema.TaggedErrorClass()("StatDatabaseError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} + +const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) + +const calculations = [ + { op: "COUNT" }, + { op: "COUNT_DISTINCT", column: "session" }, + { op: "SUM", column: "tokens.input" }, + { op: "SUM", column: "tokens.output" }, + { op: "SUM", column: "tokens.reasoning" }, + { op: "SUM", column: "tokens.cache_read" }, + { op: "SUM", column: "tokens" }, + { op: "SUM", column: "cost.input.microcents" }, + { op: "SUM", column: "cost.output.microcents" }, + { op: "SUM", column: "cost.total.microcents" }, + { op: "AVG", column: "duration" }, + { op: "P50", column: "duration" }, + { op: "P95", column: "duration" }, + { op: "AVG", column: "time_to_first_byte" }, + { op: "P50", column: "time_to_first_byte" }, + { op: "P95", column: "time_to_first_byte" }, + { op: "AVG", column: "tps.output" }, + { op: "SUM", column: "stats_success" }, + { op: "SUM", column: "stats_error" }, +] as const + +export function handler(): Promise { + return Effect.runPromise(syncStats()) +} + +const syncStats: () => Effect.Effect = Effect.fn("StatsCron.sync")(function* () { + const startedAt = yield* DateTime.nowAsDate + const periodEnd = new Date(Math.floor(startedAt.getTime() / 3_600_000) * 3_600_000) + const periodStart = new Date( + Date.UTC(periodEnd.getUTCFullYear(), periodEnd.getUTCMonth(), periodEnd.getUTCDate() - 6), + ) + + yield* logHoneycombRuntimeCheck() + + const result = yield* runHoneycombQuery({ + start_time: Math.floor(periodStart.getTime() / 1000), + end_time: Math.floor(periodEnd.getTime() / 1000), + granularity: DAY_SECONDS, + breakdowns: ["tier", "provider", "model"], + calculations, + calculated_fields: [ + { + name: "stats_success", + expression: `IF(AND(GTE($status, "200"), LT($status, "400")), 1, 0)`, + }, + { + name: "stats_error", + expression: `IF(GTE($status, "400"), 1, 0)`, + }, + ], + filters: [ + { column: "event_type", op: "=", value: "completions" }, + { column: "model", op: "exists" }, + { column: "user_agent", op: "contains", value: "opencode" }, + ], + filter_combination: "AND", + orders: [{ column: "tokens", op: "SUM", order: "descending" }], + limit: 1000, }) + const rows = rankRows([ + ...synthesizeAllTierRows( + collapseRows(result.results.map((item) => toStatRow("week", periodStart, periodEnd, item))), + ), + ...synthesizeAllTierRows( + collapseRows( + result.series.map((item) => + toStatRow( + "day", + item.time, + new Date(Math.min(item.time.getTime() + DAY_SECONDS * 1000, periodEnd.getTime())), + item.data, + ), + ), + ), + ), + ]) + + yield* saveRows(rows) + + yield* Effect.logInfo("stats sync complete").pipe( + Effect.annotateLogs({ + startedAt: startedAt.toISOString(), + periodStart: periodStart.toISOString(), + periodEnd: periodEnd.toISOString(), + rows: rows.length, + stage: Resource.App.stage, + }), + ) return { ok: true, - startedAt, + rows: rows.length, + startedAt: startedAt.toISOString(), + periodStart: periodStart.toISOString(), + periodEnd: periodEnd.toISOString(), + } +}) + +const runHoneycombQuery: ( + query: Record, +) => Effect.Effect = Effect.fn( + "StatsCron.runHoneycombQuery", +)(function* (query: Record) { + const created = asRecord(yield* honeycombRequest(`/1/queries/${HONEYCOMB_DATASET}`, "POST", query)) + const queryId = asString(created.id) + if (!queryId) return yield* new HoneycombApiError({ message: "Honeycomb did not return a query id" }) + + const queued = asRecord( + yield* honeycombRequest(`/1/query_results/${HONEYCOMB_DATASET}`, "POST", { + query_id: queryId, + disable_series: false, + disable_total_by_aggregate: true, + disable_other_by_aggregate: true, + limit: 1000, + }), + ) + const resultId = asString(queued.id) + if (!resultId) return yield* new HoneycombApiError({ message: "Honeycomb did not return a query result id" }) + + return yield* pollHoneycombResult(resultId) +}) + +const pollHoneycombResult: ( + resultId: string, + attempt?: number, +) => Effect.Effect = Effect.fn( + "StatsCron.pollHoneycombResult", +)(function* (resultId: string, attempt = 0) { + if (attempt > 0) yield* Effect.sleep("1000 millis") + const result = asRecord(yield* honeycombRequest(`/1/query_results/${HONEYCOMB_DATASET}/${resultId}`, "GET")) + + if (result.complete === true) { + const data = asRecord(result.data) + return { + results: asArray(data.results).map((item) => asData(asRecord(item).data)), + series: asArray(data.series).flatMap((item) => { + const time = new Date(String(asRecord(item).time ?? "")) + if (Number.isNaN(time.getTime())) return [] + return [{ time, data: asData(asRecord(item).data) }] + }), + } + } + + if (attempt >= MAX_POLL_ATTEMPTS - 1) + return yield* new HoneycombQueryTimeoutError({ + message: `Honeycomb query result ${resultId} did not complete`, + resultId, + }) + + return yield* pollHoneycombResult(resultId, attempt + 1) +}) + +const honeycombRequest: ( + path: string, + method: "GET" | "POST", + body?: Record, +) => Effect.Effect = Effect.fn("StatsCron.honeycombRequest")(function* ( + path: string, + method: "GET" | "POST", + body?: Record, +) { + const response = yield* Effect.tryPromise({ + try: () => + fetch(`${HONEYCOMB_API_URL}${path}`, { + method, + headers: { + "Content-Type": "application/json", + "X-Honeycomb-Team": Resource.HONEYCOMB_API_KEY.value, + }, + body: body ? JSON.stringify(body) : undefined, + }), + catch: (cause) => new HoneycombApiError({ message: `Honeycomb ${method} ${path} request failed`, cause }), + }) + const text = yield* Effect.tryPromise({ + try: () => response.text(), + catch: (cause) => new HoneycombApiError({ message: `Honeycomb ${method} ${path} response read failed`, cause }), + }) + + if (!response.ok) + return yield* new HoneycombApiError({ + message: `Honeycomb ${method} ${path} failed: ${response.status} ${text.slice(0, 500)}`, + status: response.status, + }) + if (!text) return {} + + const parsed = decodeJson(text) + if (Option.isNone(parsed)) + return yield* new HoneycombApiError({ message: `Honeycomb ${method} ${path} returned invalid JSON` }) + return parsed.value +}) + +const saveRows: (rows: StatRow[]) => Effect.Effect = Effect.fn("StatsCron.saveRows")( + function* (rows: StatRow[]) { + const db = drizzle({ + client: new Client({ + host: Resource.StatsDatabase.host, + username: Resource.StatsDatabase.username, + password: Resource.StatsDatabase.password, + }), + }) + + yield* Effect.forEach( + chunks(rows, UPSERT_CHUNK_SIZE), + (chunk) => + Effect.tryPromise({ + try: () => + db + .insert(stat) + .values(chunk) + .onDuplicateKeyUpdate({ + set: { + period_end: inserted("period_end"), + provider_model: inserted("provider_model"), + sessions: inserted("sessions"), + requests: inserted("requests"), + input_tokens: inserted("input_tokens"), + output_tokens: inserted("output_tokens"), + reasoning_tokens: inserted("reasoning_tokens"), + cache_read_tokens: inserted("cache_read_tokens"), + total_tokens: inserted("total_tokens"), + input_cost_microcents: inserted("input_cost_microcents"), + output_cost_microcents: inserted("output_cost_microcents"), + total_cost_microcents: inserted("total_cost_microcents"), + avg_duration_ms: inserted("avg_duration_ms"), + p50_duration_ms: inserted("p50_duration_ms"), + p95_duration_ms: inserted("p95_duration_ms"), + avg_ttfb_ms: inserted("avg_ttfb_ms"), + p50_ttfb_ms: inserted("p50_ttfb_ms"), + p95_ttfb_ms: inserted("p95_ttfb_ms"), + avg_output_tps: inserted("avg_output_tps"), + success_count: inserted("success_count"), + error_count: inserted("error_count"), + sample_count: inserted("sample_count"), + rank_by_tokens: inserted("rank_by_tokens"), + rank_by_requests: inserted("rank_by_requests"), + rank_by_cost: inserted("rank_by_cost"), + }, + }), + catch: (cause) => new StatDatabaseError({ message: "Failed to upsert stats rows", cause }), + }), + { discard: true }, + ) + }, +) + +function logHoneycombRuntimeCheck() { + return Effect.logInfo("honeycomb api key runtime check").pipe( + Effect.annotateLogs({ + hasHoneycombApiKey: Boolean(Resource.HONEYCOMB_API_KEY.value), + honeycombApiKeyLength: Resource.HONEYCOMB_API_KEY.value.length, + honeycombApiKeySha256: createHash("sha256").update(Resource.HONEYCOMB_API_KEY.value).digest("hex").slice(0, 12), + honeycombApiUrl: HONEYCOMB_API_URL, + }), + ) +} + +function inserted(column: string) { + return sql.raw(`values(\`${column}\`)`) +} + +function toStatRow(grain: "day" | "week", periodStart: Date, periodEnd: Date, data: HoneycombData): StatRow { + return { + grain, + period_start: periodStart, + period_end: periodEnd, + dataset: HONEYCOMB_DATASET, + tier: normalizeTier(asString(data.tier) || "unknown"), + client: "all", + source: "all", + provider: asString(data.provider) || "unknown", + model: asString(data.model) || "unknown", + provider_model: "", + sessions: Math.round(number(data, "COUNT_DISTINCT(session)")), + requests: Math.round(number(data, "COUNT")), + input_tokens: Math.round(number(data, "SUM(tokens.input)")), + output_tokens: Math.round(number(data, "SUM(tokens.output)")), + reasoning_tokens: Math.round(number(data, "SUM(tokens.reasoning)")), + cache_read_tokens: Math.round(number(data, "SUM(tokens.cache_read)")), + total_tokens: Math.round(number(data, "SUM(tokens)")), + input_cost_microcents: Math.round(number(data, "SUM(cost.input.microcents)")), + output_cost_microcents: Math.round(number(data, "SUM(cost.output.microcents)")), + total_cost_microcents: Math.round(number(data, "SUM(cost.total.microcents)")), + avg_duration_ms: nullableNumber(data, "AVG(duration)"), + p50_duration_ms: nullableInteger(data, "P50(duration)"), + p95_duration_ms: nullableInteger(data, "P95(duration)"), + avg_ttfb_ms: nullableNumber(data, "AVG(time_to_first_byte)"), + p50_ttfb_ms: nullableInteger(data, "P50(time_to_first_byte)"), + p95_ttfb_ms: nullableInteger(data, "P95(time_to_first_byte)"), + avg_output_tps: nullableNumber(data, "AVG(tps.output)"), + success_count: Math.round(number(data, "SUM(stats_success)")), + error_count: Math.round(number(data, "SUM(stats_error)")), + sample_count: Math.round(number(data, "COUNT")), } } + +function synthesizeAllTierRows(rows: StatRow[]) { + return [ + ...rows, + ...Object.values( + rows.reduce>((result, row) => { + const key = [ + row.grain, + row.period_start.toISOString(), + row.dataset, + row.client, + row.source, + row.provider, + row.model, + ].join("\u0000") + result[key] = result[key] ? combineRows(result[key], row) : { ...row, tier: "all" } + return result + }, {}), + ), + ] +} + +function collapseRows(rows: StatRow[]) { + return Object.values( + rows.reduce>((result, row) => { + const key = [ + row.grain, + row.period_start.toISOString(), + row.dataset, + row.tier, + row.client, + row.source, + row.provider, + row.model, + ].join("\u0000") + result[key] = result[key] ? combineRows(result[key], row) : row + return result + }, {}), + ) +} + +function combineRows(left: StatRow, right: StatRow): StatRow { + return { + ...left, + period_end: right.period_end > left.period_end ? right.period_end : left.period_end, + sessions: (left.sessions ?? 0) + (right.sessions ?? 0), + requests: (left.requests ?? 0) + (right.requests ?? 0), + input_tokens: (left.input_tokens ?? 0) + (right.input_tokens ?? 0), + output_tokens: (left.output_tokens ?? 0) + (right.output_tokens ?? 0), + reasoning_tokens: (left.reasoning_tokens ?? 0) + (right.reasoning_tokens ?? 0), + cache_read_tokens: (left.cache_read_tokens ?? 0) + (right.cache_read_tokens ?? 0), + total_tokens: (left.total_tokens ?? 0) + (right.total_tokens ?? 0), + input_cost_microcents: (left.input_cost_microcents ?? 0) + (right.input_cost_microcents ?? 0), + output_cost_microcents: (left.output_cost_microcents ?? 0) + (right.output_cost_microcents ?? 0), + total_cost_microcents: (left.total_cost_microcents ?? 0) + (right.total_cost_microcents ?? 0), + avg_duration_ms: weightedAverage(left.avg_duration_ms, left.requests, right.avg_duration_ms, right.requests), + p50_duration_ms: null, + p95_duration_ms: null, + avg_ttfb_ms: weightedAverage(left.avg_ttfb_ms, left.requests, right.avg_ttfb_ms, right.requests), + p50_ttfb_ms: null, + p95_ttfb_ms: null, + avg_output_tps: weightedAverage(left.avg_output_tps, left.requests, right.avg_output_tps, right.requests), + success_count: (left.success_count ?? 0) + (right.success_count ?? 0), + error_count: (left.error_count ?? 0) + (right.error_count ?? 0), + sample_count: (left.sample_count ?? 0) + (right.sample_count ?? 0), + } +} + +function rankRows(rows: StatRow[]) { + return Object.values( + rows.reduce>((result, row) => { + const key = [row.grain, row.period_start.toISOString(), row.dataset, row.tier, row.client, row.source].join( + "\u0000", + ) + result[key] = [...(result[key] ?? []), row] + return result + }, {}), + ).flatMap((group) => { + const tokenRanks = rankBy(group, (row) => row.total_tokens ?? 0) + const requestRanks = rankBy(group, (row) => row.requests ?? 0) + const costRanks = rankBy(group, (row) => row.total_cost_microcents ?? 0) + return group.map((row) => ({ + ...row, + rank_by_tokens: tokenRanks.get(row) ?? null, + rank_by_requests: requestRanks.get(row) ?? null, + rank_by_cost: costRanks.get(row) ?? null, + })) + }) +} + +function rankBy(rows: StatRow[], value: (row: StatRow) => number) { + return new Map(rows.toSorted((a, b) => value(b) - value(a)).map((row, index) => [row, index + 1])) +} + +function chunks(items: T[], size: number) { + return Array.from({ length: Math.ceil(items.length / size) }, (_, index) => + items.slice(index * size, (index + 1) * size), + ) +} + +function weightedAverage( + left: number | null | undefined, + leftWeight = 0, + right: number | null | undefined, + rightWeight = 0, +) { + const totalWeight = + (left === null || left === undefined ? 0 : leftWeight) + (right === null || right === undefined ? 0 : rightWeight) + if (totalWeight === 0) return null + return Number((((left ?? 0) * leftWeight + (right ?? 0) * rightWeight) / totalWeight).toFixed(2)) +} + +function normalizeTier(value: string) { + if (value === "Paid") return "Zen" + return value +} + +function number(data: HoneycombData, key: string) { + const value = data[key] + if (typeof value === "number") return Number.isFinite(value) ? value : 0 + if (typeof value === "string") { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : 0 + } + return 0 +} + +function nullableNumber(data: HoneycombData, key: string) { + const value = number(data, key) + if (value === 0 && data[key] === undefined) return null + return Number(value.toFixed(2)) +} + +function nullableInteger(data: HoneycombData, key: string) { + if (data[key] === undefined) return null + return Math.round(number(data, key)) +} + +function asData(value: unknown): HoneycombData { + return Object.fromEntries( + Object.entries(asRecord(value)).flatMap(([key, item]) => { + if (typeof item === "string" || typeof item === "number" || typeof item === "boolean" || item === null) + return [[key, item]] + return [] + }), + ) +} + +function asRecord(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return {} + return value as Record +} + +function asArray(value: unknown) { + if (!Array.isArray(value)) return [] + return value +} + +function asString(value: unknown) { + if (typeof value === "string") return value + if (typeof value === "number" || typeof value === "boolean") return String(value) + return "" +} diff --git a/packages/stats/core/src/database.ts b/packages/stats/core/src/database.ts index 554a5ddcb8..f94a35a2c2 100644 --- a/packages/stats/core/src/database.ts +++ b/packages/stats/core/src/database.ts @@ -1,6 +1,10 @@ +import { Client } from "@planetscale/database" +import { drizzle } from "drizzle-orm/planetscale-serverless" +import { migrate as drizzleMigrate } from "drizzle-orm/planetscale-serverless/migrator" import { Config, ConfigProvider, Effect, Layer, Schema } from "effect" import * as Context from "effect/Context" import * as schema from "./database/schema" +import { Resource } from "sst" export const DatabaseUrl = Schema.NonEmptyString.pipe(Schema.brand("DatabaseUrl")) export type DatabaseUrl = typeof DatabaseUrl.Type @@ -13,9 +17,7 @@ export class DatabaseSettings extends Schema.Class("DatabaseSe const decodeDatabaseSettings = Schema.decodeUnknownSync(DatabaseSettings) const config = Config.all({ - url: Config.nonEmptyString("DATABASE_URL").pipe( - Config.withDefault("mysql://root:changeme@localhost:3306/opencode_stats"), - ), + url: Config.nonEmptyString("DATABASE_URL").pipe(Config.withDefault(Resource.StatsDatabase.url)), migrationsDir: Config.nonEmptyString("DATABASE_MIGRATIONS_DIR").pipe(Config.withDefault("./migrations")), }).pipe(Config.map(decodeDatabaseSettings)) @@ -53,7 +55,21 @@ export class MigrationError extends Schema.TaggedErrorClass()("M export const migrate = Effect.fn("Database.migrate")(function* () { const settings = yield* DatabaseConfig - yield* Effect.logInfo("database migrations are not wired yet").pipe( + yield* Effect.logInfo("applying database migrations").pipe( + Effect.annotateLogs({ migrationsDir: settings.migrationsDir }), + ) + const result = yield* Effect.tryPromise({ + try: () => + drizzleMigrate(drizzle({ client: new Client({ url: settings.url }) }), { + migrationsFolder: settings.migrationsDir, + }), + catch: (cause) => new MigrationError({ message: "Failed to apply database migrations", cause }), + }) + if (result) + return yield* new MigrationError({ + message: `Failed to initialize database migrations: ${result.exitCode}`, + }) + yield* Effect.logInfo("database migrations complete").pipe( Effect.annotateLogs({ migrationsDir: settings.migrationsDir }), ) }) diff --git a/packages/stats/core/src/domain/ranking.ts b/packages/stats/core/src/domain/ranking.ts index 87f6d9e3cf..d813130a39 100644 --- a/packages/stats/core/src/domain/ranking.ts +++ b/packages/stats/core/src/domain/ranking.ts @@ -1,4 +1,9 @@ -import { Schema } from "effect" +import { Client } from "@planetscale/database" +import { and, asc, eq } from "drizzle-orm" +import { drizzle } from "drizzle-orm/planetscale-serverless" +import { Effect, Schema } from "effect" +import { DatabaseConfig } from "../database" +import { stat } from "../database/schema" export const RankingSnapshotId = Schema.String.check(Schema.isStartsWith("rank_"), Schema.isMaxLength(64)).pipe( Schema.brand("RankingSnapshotId"), @@ -20,3 +25,418 @@ export class RankingSnapshot extends Schema.Class("RankingSnaps capturedAt: Schema.Date, createdAt: Schema.Date, }) {} + +export type UsageProduct = "All Users" | "Zen" | "Go" | "Enterprise" +export type TokenProduct = "Zen" | "Go" | "Enterprise" +export type UsageRange = "1D" | "1W" | "1M" | "3M" | "YTD" | "ALL" +export type UsagePoint = { date: string; segments: { model: string; value: number }[] } +export type MarketDay = { date: string; total: number; authors: { author: string; share: number; tokens: number }[] } +export type LeaderboardEntry = { model: string; author: string; tokens: number; change: number; rank: number } +export type TokenCostEntry = { model: string; total: number; input: number; output: number; cached: number } +export type SessionCostEntry = { model: string; cost: number; tokens: number } +export type RankingsData = { + updatedAt: string | null + usage: Record> + leaderboard: Record> + market: Record + tokenCost: Record + sessionCost: Record +} + +export class RankingQueryError extends Schema.TaggedErrorClass()("RankingQueryError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} + +const DAY_MS = 86_400_000 +const TOKEN_SCALE = 1_000_000 +const DOLLARS_PER_MICROCENT = 1 / 100_000_000 +const months = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"] as const + +type StatQueryRow = { + periodStart: Date + periodEnd: Date + tier: string + provider: string + model: string + sessions: number + inputTokens: number + outputTokens: number + reasoningTokens: number + cacheReadTokens: number + totalTokens: number + inputCostMicrocents: number + outputCostMicrocents: number + totalCostMicrocents: number +} + +type StatMetricRow = Omit & { + periodStart: number + periodEnd: number +} + +type DateWindow = { start: number; end: number; previousStart: number; previousEnd: number } +type Bucket = { start: number; end: number; label: string } +type ModelAggregate = { + model: string + provider: string + sessions: number + inputTokens: number + outputTokens: number + reasoningTokens: number + cacheReadTokens: number + totalTokens: number + inputCostMicrocents: number + outputCostMicrocents: number + totalCostMicrocents: number +} + +export const getRankingsData = Effect.fn("Ranking.getRankingsData")(function* () { + const settings = yield* DatabaseConfig + const db = drizzle({ client: new Client({ url: settings.url }) }) + const rows = yield* Effect.tryPromise({ + try: () => + db + .select({ + periodStart: stat.period_start, + periodEnd: stat.period_end, + tier: stat.tier, + provider: stat.provider, + model: stat.model, + sessions: stat.sessions, + inputTokens: stat.input_tokens, + outputTokens: stat.output_tokens, + reasoningTokens: stat.reasoning_tokens, + cacheReadTokens: stat.cache_read_tokens, + totalTokens: stat.total_tokens, + inputCostMicrocents: stat.input_cost_microcents, + outputCostMicrocents: stat.output_cost_microcents, + totalCostMicrocents: stat.total_cost_microcents, + }) + .from(stat) + .where(and(eq(stat.grain, "day"), eq(stat.client, "all"), eq(stat.source, "all"))) + .orderBy(asc(stat.period_start)), + catch: (cause) => new RankingQueryError({ message: "Failed to load rankings stats", cause }), + }) + return buildRankingsData(rows) +}) + +function buildRankingsData(rows: StatQueryRow[]): RankingsData { + const normalized = rows.flatMap(normalizeStatRow) + if (normalized.length === 0) return emptyRankingsData() + + const earliest = Math.min(...normalized.map((row) => row.periodStart)) + const latest = Math.max(...normalized.map((row) => row.periodStart)) + const latestEnd = Math.max(...normalized.map((row) => row.periodEnd)) + + return { + updatedAt: new Date(latestEnd).toISOString(), + usage: createUsageProductRecord((product) => + createRangeRecord((range) => buildUsagePoints(normalized, product, range, getWindow(range, earliest, latest))), + ), + leaderboard: createUsageProductRecord((product) => + createRangeRecord((range) => buildLeaderboard(normalized, product, getWindow(range, earliest, latest))), + ), + market: createRangeRecord((range) => buildMarketShare(normalized, range, getWindow(range, earliest, latest))), + tokenCost: createTokenProductRecord((product) => + buildTokenCost(normalized, product, getWindow("1W", earliest, latest)), + ), + sessionCost: createTokenProductRecord((product) => + buildSessionCost(normalized, product, getWindow("1W", earliest, latest)), + ), + } +} + +function emptyRankingsData(): RankingsData { + return { + updatedAt: null, + usage: createUsageProductRecord(() => createRangeRecord(() => [])), + leaderboard: createUsageProductRecord(() => createRangeRecord(() => [])), + market: createRangeRecord(() => []), + tokenCost: createTokenProductRecord(() => []), + sessionCost: createTokenProductRecord(() => []), + } +} + +function buildUsagePoints(rows: StatMetricRow[], product: UsageProduct, range: UsageRange, window: DateWindow) { + const windowRows = rowsForProduct(rows, product, window.start, window.end) + const modelOrder = aggregateByModel(windowRows) + .toSorted((a, b) => b.totalTokens - a.totalTokens) + .slice(0, 6) + .map((item) => ({ key: modelKey(item.provider, item.model), model: item.model })) + + return createBuckets(window, range).map((bucket) => { + const bucketRows = aggregateByModel(rowsForProduct(rows, product, bucket.start, bucket.end)) + const byModel = new Map(bucketRows.map((item) => [modelKey(item.provider, item.model), item.totalTokens])) + const segmentTokens = modelOrder.map((model) => ({ model: model.model, tokens: byModel.get(model.key) ?? 0 })) + const knownTokens = segmentTokens.reduce((sum, item) => sum + item.tokens, 0) + const totalTokens = bucketRows.reduce((sum, item) => sum + item.totalTokens, 0) + return { + date: bucket.label, + segments: [ + ...segmentTokens.map((item) => ({ model: item.model, value: round(item.tokens / 1_000_000_000_000, 2) })), + { model: "Other", value: round(Math.max(totalTokens - knownTokens, 0) / 1_000_000_000_000, 2) }, + ].filter((item) => item.value > 0), + } + }) +} + +function buildLeaderboard(rows: StatMetricRow[], product: UsageProduct, window: DateWindow) { + const previous = new Map( + aggregateByModel(rowsForProduct(rows, product, window.previousStart, window.previousEnd)).map((item) => [ + modelKey(item.provider, item.model), + item.totalTokens, + ]), + ) + + return aggregateByModel(rowsForProduct(rows, product, window.start, window.end)) + .toSorted((a, b) => b.totalTokens - a.totalTokens) + .slice(0, 13) + .map((item, index) => ({ + model: item.model, + author: formatProvider(item.provider), + tokens: Math.round(item.totalTokens / 1_000_000_000), + change: percentChange(item.totalTokens, previous.get(modelKey(item.provider, item.model)) ?? 0), + rank: index + 1, + })) +} + +function buildMarketShare(rows: StatMetricRow[], range: UsageRange, window: DateWindow) { + return createBuckets(window, range).flatMap((bucket) => { + const total = aggregateByProvider(rowsForProduct(rows, "All Users", bucket.start, bucket.end)).toSorted( + (a, b) => b.tokens - a.tokens, + ) + const totalTokens = total.reduce((sum, item) => sum + item.tokens, 0) + if (totalTokens === 0) return [] + + const authors = total.slice(0, 8) + const knownTokens = authors.reduce((sum, item) => sum + item.tokens, 0) + const withOther = [...authors, { provider: "Other", tokens: Math.max(totalTokens - knownTokens, 0) }].filter( + (item) => item.tokens > 0, + ) + + return [ + { + date: bucket.label, + total: round(totalTokens / 1_000_000_000_000, 2), + authors: withOther.map((item) => ({ + author: item.provider === "Other" ? "Other" : formatProvider(item.provider), + share: round((item.tokens / totalTokens) * 100, 1), + tokens: round(item.tokens / 1_000_000_000_000, 2), + })), + }, + ] + }) +} + +function buildTokenCost(rows: StatMetricRow[], product: TokenProduct, window: DateWindow) { + return aggregateByModel(rowsForProduct(rows, product, window.start, window.end)) + .flatMap((item) => { + const total = costPerMillion(item.totalCostMicrocents, item.totalTokens) + if (total === 0) return [] + return [ + { + model: item.model, + total, + input: costPerMillion(item.inputCostMicrocents, item.inputTokens), + output: costPerMillion(item.outputCostMicrocents, item.outputTokens + item.reasoningTokens), + cached: costPerMillion(item.inputCostMicrocents, item.inputTokens + item.cacheReadTokens), + }, + ] + }) + .toSorted((a, b) => a.total - b.total) + .slice(0, 17) +} + +function buildSessionCost(rows: StatMetricRow[], product: TokenProduct, window: DateWindow) { + return aggregateByModel(rowsForProduct(rows, product, window.start, window.end)) + .flatMap((item) => { + if (item.sessions === 0) return [] + const cost = round(microcentsToDollars(item.totalCostMicrocents) / item.sessions, 4) + if (cost === 0) return [] + return [{ model: item.model, cost, tokens: Math.round(item.totalTokens / item.sessions) }] + }) + .toSorted((a, b) => a.cost - b.cost) + .slice(0, 17) +} + +function rowsForProduct(rows: StatMetricRow[], product: UsageProduct, start: number, end: number) { + const windowRows = rows.filter((row) => row.periodStart >= start && row.periodStart < end) + if (product !== "All Users") return windowRows.filter((row) => row.tier === product) + + const allRows = windowRows.filter((row) => row.tier === "all") + if (allRows.length > 0) return allRows + return windowRows.filter((row) => row.tier !== "all") +} + +function aggregateByModel(rows: StatMetricRow[]) { + return Object.values( + rows.reduce>((result, row) => { + const key = modelKey(row.provider, row.model) + result[key] = combineModelAggregate(result[key], row) + return result + }, {}), + ) +} + +function aggregateByProvider(rows: StatMetricRow[]) { + return Object.values( + rows.reduce>((result, row) => { + result[row.provider] = { + provider: row.provider, + tokens: (result[row.provider]?.tokens ?? 0) + row.totalTokens, + } + return result + }, {}), + ) +} + +function combineModelAggregate(current: ModelAggregate | undefined, row: StatMetricRow): ModelAggregate { + return { + model: row.model, + provider: row.provider, + sessions: (current?.sessions ?? 0) + row.sessions, + inputTokens: (current?.inputTokens ?? 0) + row.inputTokens, + outputTokens: (current?.outputTokens ?? 0) + row.outputTokens, + reasoningTokens: (current?.reasoningTokens ?? 0) + row.reasoningTokens, + cacheReadTokens: (current?.cacheReadTokens ?? 0) + row.cacheReadTokens, + totalTokens: (current?.totalTokens ?? 0) + row.totalTokens, + inputCostMicrocents: (current?.inputCostMicrocents ?? 0) + row.inputCostMicrocents, + outputCostMicrocents: (current?.outputCostMicrocents ?? 0) + row.outputCostMicrocents, + totalCostMicrocents: (current?.totalCostMicrocents ?? 0) + row.totalCostMicrocents, + } +} + +function getWindow(range: UsageRange, earliest: number, latest: number): DateWindow { + const end = latest + DAY_MS + const start = Math.max( + earliest, + range === "1D" + ? latest + : range === "1W" + ? latest - 6 * DAY_MS + : range === "1M" + ? latest - 29 * DAY_MS + : range === "3M" + ? latest - 89 * DAY_MS + : range === "YTD" + ? Date.UTC(new Date(latest).getUTCFullYear(), 0, 1) + : earliest, + ) + const duration = end - start + return { start, end, previousStart: start - duration, previousEnd: start } +} + +function createBuckets(window: DateWindow, range: UsageRange): Bucket[] { + const span = Math.max(window.end - window.start, DAY_MS) + const count = Math.max(1, Math.min(7, Math.ceil(span / DAY_MS))) + const size = span / count + return Array.from({ length: count }, (_, index) => { + const start = window.start + index * size + const end = index === count - 1 ? window.end : window.start + (index + 1) * size + return { start, end, label: formatBucketLabel(start, range) } + }) +} + +function createUsageProductRecord(value: (product: UsageProduct) => T): Record { + return { + "All Users": value("All Users"), + Zen: value("Zen"), + Go: value("Go"), + Enterprise: value("Enterprise"), + } +} + +function createTokenProductRecord(value: (product: TokenProduct) => T): Record { + return { + Zen: value("Zen"), + Go: value("Go"), + Enterprise: value("Enterprise"), + } +} + +function createRangeRecord(value: (range: UsageRange) => T): Record { + return { + "1D": value("1D"), + "1W": value("1W"), + "1M": value("1M"), + "3M": value("3M"), + YTD: value("YTD"), + ALL: value("ALL"), + } +} + +function normalizeStatRow(row: StatQueryRow): StatMetricRow[] { + const periodStart = dateTime(row.periodStart) + const periodEnd = dateTime(row.periodEnd) + if (!Number.isFinite(periodStart) || !Number.isFinite(periodEnd)) return [] + return [ + { + ...row, + periodStart, + periodEnd, + tier: normalizeTier(row.tier), + provider: row.provider || "unknown", + model: row.model || "unknown", + }, + ] +} + +function normalizeTier(value: string) { + const normalized = value.toLowerCase() + if (normalized === "paid" || normalized === "zen") return "Zen" + if (normalized === "go") return "Go" + if (normalized === "enterprise") return "Enterprise" + if (normalized === "all") return "all" + return value +} + +function dateTime(value: Date | string) { + return (value instanceof Date ? value : new Date(value)).getTime() +} + +function formatBucketLabel(value: number, range: UsageRange) { + const date = new Date(value) + if (range === "YTD") return months[date.getUTCMonth()] + if (range === "ALL") + return date.getUTCFullYear() === new Date().getUTCFullYear() + ? months[date.getUTCMonth()] + : String(date.getUTCFullYear()) + return `${months[date.getUTCMonth()]} ${date.getUTCDate()}` +} + +function formatProvider(provider: string) { + const known: Record = { + anthropic: "Anthropic", + google: "Google", + minimax: "MiniMax", + moonshotai: "Moonshot", + nvidia: "Nvidia", + openai: "OpenAI", + zhipuai: "Zhipu", + } + const normalized = provider.toLowerCase().replace(/[^a-z0-9]/g, "") + return known[normalized] ?? provider.replace(/[-_]/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()) +} + +function modelKey(provider: string, model: string) { + return `${provider}\u0000${model}` +} + +function costPerMillion(costMicrocents: number, tokens: number) { + if (tokens <= 0 || costMicrocents <= 0) return 0 + return round((microcentsToDollars(costMicrocents) / tokens) * TOKEN_SCALE, 2) +} + +function microcentsToDollars(value: number) { + return value * DOLLARS_PER_MICROCENT +} + +function percentChange(current: number, previous: number) { + if (previous <= 0) return current > 0 ? 100 : 0 + return Math.round(((current - previous) / previous) * 100) +} + +function round(value: number, digits: number) { + return Number(value.toFixed(digits)) +} diff --git a/packages/stats/core/src/migrate.ts b/packages/stats/core/src/migrate.ts new file mode 100644 index 0000000000..f2a7c52bca --- /dev/null +++ b/packages/stats/core/src/migrate.ts @@ -0,0 +1,4 @@ +import { Effect } from "effect" +import { layer, migrate } from "./database" + +await Effect.runPromise(migrate().pipe(Effect.provide(layer)))