From 24c70ec9740da2a509d6291943afca1dc3cfdb2f Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sat, 20 Jun 2026 14:54:15 -0500 Subject: [PATCH] feat(stats): add unique user charts --- .../stats/app/src/routes/[lab]/[model].tsx | 230 +++++++++++------- packages/stats/app/src/routes/index.css | 78 ++++-- packages/stats/app/src/routes/index.tsx | 58 ++++- .../20260620000000_unique_users/migration.sql | 3 + packages/stats/core/src/database/schema.ts | 1 + packages/stats/core/src/domain/geo.ts | 1 + packages/stats/core/src/domain/home.ts | 45 +++- packages/stats/core/src/domain/inference.ts | 5 + packages/stats/core/src/domain/model.ts | 3 + packages/stats/core/src/domain/provider.ts | 1 + packages/stats/core/src/domain/stat.ts | 4 + packages/stats/core/src/honeycomb-backfill.ts | 17 +- packages/stats/core/src/stat-sync.ts | 8 +- 13 files changed, 327 insertions(+), 127 deletions(-) create mode 100644 packages/stats/core/migrations/20260620000000_unique_users/migration.sql diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx index 92f99fe7d..a298a7cbb 100644 --- a/packages/stats/app/src/routes/[lab]/[model].tsx +++ b/packages/stats/app/src/routes/[lab]/[model].tsx @@ -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() { + @@ -358,14 +360,6 @@ function ModelOverview(props: { data: StatsModelData | null }) { } function ModelUsageSection(props: { data: ModelUsagePoint[] }) { - const [activeIndex, setActiveIndex] = createSignal() - 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 (
@@ -373,92 +367,144 @@ function ModelUsageSection(props: { data: ModelUsagePoint[] }) { when={props.data.some((item) => item.tokens > 0)} fallback={} > -
{ - if (event.pointerType === "touch") return - setActiveIndex(undefined) - }} - > - -
- - {(point, index) => ( -
{ - 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()) - }} - > -
- - {(active) => ( -
props.data.length * 0.62 ? "left" : "right"} - > - {active().date} - {formatTokens(active().tokens)} tokens -
-

- - Daily tokens - - {formatTokens(active().tokens)} -

-
- )} - -
- )} - -
-
+
) } +function ModelUsersSection(props: { data: ModelUsagePoint[] }) { + return ( +
+ + item.users > 0)} + fallback={} + > + + +
+ ) +} + +function ModelColumnChart(props: { + data: ModelUsagePoint[] + metric: "tokens" | "users" + ariaLabel: string +}) { + const [activeIndex, setActiveIndex] = createSignal() + 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 ( +
{ + if (event.pointerType === "touch") return + setActiveIndex(undefined) + }} + > + +
+ + {(point, index) => ( +
{ + 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()) + }} + > +
+ + {(active) => ( +
props.data.length * 0.62 ? "left" : "right"} + > + {active().date} + + {formatModelUsageValue(active(), props.metric)} {modelUsageLabel(props.metric)} + +
+

+ + Daily {modelUsageLabel(props.metric)} + + {formatModelUsageValue(active(), props.metric)} +

+
+ )} + +
+ )} + +
+
+ ) +} + +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 (
@@ -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)}%` } diff --git a/packages/stats/app/src/routes/index.css b/packages/stats/app/src/routes/index.css index f6c7f7bef..05569c1b8 100644 --- a/packages/stats/app/src/routes/index.css +++ b/packages/stats/app/src/routes/index.css @@ -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; } diff --git a/packages/stats/app/src/routes/index.tsx b/packages/stats/app/src/routes/index.tsx index 7f528015a..cdef00b94 100644 --- a/packages/stats/app/src/routes/index.tsx +++ b/packages/stats/app/src/routes/index.tsx @@ -171,6 +171,7 @@ export default function StatsHome() { <> + @@ -598,6 +599,8 @@ function FilterPills(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} > - {formatTokens(usageTotal(day))} + {formatUsageChartValue(usageTotal(day), metric())} {day.date} {formatTopModelsMobileDate(day.date, props.range)} @@ -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"} > {point().date} - {formatTokens(usageTotal(point()))} total + + {formatUsageChartValue(usageTotal(point()), metric())} {usageChartTotalLabel(metric())} +
{(item) => ( @@ -759,7 +766,7 @@ function TopModelsChart(props: { />{" "} {item.segment.model} - {formatTokens(item.segment.value)} + {formatUsageChartValue(item.segment.value, metric())}

)}
@@ -774,6 +781,31 @@ function TopModelsChart(props: { ) } +function UniqueUsersSection(props: { data: StatsHomeData["users"] }) { + const [activeModel, setActiveModel] = createSignal() + const data = createMemo(() => props.data.Go["2M"]) + + return ( +
+ + + usageTotal(item) > 0)} + fallback={} + > + + +
+ ) +} + function isTopModelsBlankHover(bar: HTMLElement, clientY: number) { const stack = bar.querySelector('[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 diff --git a/packages/stats/core/migrations/20260620000000_unique_users/migration.sql b/packages/stats/core/migrations/20260620000000_unique_users/migration.sql new file mode 100644 index 000000000..3b9784b0d --- /dev/null +++ b/packages/stats/core/migrations/20260620000000_unique_users/migration.sql @@ -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; diff --git a/packages/stats/core/src/database/schema.ts b/packages/stats/core/src/database/schema.ts index a658a4d51..d5bfa314b 100644 --- a/packages/stats/core/src/database/schema.ts +++ b/packages/stats/core/src/database/schema.ts @@ -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), diff --git a/packages/stats/core/src/domain/geo.ts b/packages/stats/core/src/domain/geo.ts index c58195c77..99c7bcc4a 100644 --- a/packages/stats/core/src/domain/geo.ts +++ b/packages/stats/core/src/domain/geo.ts @@ -145,6 +145,7 @@ export class GeoStatRepo extends Context.Service> + users: Record> leaderboard: Record> market: Record tokenCost: Record @@ -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, diff --git a/packages/stats/core/src/domain/inference.ts b/packages/stats/core/src/domain/inference.ts index 9a74daf95..a7e4c0037 100644 --- a/packages/stats/core/src/domain/inference.ts +++ b/packages/stats/core/src/domain/inference.ts @@ -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"), diff --git a/packages/stats/core/src/domain/model.ts b/packages/stats/core/src/domain/model.ts index 0d40a51c5..912ee6290 100644 --- a/packages/stats/core/src/domain/model.ts +++ b/packages/stats/core/src/domain/model.ts @@ -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(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), diff --git a/packages/stats/core/src/honeycomb-backfill.ts b/packages/stats/core/src/honeycomb-backfill.ts index 72f9c874e..5a2b25fa4 100644 --- a/packages/stats/core/src/honeycomb-backfill.ts +++ b/packages/stats/core/src/honeycomb-backfill.ts @@ -242,6 +242,7 @@ function metricQuery(breakdowns: string[], limit: number, filters: ReturnType) { - 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, 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, 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, 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, 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"), diff --git a/packages/stats/core/src/stat-sync.ts b/packages/stats/core/src/stat-sync.ts index df0a317d6..86e5ded7d 100644 --- a/packages/stats/core/src/stat-sync.ts +++ b/packages/stats/core/src/stat-sync.ts @@ -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