feat(stats): add unique user charts
This commit is contained in:
parent
2d993cd0d5
commit
24c70ec974
@ -45,6 +45,7 @@ const statsUnfurlUrl = new URL(statsUnfurlPath, statsCanonicalBaseUrl).toString(
|
||||
const modelHeaderLinks: readonly HeaderLink[] = [
|
||||
{ href: "#overview", label: "Overview" },
|
||||
{ href: "#usage", label: "Usage" },
|
||||
{ href: "#users", label: "Users" },
|
||||
{ href: "#efficiency", label: "Efficiency" },
|
||||
{ href: "#geo-breakdown", label: "Geo Breakdown" },
|
||||
{ href: "#peers", label: "Peers" },
|
||||
@ -175,6 +176,7 @@ export default function StatsModel() {
|
||||
<ModelHero data={stats() ?? null} catalog={catalogEntry() ?? null} labName={labName()} />
|
||||
<ModelOverview data={stats() ?? null} />
|
||||
<ModelUsageSection data={stats()?.usage ?? []} />
|
||||
<ModelUsersSection data={stats()?.usage ?? []} />
|
||||
<ModelEfficiencySection data={stats() ?? null} catalog={catalogEntry() ?? null} />
|
||||
<ModelGeoBreakdownSection data={stats()?.country ?? emptyCountryRecord()} />
|
||||
<ModelPeersSection data={stats() ?? null} />
|
||||
@ -358,14 +360,6 @@ function ModelOverview(props: { data: StatsModelData | null }) {
|
||||
}
|
||||
|
||||
function ModelUsageSection(props: { data: ModelUsagePoint[] }) {
|
||||
const [activeIndex, setActiveIndex] = createSignal<number>()
|
||||
const max = createMemo(() => Math.max(0, ...props.data.map((item) => item.tokens)) || 1)
|
||||
const activePoint = createMemo(() => {
|
||||
const index = activeIndex()
|
||||
if (index === undefined) return undefined
|
||||
return props.data[index]
|
||||
})
|
||||
|
||||
return (
|
||||
<section id="usage" data-section="model-panel">
|
||||
<SectionTitle title="Usage" description="Daily OpenCode Go token volume over the recent two-month window." />
|
||||
@ -373,92 +367,144 @@ function ModelUsageSection(props: { data: ModelUsagePoint[] }) {
|
||||
when={props.data.some((item) => item.tokens > 0)}
|
||||
fallback={<ModelEmptyState title="No usage" description="No usage landed in the current window." />}
|
||||
>
|
||||
<div
|
||||
data-component="model-usage-chart"
|
||||
data-dense-labels={isModelUsageDense(props.data.length) ? "true" : undefined}
|
||||
role="img"
|
||||
aria-label="Daily token usage chart"
|
||||
style={{ "--model-usage-count": props.data.length } as JSX.CSSProperties}
|
||||
onPointerLeave={(event) => {
|
||||
if (event.pointerType === "touch") return
|
||||
setActiveIndex(undefined)
|
||||
}}
|
||||
>
|
||||
<div data-slot="model-usage-axis" aria-hidden="true">
|
||||
<For each={props.data}>
|
||||
{(point, index) => (
|
||||
<div
|
||||
data-active={activeIndex() === index() ? "true" : undefined}
|
||||
data-label-hidden={isModelUsageLabelHidden(index(), props.data.length) ? "true" : undefined}
|
||||
>
|
||||
<span data-slot="model-usage-label">
|
||||
<span data-slot="model-usage-total">{formatTokens(point.tokens)}</span>
|
||||
<span data-slot="model-usage-date">{point.date}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div data-slot="model-usage-bars">
|
||||
<For each={props.data}>
|
||||
{(point, index) => (
|
||||
<div
|
||||
data-slot="model-usage-column"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${point.date} ${formatTokens(point.tokens)} tokens`}
|
||||
data-active={activeIndex() === index() ? "true" : undefined}
|
||||
data-muted={activeIndex() !== undefined && activeIndex() !== index() ? "true" : undefined}
|
||||
onPointerDown={(event) => {
|
||||
if (event.pointerType !== "touch") return
|
||||
setActiveIndex(index())
|
||||
}}
|
||||
onPointerEnter={() => setActiveIndex(index())}
|
||||
onPointerMove={(event) => {
|
||||
if (event.pointerType === "touch") return
|
||||
setActiveIndex(index())
|
||||
}}
|
||||
onClick={() => setActiveIndex(index())}
|
||||
onFocus={() => setActiveIndex(index())}
|
||||
onBlur={() => setActiveIndex(undefined)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
setActiveIndex(index())
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-slot="model-usage-bar"
|
||||
style={{ "--model-usage-fill": `${modelUsageHeight(point.tokens, max())}%` } as JSX.CSSProperties}
|
||||
/>
|
||||
<Show when={activeIndex() === index() && activePoint()}>
|
||||
{(active) => (
|
||||
<div
|
||||
data-component="chart-tooltip"
|
||||
data-placement={index() > props.data.length * 0.62 ? "left" : "right"}
|
||||
>
|
||||
<strong>{active().date}</strong>
|
||||
<span>{formatTokens(active().tokens)} tokens</span>
|
||||
<div data-slot="tooltip-divider" />
|
||||
<p>
|
||||
<span data-slot="tooltip-label">
|
||||
<i /> Daily tokens
|
||||
</span>
|
||||
<b>{formatTokens(active().tokens)}</b>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
<ModelColumnChart data={props.data} metric="tokens" ariaLabel="Daily token usage chart" />
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelUsersSection(props: { data: ModelUsagePoint[] }) {
|
||||
return (
|
||||
<section id="users" data-section="model-panel">
|
||||
<SectionTitle title="Unique Users" description="Daily unique OpenCode Go users over the recent two-month window." />
|
||||
<Show
|
||||
when={props.data.some((item) => item.users > 0)}
|
||||
fallback={<ModelEmptyState title="No user data" description="No user-bearing rows landed in the current window." />}
|
||||
>
|
||||
<ModelColumnChart data={props.data} metric="users" ariaLabel="Daily unique user chart" />
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelColumnChart(props: {
|
||||
data: ModelUsagePoint[]
|
||||
metric: "tokens" | "users"
|
||||
ariaLabel: string
|
||||
}) {
|
||||
const [activeIndex, setActiveIndex] = createSignal<number>()
|
||||
const max = createMemo(() => Math.max(0, ...props.data.map((item) => modelUsageMetricValue(item, props.metric))) || 1)
|
||||
const activePoint = createMemo(() => {
|
||||
const index = activeIndex()
|
||||
if (index === undefined) return undefined
|
||||
return props.data[index]
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="model-usage-chart"
|
||||
data-metric={props.metric}
|
||||
data-dense-labels={isModelUsageDense(props.data.length) ? "true" : undefined}
|
||||
role="img"
|
||||
aria-label={props.ariaLabel}
|
||||
style={{ "--model-usage-count": props.data.length } as JSX.CSSProperties}
|
||||
onPointerLeave={(event) => {
|
||||
if (event.pointerType === "touch") return
|
||||
setActiveIndex(undefined)
|
||||
}}
|
||||
>
|
||||
<div data-slot="model-usage-axis" aria-hidden="true">
|
||||
<For each={props.data}>
|
||||
{(point, index) => (
|
||||
<div
|
||||
data-active={activeIndex() === index() ? "true" : undefined}
|
||||
data-label-hidden={isModelUsageLabelHidden(index(), props.data.length) ? "true" : undefined}
|
||||
>
|
||||
<span data-slot="model-usage-label">
|
||||
<span data-slot="model-usage-total">{formatModelUsageValue(point, props.metric)}</span>
|
||||
<span data-slot="model-usage-date">{point.date}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div data-slot="model-usage-bars">
|
||||
<For each={props.data}>
|
||||
{(point, index) => (
|
||||
<div
|
||||
data-slot="model-usage-column"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${point.date} ${formatModelUsageValue(point, props.metric)} ${modelUsageLabel(props.metric)}`}
|
||||
data-active={activeIndex() === index() ? "true" : undefined}
|
||||
data-muted={activeIndex() !== undefined && activeIndex() !== index() ? "true" : undefined}
|
||||
onPointerDown={(event) => {
|
||||
if (event.pointerType !== "touch") return
|
||||
setActiveIndex(index())
|
||||
}}
|
||||
onPointerEnter={() => setActiveIndex(index())}
|
||||
onPointerMove={(event) => {
|
||||
if (event.pointerType === "touch") return
|
||||
setActiveIndex(index())
|
||||
}}
|
||||
onClick={() => setActiveIndex(index())}
|
||||
onFocus={() => setActiveIndex(index())}
|
||||
onBlur={() => setActiveIndex(undefined)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
setActiveIndex(index())
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-slot="model-usage-bar"
|
||||
style={{
|
||||
"--model-usage-fill": `${modelUsageHeight(modelUsageMetricValue(point, props.metric), max())}%`,
|
||||
} as JSX.CSSProperties}
|
||||
/>
|
||||
<Show when={activeIndex() === index() && activePoint()}>
|
||||
{(active) => (
|
||||
<div
|
||||
data-component="chart-tooltip"
|
||||
data-placement={index() > props.data.length * 0.62 ? "left" : "right"}
|
||||
>
|
||||
<strong>{active().date}</strong>
|
||||
<span>
|
||||
{formatModelUsageValue(active(), props.metric)} {modelUsageLabel(props.metric)}
|
||||
</span>
|
||||
<div data-slot="tooltip-divider" />
|
||||
<p>
|
||||
<span data-slot="tooltip-label">
|
||||
<i /> Daily {modelUsageLabel(props.metric)}
|
||||
</span>
|
||||
<b>{formatModelUsageValue(active(), props.metric)}</b>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function modelUsageMetricValue(point: ModelUsagePoint, metric: "tokens" | "users") {
|
||||
if (metric === "users") return point.users
|
||||
return point.tokens
|
||||
}
|
||||
|
||||
function formatModelUsageValue(point: ModelUsagePoint, metric: "tokens" | "users") {
|
||||
if (metric === "users") return formatUsers(point.users)
|
||||
return formatTokens(point.tokens)
|
||||
}
|
||||
|
||||
function modelUsageLabel(metric: "tokens" | "users") {
|
||||
if (metric === "users") return "users"
|
||||
return "tokens"
|
||||
}
|
||||
|
||||
function ModelEfficiencySection(props: { data: StatsModelData | null; catalog: ModelCatalogEntry | null }) {
|
||||
return (
|
||||
<section id="efficiency" data-section="model-panel">
|
||||
@ -834,6 +880,12 @@ function formatInteger(value: number) {
|
||||
return new Intl.NumberFormat("en").format(value)
|
||||
}
|
||||
|
||||
function formatUsers(value: number) {
|
||||
if (value >= 1_000_000) return `${trimNumber(value / 1_000_000, value >= 10_000_000 ? 0 : 1)}M`
|
||||
if (value >= 1_000) return `${trimNumber(value / 1_000, value >= 10_000 ? 0 : 1)}K`
|
||||
return formatInteger(Math.round(value))
|
||||
}
|
||||
|
||||
function formatPercent(value: number) {
|
||||
return `${value.toFixed(value > 0 && value < 10 ? 1 : 0)}%`
|
||||
}
|
||||
|
||||
@ -1546,7 +1546,7 @@
|
||||
color: var(--stats-text);
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] {
|
||||
[data-page="stats"] :is([data-section="top-models"], [data-section="unique-users"]) [data-component="chart-tooltip"] {
|
||||
top: 110px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
@ -1564,40 +1564,53 @@
|
||||
color: var(--stats-text);
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"][data-placement="right"] {
|
||||
[data-page="stats"]
|
||||
:is([data-section="top-models"], [data-section="unique-users"])
|
||||
[data-component="chart-tooltip"][data-placement="right"] {
|
||||
right: auto;
|
||||
left: calc(100% + 8px);
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"][data-placement="left"] {
|
||||
[data-page="stats"]
|
||||
:is([data-section="top-models"], [data-section="unique-users"])
|
||||
[data-component="chart-tooltip"][data-placement="left"] {
|
||||
right: calc(100% + 8px);
|
||||
left: auto;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] strong,
|
||||
[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] > span {
|
||||
[data-page="stats"] :is([data-section="top-models"], [data-section="unique-users"]) [data-component="chart-tooltip"] strong,
|
||||
[data-page="stats"]
|
||||
:is([data-section="top-models"], [data-section="unique-users"])
|
||||
[data-component="chart-tooltip"]
|
||||
> span {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
line-height: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] strong {
|
||||
[data-page="stats"] :is([data-section="top-models"], [data-section="unique-users"]) [data-component="chart-tooltip"] strong {
|
||||
padding: 8px 8px 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] > span {
|
||||
[data-page="stats"]
|
||||
:is([data-section="top-models"], [data-section="unique-users"])
|
||||
[data-component="chart-tooltip"]
|
||||
> span {
|
||||
padding: 4px 8px 8px;
|
||||
color: var(--stats-muted);
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] [data-slot="tooltip-divider"] {
|
||||
[data-page="stats"]
|
||||
:is([data-section="top-models"], [data-section="unique-users"])
|
||||
[data-component="chart-tooltip"]
|
||||
[data-slot="tooltip-divider"] {
|
||||
height: 0.5px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] p {
|
||||
[data-page="stats"] :is([data-section="top-models"], [data-section="unique-users"]) [data-component="chart-tooltip"] p {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 4px;
|
||||
height: 16px;
|
||||
@ -1608,36 +1621,50 @@
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] p[data-muted="true"] {
|
||||
[data-page="stats"]
|
||||
:is([data-section="top-models"], [data-section="unique-users"])
|
||||
[data-component="chart-tooltip"]
|
||||
p[data-muted="true"] {
|
||||
opacity: 0.46;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] [data-slot="tooltip-divider"] + p {
|
||||
[data-page="stats"]
|
||||
:is([data-section="top-models"], [data-section="unique-users"])
|
||||
[data-component="chart-tooltip"]
|
||||
[data-slot="tooltip-divider"]
|
||||
+ p {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] p:last-child {
|
||||
[data-page="stats"]
|
||||
:is([data-section="top-models"], [data-section="unique-users"])
|
||||
[data-component="chart-tooltip"]
|
||||
p:last-child {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] [data-slot="tooltip-label"] {
|
||||
[data-page="stats"]
|
||||
:is([data-section="top-models"], [data-section="unique-users"])
|
||||
[data-component="chart-tooltip"]
|
||||
[data-slot="tooltip-label"] {
|
||||
grid-template-columns: 16px minmax(0, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] i {
|
||||
[data-page="stats"] :is([data-section="top-models"], [data-section="unique-users"]) [data-component="chart-tooltip"] i {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] b {
|
||||
[data-page="stats"] :is([data-section="top-models"], [data-section="unique-users"]) [data-component="chart-tooltip"] b {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
[data-page="stats"]
|
||||
:is(
|
||||
[data-section="leaderboard"],
|
||||
[data-section="unique-users"],
|
||||
[data-section="market-share"],
|
||||
[data-section="geo-breakdown"],
|
||||
[data-section="token-cost"],
|
||||
@ -3264,10 +3291,12 @@
|
||||
background: #242424f2;
|
||||
}
|
||||
|
||||
[data-page="stats"][data-theme="dark"] [data-section="top-models"] [data-component="chart-tooltip"],
|
||||
[data-page="stats"][data-theme="dark"]
|
||||
:is([data-section="top-models"], [data-section="unique-users"])
|
||||
[data-component="chart-tooltip"],
|
||||
:root[data-stats-theme="dark"]
|
||||
[data-page="stats"]:not([data-theme="light"])
|
||||
[data-section="top-models"]
|
||||
:is([data-section="top-models"], [data-section="unique-users"])
|
||||
[data-component="chart-tooltip"] {
|
||||
background: #242424f2;
|
||||
box-shadow:
|
||||
@ -3276,10 +3305,13 @@
|
||||
0 4px 8px #00000052;
|
||||
}
|
||||
|
||||
[data-page="stats"][data-theme="dark"] [data-section="top-models"] [data-component="chart-tooltip"] > span,
|
||||
[data-page="stats"][data-theme="dark"]
|
||||
:is([data-section="top-models"], [data-section="unique-users"])
|
||||
[data-component="chart-tooltip"]
|
||||
> span,
|
||||
:root[data-stats-theme="dark"]
|
||||
[data-page="stats"]:not([data-theme="light"])
|
||||
[data-section="top-models"]
|
||||
:is([data-section="top-models"], [data-section="unique-users"])
|
||||
[data-component="chart-tooltip"]
|
||||
> span {
|
||||
color: var(--stats-faint);
|
||||
@ -3544,6 +3576,7 @@
|
||||
@media (max-width: 74rem) {
|
||||
[data-page="stats"] [data-section="top-models"],
|
||||
[data-page="stats"] [data-section="leaderboard"],
|
||||
[data-page="stats"] [data-section="unique-users"],
|
||||
[data-page="stats"] [data-section="market-share"],
|
||||
[data-page="stats"] [data-section="geo-breakdown"],
|
||||
[data-page="stats"] [data-section="token-cost"],
|
||||
@ -3703,6 +3736,7 @@
|
||||
@media (max-width: 47.999rem) {
|
||||
[data-page="stats"] [data-section="top-models"],
|
||||
[data-page="stats"] [data-section="leaderboard"],
|
||||
[data-page="stats"] [data-section="unique-users"],
|
||||
[data-page="stats"] [data-section="market-share"],
|
||||
[data-page="stats"] [data-section="geo-breakdown"],
|
||||
[data-page="stats"] [data-section="token-cost"],
|
||||
@ -4011,7 +4045,7 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"] {
|
||||
[data-page="stats"] :is([data-section="top-models"], [data-section="unique-users"]) [data-component="chart-tooltip"] {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
right: 12px;
|
||||
@ -4025,7 +4059,9 @@
|
||||
transform: none;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-section="top-models"] [data-component="chart-tooltip"][data-placement] {
|
||||
[data-page="stats"]
|
||||
:is([data-section="top-models"], [data-section="unique-users"])
|
||||
[data-component="chart-tooltip"][data-placement] {
|
||||
right: 12px;
|
||||
left: 12px;
|
||||
}
|
||||
|
||||
@ -171,6 +171,7 @@ export default function StatsHome() {
|
||||
<>
|
||||
<Hero updatedAt={stats().updatedAt} />
|
||||
<TopModelsSection data={stats().usage} leaderboard={stats().leaderboard} />
|
||||
<UniqueUsersSection data={stats().users} />
|
||||
<SessionCostSection data={stats().sessionCost} />
|
||||
<TokenCostSection data={stats().tokenCost} catalog={catalog() ?? null} />
|
||||
<CacheRatioSection data={stats().cacheRatio} />
|
||||
@ -598,6 +599,8 @@ function FilterPills<T extends string>(props: {
|
||||
function TopModelsChart(props: {
|
||||
data: UsagePoint[]
|
||||
range: UsageRange
|
||||
metric?: "tokens" | "users"
|
||||
ariaLabel?: string
|
||||
activeModel: string | undefined
|
||||
onActiveModelChange: (model: string | undefined) => void
|
||||
}) {
|
||||
@ -606,6 +609,7 @@ function TopModelsChart(props: {
|
||||
const maxTotal = createMemo(() => getTopModelsMaxTotal(props.data))
|
||||
const segmentOrder = createMemo(() => getTopModelsSegmentOrder(props.data))
|
||||
const activePoint = createMemo(() => props.data[activeIndex() ?? -1])
|
||||
const metric = createMemo(() => props.metric ?? "tokens")
|
||||
|
||||
createEffect(() => scrollDenseChartToEnd(chartRef, props.range, props.data.length))
|
||||
|
||||
@ -614,9 +618,10 @@ function TopModelsChart(props: {
|
||||
ref={chartRef}
|
||||
data-component="top-models-chart"
|
||||
data-range={props.range}
|
||||
data-metric={metric()}
|
||||
data-dense-labels={isDenseColumnRange(props.range) ? "true" : undefined}
|
||||
role="img"
|
||||
aria-label="Stacked top model usage chart"
|
||||
aria-label={props.ariaLabel ?? "Stacked top model usage chart"}
|
||||
style={{ "--top-models-count": props.data.length } as JSX.CSSProperties}
|
||||
onPointerLeave={(event) => {
|
||||
if (event.pointerType === "touch") return
|
||||
@ -633,7 +638,7 @@ function TopModelsChart(props: {
|
||||
data-mobile-hidden={isTopModelsMobileAxisHidden(index(), props.data.length) ? "true" : undefined}
|
||||
>
|
||||
<span data-slot="axis-label">
|
||||
<span data-slot="axis-total">{formatTokens(usageTotal(day))}</span>
|
||||
<span data-slot="axis-total">{formatUsageChartValue(usageTotal(day), metric())}</span>
|
||||
<span data-slot="axis-date">
|
||||
<span data-slot="axis-date-full">{day.date}</span>
|
||||
<span data-slot="axis-date-mobile">{formatTopModelsMobileDate(day.date, props.range)}</span>
|
||||
@ -657,7 +662,7 @@ function TopModelsChart(props: {
|
||||
data-slot="top-models-bar"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${day.date} ${formatTokens(usageTotal(day))}`}
|
||||
aria-label={`${day.date} ${formatUsageChartValue(usageTotal(day), metric())} ${usageChartTotalLabel(metric())}`}
|
||||
data-active={activeIndex() === dayIndex() ? "true" : undefined}
|
||||
data-muted={activeIndex() !== undefined && activeIndex() !== dayIndex() ? "true" : undefined}
|
||||
style={{ "--top-models-bar-height": `${getTopModelsBarHeight(usageTotal(day), maxTotal())}%` }}
|
||||
@ -739,7 +744,9 @@ function TopModelsChart(props: {
|
||||
data-placement={dayIndex() > props.data.length * 0.62 ? "left" : "right"}
|
||||
>
|
||||
<strong>{point().date}</strong>
|
||||
<span>{formatTokens(usageTotal(point()))} total</span>
|
||||
<span>
|
||||
{formatUsageChartValue(usageTotal(point()), metric())} {usageChartTotalLabel(metric())}
|
||||
</span>
|
||||
<div data-slot="tooltip-divider" />
|
||||
<For each={visibleTopModelsSegments(point())}>
|
||||
{(item) => (
|
||||
@ -759,7 +766,7 @@ function TopModelsChart(props: {
|
||||
/>{" "}
|
||||
{item.segment.model}
|
||||
</span>
|
||||
<b>{formatTokens(item.segment.value)}</b>
|
||||
<b>{formatUsageChartValue(item.segment.value, metric())}</b>
|
||||
</p>
|
||||
)}
|
||||
</For>
|
||||
@ -774,6 +781,31 @@ function TopModelsChart(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function UniqueUsersSection(props: { data: StatsHomeData["users"] }) {
|
||||
const [activeModel, setActiveModel] = createSignal<string>()
|
||||
const data = createMemo(() => props.data.Go["2M"])
|
||||
|
||||
return (
|
||||
<section id="unique-users" data-section="unique-users">
|
||||
<SectionBridge label="TOP MODELS" href="#top-models" />
|
||||
<SectionTitle title="Unique Users" description="Daily unique OpenCode Go users by model." />
|
||||
<Show
|
||||
when={data().some((item) => usageTotal(item) > 0)}
|
||||
fallback={<EmptyState title="No user data" description="No user-bearing model_stat rows matched this window." />}
|
||||
>
|
||||
<TopModelsChart
|
||||
data={data()}
|
||||
range="2M"
|
||||
metric="users"
|
||||
ariaLabel="Stacked unique user chart by model"
|
||||
activeModel={activeModel()}
|
||||
onActiveModelChange={setActiveModel}
|
||||
/>
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function isTopModelsBlankHover(bar: HTMLElement, clientY: number) {
|
||||
const stack = bar.querySelector<HTMLElement>('[data-slot="top-models-stack"]')
|
||||
if (!stack) return true
|
||||
@ -864,6 +896,22 @@ function formatTokens(value: number) {
|
||||
return `${Math.round(value * 1000)}B`
|
||||
}
|
||||
|
||||
function formatUsageChartValue(value: number, metric: "tokens" | "users") {
|
||||
if (metric === "users") return formatUsers(value)
|
||||
return formatTokens(value)
|
||||
}
|
||||
|
||||
function usageChartTotalLabel(metric: "tokens" | "users") {
|
||||
if (metric === "users") return "model users"
|
||||
return "total"
|
||||
}
|
||||
|
||||
function formatUsers(value: number) {
|
||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(value >= 10_000_000 ? 0 : 1)}M`
|
||||
if (value >= 1_000) return `${(value / 1_000).toFixed(value >= 10_000 ? 0 : 1)}K`
|
||||
return new Intl.NumberFormat("en").format(Math.round(value))
|
||||
}
|
||||
|
||||
function Leaderboard(props: {
|
||||
data: LeaderboardEntry[]
|
||||
activeModel: string | undefined
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
ALTER TABLE `geo_stat` ADD `unique_users` bigint NOT NULL DEFAULT 0;--> statement-breakpoint
|
||||
ALTER TABLE `model_stat` ADD `unique_users` bigint NOT NULL DEFAULT 0;--> statement-breakpoint
|
||||
ALTER TABLE `provider_stat` ADD `unique_users` bigint NOT NULL DEFAULT 0;
|
||||
@ -123,6 +123,7 @@ function metricColumns() {
|
||||
return {
|
||||
sessions: bigint({ mode: "number" }).notNull().default(0),
|
||||
requests: bigint({ mode: "number" }).notNull().default(0),
|
||||
unique_users: bigint({ mode: "number" }).notNull().default(0),
|
||||
input_tokens: bigint({ mode: "number" }).notNull().default(0),
|
||||
output_tokens: bigint({ mode: "number" }).notNull().default(0),
|
||||
reasoning_tokens: bigint({ mode: "number" }).notNull().default(0),
|
||||
|
||||
@ -145,6 +145,7 @@ export class GeoStatRepo extends Context.Service<GeoStatRepo, GeoStatRepo.Servic
|
||||
continent: inserted("continent"),
|
||||
sessions: inserted("sessions"),
|
||||
requests: inserted("requests"),
|
||||
unique_users: inserted("unique_users"),
|
||||
input_tokens: inserted("input_tokens"),
|
||||
output_tokens: inserted("output_tokens"),
|
||||
reasoning_tokens: inserted("reasoning_tokens"),
|
||||
|
||||
@ -21,7 +21,7 @@ export type TokenCostEntry = { model: string; total: number; input: number; outp
|
||||
export type CacheRatioEntry = { model: string; ratio: number; cached: number; uncached: number; total: 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 ModelUsagePoint = { date: string; tokens: number; sessions: number; cost: number }
|
||||
export type ModelUsagePoint = { date: string; tokens: number; users: number; sessions: number; cost: number }
|
||||
export type ModelMixEntry = { label: string; tokens: number; share: number }
|
||||
export type ModelPeerEntry = {
|
||||
model: string
|
||||
@ -82,6 +82,7 @@ export type StatsLabData = {
|
||||
export type StatsHomeData = {
|
||||
updatedAt: string | null
|
||||
usage: Record<UsageProduct, Record<UsageRange, UsagePoint[]>>
|
||||
users: Record<UsageProduct, Record<UsageRange, UsagePoint[]>>
|
||||
leaderboard: Record<UsageProduct, Record<UsageRange, LeaderboardEntry[]>>
|
||||
market: Record<UsageRange, MarketDay[]>
|
||||
tokenCost: Record<TokenProduct, TokenCostEntry[]>
|
||||
@ -118,6 +119,7 @@ type ModelAggregate = {
|
||||
model: string
|
||||
provider: string
|
||||
sessions: number
|
||||
uniqueUsers: number
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
reasoningTokens: number
|
||||
@ -200,6 +202,18 @@ function buildStatsHomeData(
|
||||
),
|
||||
),
|
||||
),
|
||||
users: createUsageProductRecord((product) =>
|
||||
createRangeRecord((range) =>
|
||||
buildUsagePoints(
|
||||
normalized,
|
||||
product,
|
||||
range,
|
||||
getWindow(range, earliest, latest),
|
||||
getWindow("1W", earliest, latest),
|
||||
"users",
|
||||
),
|
||||
),
|
||||
),
|
||||
leaderboard: createUsageProductRecord((product) =>
|
||||
createRangeRecord((range) => buildLeaderboard(normalized, product, getWindow("1W", earliest, latest))),
|
||||
),
|
||||
@ -340,6 +354,7 @@ function emptyStatsHomeData(): StatsHomeData {
|
||||
return {
|
||||
updatedAt: null,
|
||||
usage: createUsageProductRecord(() => createRangeRecord(() => [])),
|
||||
users: createUsageProductRecord(() => createRangeRecord(() => [])),
|
||||
leaderboard: createUsageProductRecord(() => createRangeRecord(() => [])),
|
||||
market: createRangeRecord(() => []),
|
||||
tokenCost: createTokenProductRecord(() => []),
|
||||
@ -355,28 +370,39 @@ function buildUsagePoints(
|
||||
range: UsageRange,
|
||||
window: DateWindow,
|
||||
rankWindow: DateWindow,
|
||||
metric: "tokens" | "users" = "tokens",
|
||||
) {
|
||||
const modelOrder = aggregateByModelName(rowsForProduct(rows, product, rankWindow.start, rankWindow.end))
|
||||
.toSorted((a, b) => b.totalTokens - a.totalTokens)
|
||||
.toSorted((a, b) => modelUsageValue(b, metric) - modelUsageValue(a, metric))
|
||||
.slice(0, TOP_MODEL_SEGMENT_LIMIT)
|
||||
.map((item) => item.model)
|
||||
|
||||
return createBuckets(window, range).map((bucket) => {
|
||||
const bucketRows = aggregateByModelName(rowsForProduct(rows, product, bucket.start, bucket.end))
|
||||
const byModel = new Map(bucketRows.map((item) => [item.model, item.totalTokens]))
|
||||
const segmentTokens = modelOrder.map((model) => ({ model, tokens: byModel.get(model) ?? 0 }))
|
||||
const knownTokens = segmentTokens.reduce((sum, item) => sum + item.tokens, 0)
|
||||
const totalTokens = bucketRows.reduce((sum, item) => sum + item.totalTokens, 0)
|
||||
const byModel = new Map(bucketRows.map((item) => [item.model, modelUsageValue(item, metric)]))
|
||||
const segments = modelOrder.map((model) => ({ model, value: byModel.get(model) ?? 0 }))
|
||||
const knownValue = segments.reduce((sum, item) => sum + item.value, 0)
|
||||
const totalValue = bucketRows.reduce((sum, item) => sum + modelUsageValue(item, metric), 0)
|
||||
return {
|
||||
date: bucket.label,
|
||||
segments: [
|
||||
...segmentTokens.map((item) => ({ model: item.model, value: round(item.tokens / 1_000_000_000_000, 4) })),
|
||||
{ model: "Other", value: round(Math.max(totalTokens - knownTokens, 0) / 1_000_000_000_000, 4) },
|
||||
...segments.map((item) => ({ model: item.model, value: usagePointValue(item.value, metric) })),
|
||||
{ model: "Other", value: usagePointValue(Math.max(totalValue - knownValue, 0), metric) },
|
||||
],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function modelUsageValue(item: ModelAggregate, metric: "tokens" | "users") {
|
||||
if (metric === "users") return item.uniqueUsers
|
||||
return item.totalTokens
|
||||
}
|
||||
|
||||
function usagePointValue(value: number, metric: "tokens" | "users") {
|
||||
if (metric === "users") return value
|
||||
return round(value / 1_000_000_000_000, 4)
|
||||
}
|
||||
|
||||
function buildLeaderboard(rows: StatMetricRow[], product: UsageProduct, rankWindow: DateWindow) {
|
||||
const previous = new Map(
|
||||
aggregateByModelName(rowsForProduct(rows, product, rankWindow.previousStart, rankWindow.previousEnd)).map(
|
||||
@ -502,6 +528,7 @@ function buildModelUsage(rows: StatMetricRow[], window: DateWindow, range: Usage
|
||||
return {
|
||||
date: bucket.label,
|
||||
tokens: aggregate.totalTokens,
|
||||
users: aggregate.uniqueUsers,
|
||||
sessions: aggregate.sessions,
|
||||
cost: round(microcentsToDollars(aggregate.totalCostMicrocents), 2),
|
||||
}
|
||||
@ -601,6 +628,7 @@ function combineRowsForModel(model: string, rows: StatMetricRow[]): ModelAggrega
|
||||
model,
|
||||
provider: "unknown",
|
||||
sessions: 0,
|
||||
uniqueUsers: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
@ -617,6 +645,7 @@ function combineModelAggregate(current: ModelAggregate | undefined, row: StatMet
|
||||
model: row.model,
|
||||
provider: row.provider,
|
||||
sessions: (current?.sessions ?? 0) + row.sessions,
|
||||
uniqueUsers: (current?.uniqueUsers ?? 0) + row.uniqueUsers,
|
||||
inputTokens: (current?.inputTokens ?? 0) + row.inputTokens,
|
||||
outputTokens: (current?.outputTokens ?? 0) + row.outputTokens,
|
||||
reasoningTokens: (current?.reasoningTokens ?? 0) + row.reasoningTokens,
|
||||
|
||||
@ -40,6 +40,7 @@ export function buildStatsQuery(periodStart: Date, periodEnd: Date, dimension: S
|
||||
const aggregateColumns = `
|
||||
COUNT(DISTINCT session) AS sessions,
|
||||
COUNT(*) AS requests,
|
||||
COUNT(DISTINCT user_key) AS unique_users,
|
||||
COALESCE(SUM(tokens_input), 0) AS input_tokens,
|
||||
COALESCE(SUM(tokens_output), 0) AS output_tokens,
|
||||
COALESCE(SUM(tokens_reasoning), 0) AS reasoning_tokens,
|
||||
@ -70,6 +71,8 @@ WITH normalized AS (
|
||||
UPPER(COALESCE(NULLIF(cf_country, ''), 'ZZ')) AS country,
|
||||
COALESCE(NULLIF(cf_continent, ''), '') AS continent,
|
||||
session,
|
||||
COALESCE(NULLIF(workspace, ''), '') AS workspace,
|
||||
COALESCE(NULLIF(api_key, ''), '') AS api_key,
|
||||
status,
|
||||
duration AS duration_ms,
|
||||
time_to_first_byte AS ttfb_ms,
|
||||
@ -108,6 +111,7 @@ WITH normalized AS (
|
||||
country,
|
||||
continent,
|
||||
session,
|
||||
COALESCE(NULLIF(workspace, ''), NULLIF(api_key, '')) AS user_key,
|
||||
status,
|
||||
duration_ms,
|
||||
ttfb_ms,
|
||||
@ -197,6 +201,7 @@ function toStatBaseAggregate(data: AthenaData): StatBaseAggregate[] {
|
||||
tier: normalizeTier(data.tier || "unknown"),
|
||||
sessions: integer(data, "sessions"),
|
||||
requests: integer(data, "requests"),
|
||||
unique_users: integer(data, "unique_users"),
|
||||
input_tokens: integer(data, "input_tokens"),
|
||||
output_tokens: integer(data, "output_tokens"),
|
||||
reasoning_tokens: integer(data, "reasoning_tokens"),
|
||||
|
||||
@ -27,6 +27,7 @@ export type ModelStatMetric = {
|
||||
provider: string
|
||||
model: string
|
||||
sessions: number
|
||||
uniqueUsers: number
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
reasoningTokens: number
|
||||
@ -64,6 +65,7 @@ export class ModelStatRepo extends Context.Service<ModelStatRepo, ModelStatRepo.
|
||||
provider: modelStat.provider,
|
||||
model: modelStat.model,
|
||||
sessions: modelStat.sessions,
|
||||
uniqueUsers: modelStat.unique_users,
|
||||
inputTokens: modelStat.input_tokens,
|
||||
outputTokens: modelStat.output_tokens,
|
||||
reasoningTokens: modelStat.reasoning_tokens,
|
||||
@ -101,6 +103,7 @@ export class ModelStatRepo extends Context.Service<ModelStatRepo, ModelStatRepo.
|
||||
provider_model: inserted("provider_model"),
|
||||
sessions: inserted("sessions"),
|
||||
requests: inserted("requests"),
|
||||
unique_users: inserted("unique_users"),
|
||||
input_tokens: inserted("input_tokens"),
|
||||
output_tokens: inserted("output_tokens"),
|
||||
reasoning_tokens: inserted("reasoning_tokens"),
|
||||
|
||||
@ -115,6 +115,7 @@ export class ProviderStatRepo extends Context.Service<ProviderStatRepo, Provider
|
||||
set: {
|
||||
sessions: inserted("sessions"),
|
||||
requests: inserted("requests"),
|
||||
unique_users: inserted("unique_users"),
|
||||
input_tokens: inserted("input_tokens"),
|
||||
output_tokens: inserted("output_tokens"),
|
||||
reasoning_tokens: inserted("reasoning_tokens"),
|
||||
|
||||
@ -12,6 +12,7 @@ export type StatBaseAggregate = {
|
||||
tier: string
|
||||
sessions: number
|
||||
requests: number
|
||||
unique_users: number
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
reasoning_tokens: number
|
||||
@ -41,6 +42,7 @@ export type StatBaseRow = {
|
||||
source?: string
|
||||
sessions?: number
|
||||
requests?: number
|
||||
unique_users?: number
|
||||
input_tokens?: number
|
||||
output_tokens?: number
|
||||
reasoning_tokens?: number
|
||||
@ -71,6 +73,7 @@ export function toStatBaseRow(data: StatBaseAggregate) {
|
||||
source: "all",
|
||||
sessions: data.sessions,
|
||||
requests: data.requests,
|
||||
unique_users: data.unique_users,
|
||||
input_tokens: data.input_tokens,
|
||||
output_tokens: data.output_tokens,
|
||||
reasoning_tokens: data.reasoning_tokens,
|
||||
@ -122,6 +125,7 @@ export function combineRows<T extends StatBaseRow>(left: T, right: T): T {
|
||||
...left,
|
||||
sessions: (left.sessions ?? 0) + (right.sessions ?? 0),
|
||||
requests: (left.requests ?? 0) + (right.requests ?? 0),
|
||||
unique_users: (left.unique_users ?? 0) + (right.unique_users ?? 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),
|
||||
|
||||
@ -242,6 +242,7 @@ function metricQuery(breakdowns: string[], limit: number, filters: ReturnType<ty
|
||||
calculations: [
|
||||
{ op: "COUNT_DISTINCT", column: "session" },
|
||||
{ op: "COUNT" },
|
||||
{ op: "COUNT_DISTINCT", column: "workspace" },
|
||||
{ op: "SUM", column: "tokens.input" },
|
||||
{ op: "SUM", column: "tokens.output" },
|
||||
{ op: "SUM", column: "tokens.reasoning" },
|
||||
@ -374,9 +375,15 @@ function classifyRows(file: string, rows: RawRow[]): ImportKey {
|
||||
}
|
||||
|
||||
function hasMetricHeaders(headers: Set<string>) {
|
||||
return ["sumtokens", "sumtokensinput", "inputtokens", "totaltokens", "avgduration", "countdistinctsession"].some(
|
||||
(header) => headers.has(header),
|
||||
)
|
||||
return [
|
||||
"sumtokens",
|
||||
"sumtokensinput",
|
||||
"inputtokens",
|
||||
"totaltokens",
|
||||
"avgduration",
|
||||
"countdistinctsession",
|
||||
"countdistinctworkspace",
|
||||
].some((header) => headers.has(header))
|
||||
}
|
||||
|
||||
function hasHeader(headers: Set<string>, names: string[]) {
|
||||
@ -447,6 +454,7 @@ function baseAggregate(row: RawRow, grain: Grain, opts: ImportOptions): StatBase
|
||||
tier: tier(row),
|
||||
sessions: integer(row, "sessions", ["COUNT_DISTINCT(session)"]),
|
||||
requests: integer(row, "requests", ["COUNT", "COUNT()"]),
|
||||
unique_users: integer(row, "unique_users", ["COUNT_DISTINCT(workspace)", "COUNT_DISTINCT(api_key)"]),
|
||||
input_tokens: integer(row, "input_tokens", ["SUM(tokens.input)", "SUM(tokens_input)"]),
|
||||
output_tokens: integer(row, "output_tokens", ["SUM(tokens.output)", "SUM(tokens_output)"]),
|
||||
reasoning_tokens: integer(row, "reasoning_tokens", ["SUM(tokens.reasoning)", "SUM(tokens_reasoning)"]),
|
||||
@ -808,6 +816,7 @@ async function upsertModelRows(db: ReturnType<typeof drizzle>, rows: ModelStatRo
|
||||
provider_model: inserted("provider_model"),
|
||||
sessions: inserted("sessions"),
|
||||
requests: inserted("requests"),
|
||||
unique_users: inserted("unique_users"),
|
||||
input_tokens: inserted("input_tokens"),
|
||||
output_tokens: inserted("output_tokens"),
|
||||
reasoning_tokens: inserted("reasoning_tokens"),
|
||||
@ -845,6 +854,7 @@ async function upsertProviderRows(db: ReturnType<typeof drizzle>, rows: Provider
|
||||
set: {
|
||||
sessions: inserted("sessions"),
|
||||
requests: inserted("requests"),
|
||||
unique_users: inserted("unique_users"),
|
||||
input_tokens: inserted("input_tokens"),
|
||||
output_tokens: inserted("output_tokens"),
|
||||
reasoning_tokens: inserted("reasoning_tokens"),
|
||||
@ -887,6 +897,7 @@ async function upsertGeoRows(db: ReturnType<typeof drizzle>, rows: GeoStatRow[],
|
||||
continent: inserted("continent"),
|
||||
sessions: inserted("sessions"),
|
||||
requests: inserted("requests"),
|
||||
unique_users: inserted("unique_users"),
|
||||
input_tokens: inserted("input_tokens"),
|
||||
output_tokens: inserted("output_tokens"),
|
||||
reasoning_tokens: inserted("reasoning_tokens"),
|
||||
|
||||
@ -11,6 +11,7 @@ import { startOfIsoWeek } from "./domain/stat"
|
||||
const DATALAKE_INGESTION_LAG_MS = 5 * 60_000
|
||||
const STATS_DATA_START_MS = new Date("2026-05-28T00:00:00.000Z").getTime()
|
||||
const WEEK_MS = 7 * 86_400_000
|
||||
const DISPLAY_WINDOW_MS = 56 * 86_400_000
|
||||
|
||||
export type SyncStatsResult = { ok: true; rows: number; startedAt: string; periodStart: string; periodEnd: string }
|
||||
export type SyncStatsError = AthenaQueryError | AthenaQueryTimeoutError | DatabaseError
|
||||
@ -23,7 +24,12 @@ export const syncStats: () => Effect.Effect<
|
||||
const startedAt = yield* DateTime.nowAsDate
|
||||
const periodEnd = new Date(Math.floor((startedAt.getTime() - DATALAKE_INGESTION_LAG_MS) / 60_000) * 60_000)
|
||||
// May 27 was partial, so keep Athena stats anchored at the first complete day.
|
||||
const periodStart = new Date(Math.max(startOfIsoWeek(periodEnd).getTime() - WEEK_MS, STATS_DATA_START_MS))
|
||||
const periodStart = new Date(
|
||||
Math.max(
|
||||
Math.min(startOfIsoWeek(periodEnd).getTime() - WEEK_MS, periodEnd.getTime() - DISPLAY_WINDOW_MS),
|
||||
STATS_DATA_START_MS,
|
||||
),
|
||||
)
|
||||
const athena = yield* Athena
|
||||
const modelStats = yield* ModelStatRepo
|
||||
const providerStats = yield* ProviderStatRepo
|
||||
|
||||
Loading…
Reference in New Issue
Block a user