wip: stats

This commit is contained in:
Adam
2026-05-23 07:12:06 -05:00
parent f02616213e
commit 3e4e6ea005
7 changed files with 535 additions and 47 deletions
@@ -0,0 +1,18 @@
<svg width="234" height="42" viewBox="0 0 234 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 30H6V18H18V30Z" fill="#4B4646"/>
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="#B7B1B1"/>
<path d="M48 30H36V18H48V30Z" fill="#4B4646"/>
<path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="#B7B1B1"/>
<path d="M84 24V30H66V24H84Z" fill="#4B4646"/>
<path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="#B7B1B1"/>
<path d="M108 36H96V18H108V36Z" fill="#4B4646"/>
<path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="#B7B1B1"/>
<path d="M144 30H126V18H144V30Z" fill="#4B4646"/>
<path d="M144 12H126V30H144V36H120V6H144V12Z" fill="#F1ECEC"/>
<path d="M168 30H156V18H168V30Z" fill="#4B4646"/>
<path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="#F1ECEC"/>
<path d="M198 30H186V18H198V30Z" fill="#4B4646"/>
<path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="#F1ECEC"/>
<path d="M234 24V30H216V24H234Z" fill="#4B4646"/>
<path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="#F1ECEC"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,18 @@
<svg width="234" height="42" viewBox="0 0 234 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 30H6V18H18V30Z" fill="#CFCECD"/>
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="#656363"/>
<path d="M48 30H36V18H48V30Z" fill="#CFCECD"/>
<path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="#656363"/>
<path d="M84 24V30H66V24H84Z" fill="#CFCECD"/>
<path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="#656363"/>
<path d="M108 36H96V18H108V36Z" fill="#CFCECD"/>
<path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="#656363"/>
<path d="M144 30H126V18H144V30Z" fill="#CFCECD"/>
<path d="M144 12H126V30H144V36H120V6H144V12Z" fill="#211E1E"/>
<path d="M168 30H156V18H168V30Z" fill="#CFCECD"/>
<path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="#211E1E"/>
<path d="M198 30H186V18H198V30Z" fill="#CFCECD"/>
<path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="#211E1E"/>
<path d="M234 24V30H216V24H234Z" fill="#CFCECD"/>
<path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="#211E1E"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+170 -15
View File
@@ -21,6 +21,8 @@
--stats-accent-text: #6c7dff;
--stats-bar-idle: #d4d4d4;
--stats-dot: #d4d4d4;
--stats-page-padding: 5rem;
--stats-section-padding: 6rem;
min-height: 100vh;
display: flex;
flex-direction: column;
@@ -52,11 +54,10 @@
justify-content: space-between;
height: 80px;
min-height: 80px;
padding: 24px 80px;
padding: 24px var(--stats-page-padding);
color: var(--stats-muted);
background: color-mix(in srgb, var(--stats-bg) 94%, transparent);
background: var(--stats-bg);
border-bottom: 1px solid var(--stats-line);
backdrop-filter: blur(16px);
font-family:
"IBM Plex Mono",
var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
@@ -74,9 +75,18 @@
text-underline-offset: 4px;
}
[data-page="stats"] [data-slot="wordmark"] {
font-weight: 600;
letter-spacing: -0.04em;
[data-page="stats"] [data-slot="brand"] {
display: flex;
align-items: center;
}
[data-page="stats"] [data-slot="brand"] img {
width: auto;
height: 34px;
}
[data-page="stats"] [data-slot="logo dark"] {
display: none;
}
[data-page="stats"] [data-component="nav-desktop"] ul {
@@ -88,6 +98,27 @@
list-style: none;
}
[data-page="stats"] [data-component="nav-desktop"] a span {
color: var(--stats-faint);
}
[data-page="stats"] [data-component="nav-desktop"] [data-slot="cta-button"] {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px 8px 10px;
border-radius: 4px;
background: var(--color-background-strong);
color: var(--color-text-inverted);
font-weight: 500;
white-space: nowrap;
}
[data-page="stats"] [data-component="nav-desktop"] [data-slot="cta-button"]:hover {
background: var(--color-background-strong-hover);
text-decoration: none;
}
[data-page="stats"] [data-component="footer"] {
display: flex;
border-top: 1px solid var(--stats-line);
@@ -157,7 +188,7 @@
[data-page="stats"] [data-section="chart"],
[data-page="stats"] [data-section="newsletter"] {
border-bottom: 1px solid var(--stats-line);
padding: 96px 80px;
padding: var(--stats-section-padding) var(--stats-page-padding);
}
[data-page="stats"] [data-section="hero"] {
@@ -237,7 +268,7 @@
}
[data-page="stats"] [data-section="chart"] {
min-height: 760px;
min-height: 0;
}
[data-page="stats"] [data-slot="section-header"] {
@@ -943,21 +974,122 @@
[data-page="stats"] [data-component="country-map"] circle {
fill: var(--stats-accent);
fill-opacity: 0.2;
fill-opacity: 0.14;
stroke: var(--stats-accent);
stroke-width: 2;
stroke-width: 1.5;
transition:
fill-opacity 120ms ease,
stroke-width 120ms ease;
}
[data-page="stats"] [data-component="country-map"] g[role="button"] {
cursor: pointer;
outline: none;
}
[data-page="stats"] [data-component="country-map"] g[role="button"][data-active="true"] circle {
fill-opacity: 0.28;
stroke-width: 3;
}
[data-page="stats"] [data-component="country-map"] text {
fill: var(--stats-muted);
fill: var(--stats-text);
font-size: 12px;
font-weight: 600;
pointer-events: none;
}
[data-page="stats"] [data-component="map-tooltip"] {
top: 90px;
left: 220px;
top: 24px;
left: 24px;
right: auto;
min-width: 120px;
min-width: 168px;
}
[data-page="stats"] [data-component="map-tooltip"] span,
[data-page="stats"] [data-component="map-tooltip"] em {
color: var(--stats-muted);
font-size: 12px;
font-style: normal;
}
[data-page="stats"] [data-component="map-tooltip"] p {
display: flex;
justify-content: space-between;
gap: 16px;
color: var(--stats-text);
font-size: 12px;
}
[data-page="stats"] [data-component="country-list"] {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin: 20px 0 0;
padding: 0;
list-style: none;
}
[data-page="stats"] [data-component="country-list"] button {
display: grid;
grid-template-columns: 24px minmax(0, 1fr) auto auto;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px;
border: 1px solid var(--stats-line);
border-radius: 0;
background: var(--stats-layer);
color: var(--stats-muted);
font-size: 13px;
line-height: 1.1;
text-align: left;
}
[data-page="stats"] [data-component="country-list"] button[data-active="true"] {
border-color: var(--stats-line-strong);
background: var(--stats-layer-2);
color: var(--stats-muted);
}
[data-page="stats"] [data-component="country-list"] span,
[data-page="stats"] [data-component="country-list"] em {
color: var(--stats-faint);
font-style: normal;
}
[data-page="stats"] [data-component="country-list"] strong {
min-width: 0;
overflow: hidden;
color: var(--stats-text);
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-page="stats"] [data-component="country-list"] b {
color: var(--stats-muted);
font-weight: 600;
}
[data-page="stats"] [data-slot="country-footer"] {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
margin-top: 32px;
}
[data-page="stats"] [data-slot="country-footer"] p {
display: flex;
align-items: center;
gap: 8px;
color: var(--stats-muted);
font-size: 13px;
}
[data-page="stats"] [data-slot="country-footer"] span {
color: var(--stats-faint);
}
[data-page="stats"] [data-section="newsletter"] {
@@ -1035,6 +1167,14 @@
0 1px 2px -1px #00000052,
0 2px 4px #0000003d;
}
[data-page="stats"] [data-slot="logo light"] {
display: none;
}
[data-page="stats"] [data-slot="logo dark"] {
display: block;
}
}
@media (max-width: 74rem) {
@@ -1044,6 +1184,11 @@
}
@media (max-width: 58rem) {
[data-page="stats"] {
--stats-page-padding: 24px;
--stats-section-padding: 4rem;
}
[data-page="stats"] [data-component="top"] {
padding-left: 24px;
padding-right: 24px;
@@ -1129,13 +1274,23 @@
grid-template-rows: none;
}
[data-page="stats"] [data-component="country-list"] {
grid-template-columns: 1fr;
}
[data-page="stats"] [data-slot="market-footer"] {
align-items: flex-start;
flex-direction: column;
gap: 24px;
}
[data-page="stats"] [data-component="chart-tooltip"] {
[data-page="stats"] [data-slot="country-footer"] {
align-items: flex-start;
flex-direction: column;
}
[data-page="stats"] [data-component="chart-tooltip"],
[data-page="stats"] [data-component="map-tooltip"] {
position: static;
margin-top: 16px;
}
+163 -14
View File
@@ -2,6 +2,7 @@ import "./index.css"
import { Meta, Title } from "@solidjs/meta"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import {
type CountryEntry,
getStatsHomeData,
type LeaderboardEntry,
type MarketDay,
@@ -15,12 +16,28 @@ 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"
import logoDark from "../asset/logo-ornate-dark.svg"
import logoLight from "../asset/logo-ornate-light.svg"
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 countryPositions = [
{ x: 112, y: 96 },
{ x: 284, y: 144 },
{ x: 472, y: 92 },
{ x: 642, y: 154 },
{ x: 800, y: 96 },
{ x: 172, y: 234 },
{ x: 362, y: 250 },
{ x: 552, y: 236 },
{ x: 744, y: 252 },
{ x: 48, y: 184 },
{ x: 892, y: 198 },
{ x: 456, y: 176 },
] as const
type UsageProduct = (typeof products)[number]
type TokenProduct = (typeof tokenProducts)[number]
@@ -54,15 +71,7 @@ export default function StatsHome() {
<MarketShareSection data={stats().market} />
<TokenCostSection data={stats().tokenCost} />
<SessionCostSection data={stats().sessionCost} />
<ChartSection
title="Token by Country"
description="Country-level token data is collected in geo_stat; this chart is not wired yet."
>
<EmptyState
title="No country chart"
description="Wire geo_stat into the stats payload to power this chart."
/>
</ChartSection>
<CountrySection data={stats().country} />
<Newsletter />
</>
)}
@@ -741,6 +750,126 @@ function formatSessionCost(value: number) {
return `$${value.toFixed(4)}`
}
function CountrySection(props: { data: StatsHomeData["country"] }) {
const [range, setRange] = createSignal<UsageRange>("1W")
const data = createMemo(() => props.data[range()])
return (
<ChartSection title="Token by Country" description="Country-level token totals from geo_stat.">
<Show
when={data().length > 0}
fallback={<EmptyState title="No country data" description="No geo_stat rows matched this range." />}
>
<CountryChart data={data()} />
</Show>
<div data-slot="country-footer">
<p>
<span>[*]</span>
<strong>Top countries by tokens</strong>
</p>
<FilterPills items={ranges} selected={range()} label="Date range" variant="range" onSelect={setRange} />
</div>
</ChartSection>
)
}
function CountryChart(props: { data: CountryEntry[] }) {
const [activeIndex, setActiveIndex] = createSignal(0)
const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(props.data.length - 1, 0)))
const active = createMemo(() => props.data[selectedIndex()])
const max = createMemo(() => Math.max(0.0001, ...props.data.map((item) => item.tokens)))
return (
<div data-component="country-map">
<svg viewBox="0 0 920 320" role="img" aria-label="Country token share bubble chart">
<For each={props.data.slice(0, countryPositions.length)}>
{(item, index) => {
const position = countryPositions[index()]
const radius = 18 + Math.sqrt(item.tokens / max()) * 58
return (
<g
role="button"
tabIndex={0}
aria-label={`${formatCountry(item.country)} ${formatTokens(item.tokens)}`}
data-active={selectedIndex() === index() ? "true" : undefined}
onPointerEnter={() => setActiveIndex(index())}
onClick={() => setActiveIndex(index())}
onFocus={() => setActiveIndex(index())}
>
<circle cx={position.x} cy={position.y} r={radius} />
<text x={position.x} y={position.y + 4} text-anchor="middle">
{item.country}
</text>
</g>
)
}}
</For>
</svg>
<Show when={active()}>
{(item) => (
<div data-component="map-tooltip">
<strong>{formatCountry(item().country)}</strong>
<span>{item().continent || "Unknown region"}</span>
<p>
<b>{formatTokens(item().tokens)}</b>
<em>{item().share.toFixed(1)}%</em>
</p>
</div>
)}
</Show>
<CountryList data={props.data.slice(0, 8)} activeIndex={selectedIndex()} onActiveIndexChange={setActiveIndex} />
</div>
)
}
function CountryList(props: {
data: CountryEntry[]
activeIndex: number
onActiveIndexChange: (index: number) => void
}) {
return (
<ol data-component="country-list">
<For each={props.data}>
{(item, index) => (
<li>
<button
type="button"
data-active={props.activeIndex === index() ? "true" : undefined}
onClick={() => props.onActiveIndexChange(index())}
onPointerEnter={() => props.onActiveIndexChange(index())}
>
<span>{String(item.rank).padStart(2, "0")}</span>
<strong>{formatCountry(item.country)}</strong>
<em>{formatTokens(item.tokens)}</em>
<b>{item.share.toFixed(1)}%</b>
</button>
</li>
)}
</For>
</ol>
)
}
function formatCountry(country: string) {
const known: Record<string, string> = {
AU: "Australia",
BR: "Brazil",
CA: "Canada",
CN: "China",
DE: "Germany",
FR: "France",
GB: "United Kingdom",
IN: "India",
JP: "Japan",
KR: "South Korea",
NL: "Netherlands",
SG: "Singapore",
US: "United States",
ZZ: "Unknown",
}
return known[country] ?? country
}
function Newsletter() {
return (
<section data-section="newsletter">
@@ -759,13 +888,14 @@ function Newsletter() {
function Header() {
return (
<section data-component="top">
<a data-slot="wordmark" href="/">
opencode
<a data-slot="brand" href="https://opencode.ai/" aria-label="OpenCode home">
<img data-slot="logo light" src={logoLight} alt="OpenCode" width="234" height="42" />
<img data-slot="logo dark" src={logoDark} alt="OpenCode" width="234" height="42" />
</a>
<nav data-component="nav-desktop" aria-label="Main navigation">
<ul>
<li>
<a href="https://github.com/sst/opencode" target="_blank">
<a href="https://github.com/sst/opencode" target="_blank" rel="noreferrer">
GitHub
</a>
</li>
@@ -773,7 +903,26 @@ function Header() {
<a href="https://opencode.ai/docs">Docs</a>
</li>
<li>
<a href="/">Stats</a>
<a href="https://opencode.ai/zen">Zen</a>
</li>
<li>
<a href="https://opencode.ai/go">Go</a>
</li>
<li>
<a href="https://opencode.ai/enterprise">Enterprise</a>
</li>
<li>
<a href="https://opencode.ai/download" data-slot="cta-button">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path
d="M12.1875 9.75L9.00001 12.9375L5.8125 9.75M9.00001 2.0625L9 12.375M14.4375 15.9375H3.5625"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="square"
/>
</svg>
Download
</a>
</li>
</ul>
</nav>
@@ -785,7 +934,7 @@ function Footer() {
return (
<footer data-component="footer">
<div data-slot="cell">
<a href="https://github.com/sst/opencode" target="_blank">
<a href="https://github.com/sst/opencode" target="_blank" rel="noreferrer">
GitHub
</a>
</div>
+30 -2
View File
@@ -1,4 +1,4 @@
import { and, eq } from "drizzle-orm"
import { and, asc, eq } from "drizzle-orm"
import { Effect, Layer } from "effect"
import * as Context from "effect/Context"
import { DatabaseError, DrizzleClient } from "../database"
@@ -16,9 +16,18 @@ import {
export type GeoStatRow = typeof geoStat.$inferInsert
export type GeoStatAggregate = StatBaseAggregate & { country: string; continent: string }
export type GeoStatMetric = {
periodStart: Date
periodEnd: Date
tier: string
country: string
continent: string
totalTokens: number
}
export declare namespace GeoStatRepo {
export interface Service {
readonly listDaily: () => Effect.Effect<GeoStatMetric[], DatabaseError>
readonly listByPeriod: (opts: {
readonly grain: string
readonly periodStart: Date
@@ -37,6 +46,25 @@ export class GeoStatRepo extends Context.Service<GeoStatRepo, GeoStatRepo.Servic
Effect.gen(function* () {
const db = yield* DrizzleClient
const listDaily = Effect.fn("GeoStatRepo.listDaily")(function* () {
return yield* Effect.tryPromise({
try: () =>
db
.select({
periodStart: geoStat.period_start,
periodEnd: geoStat.period_end,
tier: geoStat.tier,
country: geoStat.country,
continent: geoStat.continent,
totalTokens: geoStat.total_tokens,
})
.from(geoStat)
.where(and(eq(geoStat.grain, "day"), eq(geoStat.client, "all"), eq(geoStat.source, "all")))
.orderBy(asc(geoStat.period_start)),
catch: (cause) => DatabaseError.make({ cause }),
})
})
const listByPeriod = Effect.fn("GeoStatRepo.listByPeriod")(function* (opts: {
readonly grain: string
readonly periodStart: Date
@@ -112,7 +140,7 @@ export class GeoStatRepo extends Context.Service<GeoStatRepo, GeoStatRepo.Servic
)
})
return GeoStatRepo.of({ listByPeriod, upsert })
return GeoStatRepo.of({ listDaily, listByPeriod, upsert })
}),
)
}
+108 -14
View File
@@ -1,6 +1,8 @@
import { Effect } from "effect"
import { DatabaseError } from "../database"
import { GeoStatRepo, type GeoStatMetric } from "./geo"
import { ModelStatRepo, type ModelStatMetric } from "./model"
import { ProviderStatRepo, type ProviderStatMetric } from "./provider"
export type UsageProduct = "All Users" | "Zen" | "Go" | "Enterprise"
export type TokenProduct = "Zen" | "Go" | "Enterprise"
@@ -10,6 +12,7 @@ export type MarketDay = { date: string; total: number; authors: { author: string
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 CountryEntry = { country: string; continent: string; tokens: number; share: number; rank: number }
export type StatsHomeData = {
updatedAt: string | null
usage: Record<UsageProduct, Record<UsageRange, UsagePoint[]>>
@@ -17,6 +20,7 @@ export type StatsHomeData = {
market: Record<UsageRange, MarketDay[]>
tokenCost: Record<TokenProduct, TokenCostEntry[]>
sessionCost: Record<TokenProduct, SessionCostEntry[]>
country: Record<UsageRange, CountryEntry[]>
}
const DAY_MS = 86_400_000
@@ -28,6 +32,14 @@ type StatMetricRow = Omit<ModelStatMetric, "periodStart" | "periodEnd"> & {
periodStart: number
periodEnd: number
}
type ProviderMetricRow = Omit<ProviderStatMetric, "periodStart" | "periodEnd"> & {
periodStart: number
periodEnd: number
}
type GeoMetricRow = Omit<GeoStatMetric, "periodStart" | "periodEnd"> & {
periodStart: number
periodEnd: number
}
type DateWindow = { start: number; end: number; previousStart: number; previousEnd: number }
type Bucket = { start: number; end: number; label: string }
@@ -45,20 +57,35 @@ type ModelAggregate = {
totalCostMicrocents: number
}
export const getStatsHomeData: () => Effect.Effect<StatsHomeData, DatabaseError, ModelStatRepo> = Effect.fn(
"StatsHome.getData",
)(function* () {
export const getStatsHomeData: () => Effect.Effect<
StatsHomeData,
DatabaseError,
ModelStatRepo | ProviderStatRepo | GeoStatRepo
> = Effect.fn("StatsHome.getData")(function* () {
const modelStats = yield* ModelStatRepo
return buildStatsHomeData(yield* modelStats.listDaily())
const providerStats = yield* ProviderStatRepo
const geoStats = yield* GeoStatRepo
const [modelRows, providerRows, geoRows] = yield* Effect.all(
[modelStats.listDaily(), providerStats.listDaily(), geoStats.listDaily()],
{ concurrency: "unbounded" },
)
return buildStatsHomeData(modelRows, providerRows, geoRows)
})
function buildStatsHomeData(rows: ModelStatMetric[]): StatsHomeData {
const normalized = rows.flatMap(normalizeStatRow)
if (normalized.length === 0) return emptyStatsHomeData()
function buildStatsHomeData(
modelRows: ModelStatMetric[],
providerRows: ProviderStatMetric[],
geoRows: GeoStatMetric[],
): StatsHomeData {
const normalized = modelRows.flatMap(normalizeStatRow)
const providers = providerRows.flatMap(normalizeProviderRow)
const geo = geoRows.flatMap(normalizeGeoRow)
const periods = [...normalized, ...providers, ...geo]
if (periods.length === 0) return emptyStatsHomeData()
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))
const earliest = Math.min(...periods.map((row) => row.periodStart))
const latest = Math.max(...periods.map((row) => row.periodStart))
const latestEnd = Math.max(...periods.map((row) => row.periodEnd))
return {
updatedAt: new Date(latestEnd).toISOString(),
@@ -68,13 +95,14 @@ function buildStatsHomeData(rows: ModelStatMetric[]): StatsHomeData {
leaderboard: createUsageProductRecord((product) =>
createRangeRecord((range) => buildLeaderboard(normalized, product, getWindow(range, earliest, latest))),
),
market: createRangeRecord((range) => buildMarketShare(normalized, range, getWindow(range, earliest, latest))),
market: createRangeRecord((range) => buildMarketShare(providers, 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)),
),
country: createRangeRecord((range) => buildCountryStats(geo, getWindow(range, earliest, latest))),
}
}
@@ -86,6 +114,7 @@ function emptyStatsHomeData(): StatsHomeData {
market: createRangeRecord(() => []),
tokenCost: createTokenProductRecord(() => []),
sessionCost: createTokenProductRecord(() => []),
country: createRangeRecord(() => []),
}
}
@@ -132,7 +161,7 @@ function buildLeaderboard(rows: StatMetricRow[], product: UsageProduct, window:
}))
}
function buildMarketShare(rows: StatMetricRow[], range: UsageRange, window: DateWindow) {
function buildMarketShare(rows: ProviderMetricRow[], 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,
@@ -160,6 +189,22 @@ function buildMarketShare(rows: StatMetricRow[], range: UsageRange, window: Date
})
}
function buildCountryStats(rows: GeoMetricRow[], window: DateWindow) {
const countries = aggregateByCountry(rowsForProduct(rows, "All Users", window.start, window.end))
.filter((item) => item.tokens > 0)
.toSorted((a, b) => b.tokens - a.tokens)
const totalTokens = countries.reduce((sum, item) => sum + item.tokens, 0)
if (totalTokens === 0) return []
return countries.slice(0, 16).map((item, index) => ({
country: item.country,
continent: item.continent,
tokens: round(item.tokens / 1_000_000_000_000, 4),
share: round((item.tokens / totalTokens) * 100, 1),
rank: index + 1,
}))
}
function buildTokenCost(rows: StatMetricRow[], product: TokenProduct, window: DateWindow) {
return aggregateByModel(rowsForProduct(rows, product, window.start, window.end))
.flatMap((item) => {
@@ -191,7 +236,12 @@ function buildSessionCost(rows: StatMetricRow[], product: TokenProduct, window:
.slice(0, 17)
}
function rowsForProduct(rows: StatMetricRow[], product: UsageProduct, start: number, end: number) {
function rowsForProduct<T extends { periodStart: number; tier: string }>(
rows: T[],
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)
@@ -210,7 +260,7 @@ function aggregateByModel(rows: StatMetricRow[]) {
)
}
function aggregateByProvider(rows: StatMetricRow[]) {
function aggregateByProvider(rows: ProviderMetricRow[]) {
return Object.values(
rows.reduce<Record<string, { provider: string; tokens: number }>>((result, row) => {
result[row.provider] = {
@@ -222,6 +272,19 @@ function aggregateByProvider(rows: StatMetricRow[]) {
)
}
function aggregateByCountry(rows: GeoMetricRow[]) {
return Object.values(
rows.reduce<Record<string, { country: string; continent: string; tokens: number }>>((result, row) => {
result[row.country] = {
country: row.country,
continent: result[row.country]?.continent || row.continent,
tokens: (result[row.country]?.tokens ?? 0) + row.totalTokens,
}
return result
}, {}),
)
}
function combineModelAggregate(current: ModelAggregate | undefined, row: StatMetricRow): ModelAggregate {
return {
model: row.model,
@@ -313,6 +376,37 @@ function normalizeStatRow(row: ModelStatMetric): StatMetricRow[] {
]
}
function normalizeProviderRow(row: ProviderStatMetric): ProviderMetricRow[] {
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",
},
]
}
function normalizeGeoRow(row: GeoStatMetric): GeoMetricRow[] {
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),
country: row.country || "ZZ",
continent: row.continent || "",
},
]
}
function normalizeTier(value: string) {
const normalized = value.toLowerCase()
if (normalized === "paid" || normalized === "zen") return "Zen"
+28 -2
View File
@@ -1,4 +1,4 @@
import { and, eq } from "drizzle-orm"
import { and, asc, eq } from "drizzle-orm"
import { Effect, Layer } from "effect"
import * as Context from "effect/Context"
import { DatabaseError, DrizzleClient } from "../database"
@@ -16,9 +16,17 @@ import {
export type ProviderStatRow = typeof providerStat.$inferInsert
export type ProviderStatAggregate = StatBaseAggregate & { provider: string }
export type ProviderStatMetric = {
periodStart: Date
periodEnd: Date
tier: string
provider: string
totalTokens: number
}
export declare namespace ProviderStatRepo {
export interface Service {
readonly listDaily: () => Effect.Effect<ProviderStatMetric[], DatabaseError>
readonly listByPeriod: (opts: {
readonly grain: string
readonly periodStart: Date
@@ -39,6 +47,24 @@ export class ProviderStatRepo extends Context.Service<ProviderStatRepo, Provider
Effect.gen(function* () {
const db = yield* DrizzleClient
const listDaily = Effect.fn("ProviderStatRepo.listDaily")(function* () {
return yield* Effect.tryPromise({
try: () =>
db
.select({
periodStart: providerStat.period_start,
periodEnd: providerStat.period_end,
tier: providerStat.tier,
provider: providerStat.provider,
totalTokens: providerStat.total_tokens,
})
.from(providerStat)
.where(and(eq(providerStat.grain, "day"), eq(providerStat.client, "all"), eq(providerStat.source, "all")))
.orderBy(asc(providerStat.period_start)),
catch: (cause) => DatabaseError.make({ cause }),
})
})
const listByPeriod = Effect.fn("ProviderStatRepo.listByPeriod")(function* (opts: {
readonly grain: string
readonly periodStart: Date
@@ -113,7 +139,7 @@ export class ProviderStatRepo extends Context.Service<ProviderStatRepo, Provider
)
})
return ProviderStatRepo.of({ listByPeriod, upsert })
return ProviderStatRepo.of({ listDaily, listByPeriod, upsert })
}),
)
}