Parcourir la source

feat(stats): add unique user charts

Adam il y a 1 mois
Parent
commit
24c70ec974

+ 141 - 89
packages/stats/app/src/routes/[lab]/[model].tsx

@@ -45,6 +45,7 @@ const statsUnfurlUrl = new URL(statsUnfurlPath, statsCanonicalBaseUrl).toString(
 const modelHeaderLinks: readonly HeaderLink[] = [
 const modelHeaderLinks: readonly HeaderLink[] = [
   { href: "#overview", label: "Overview" },
   { href: "#overview", label: "Overview" },
   { href: "#usage", label: "Usage" },
   { href: "#usage", label: "Usage" },
+  { href: "#users", label: "Users" },
   { href: "#efficiency", label: "Efficiency" },
   { href: "#efficiency", label: "Efficiency" },
   { href: "#geo-breakdown", label: "Geo Breakdown" },
   { href: "#geo-breakdown", label: "Geo Breakdown" },
   { href: "#peers", label: "Peers" },
   { href: "#peers", label: "Peers" },
@@ -175,6 +176,7 @@ export default function StatsModel() {
                 <ModelHero data={stats() ?? null} catalog={catalogEntry() ?? null} labName={labName()} />
                 <ModelHero data={stats() ?? null} catalog={catalogEntry() ?? null} labName={labName()} />
                 <ModelOverview data={stats() ?? null} />
                 <ModelOverview data={stats() ?? null} />
                 <ModelUsageSection data={stats()?.usage ?? []} />
                 <ModelUsageSection data={stats()?.usage ?? []} />
+                <ModelUsersSection data={stats()?.usage ?? []} />
                 <ModelEfficiencySection data={stats() ?? null} catalog={catalogEntry() ?? null} />
                 <ModelEfficiencySection data={stats() ?? null} catalog={catalogEntry() ?? null} />
                 <ModelGeoBreakdownSection data={stats()?.country ?? emptyCountryRecord()} />
                 <ModelGeoBreakdownSection data={stats()?.country ?? emptyCountryRecord()} />
                 <ModelPeersSection data={stats() ?? null} />
                 <ModelPeersSection data={stats() ?? null} />
@@ -358,14 +360,6 @@ function ModelOverview(props: { data: StatsModelData | null }) {
 }
 }
 
 
 function ModelUsageSection(props: { data: ModelUsagePoint[] }) {
 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 (
   return (
     <section id="usage" data-section="model-panel">
     <section id="usage" data-section="model-panel">
       <SectionTitle title="Usage" description="Daily OpenCode Go token volume over the recent two-month window." />
       <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)}
         when={props.data.some((item) => item.tokens > 0)}
         fallback={<ModelEmptyState title="No usage" description="No usage landed in the current window." />}
         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>
       </Show>
     </section>
     </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 }) {
 function ModelEfficiencySection(props: { data: StatsModelData | null; catalog: ModelCatalogEntry | null }) {
   return (
   return (
     <section id="efficiency" data-section="model-panel">
     <section id="efficiency" data-section="model-panel">
@@ -834,6 +880,12 @@ function formatInteger(value: number) {
   return new Intl.NumberFormat("en").format(value)
   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) {
 function formatPercent(value: number) {
   return `${value.toFixed(value > 0 && value < 10 ? 1 : 0)}%`
   return `${value.toFixed(value > 0 && value < 10 ? 1 : 0)}%`
 }
 }

+ 57 - 21
packages/stats/app/src/routes/index.css

@@ -1546,7 +1546,7 @@
   color: var(--stats-text);
   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;
   top: 110px;
   box-sizing: border-box;
   box-sizing: border-box;
   display: flex;
   display: flex;
@@ -1564,40 +1564,53 @@
   color: var(--stats-text);
   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;
   right: auto;
   left: calc(100% + 8px);
   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);
   right: calc(100% + 8px);
   left: auto;
   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;
   display: block;
   font-size: 11px;
   font-size: 11px;
   line-height: 12px;
   line-height: 12px;
   white-space: nowrap;
   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;
   padding: 8px 8px 0;
   font-weight: 500;
   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;
   padding: 4px 8px 8px;
   color: var(--stats-muted);
   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;
   height: 0.5px;
   margin: 0;
   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;
   grid-template-columns: minmax(0, 1fr) auto;
   gap: 4px;
   gap: 4px;
   height: 16px;
   height: 16px;
@@ -1608,36 +1621,50 @@
   line-height: 12px;
   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;
   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;
   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;
   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);
   grid-template-columns: 16px minmax(0, 1fr);
   gap: 4px;
   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;
   width: 6px;
   height: 6px;
   height: 6px;
   justify-self: center;
   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;
   font-weight: 500;
 }
 }
 
 
 [data-page="stats"]
 [data-page="stats"]
   :is(
   :is(
     [data-section="leaderboard"],
     [data-section="leaderboard"],
+    [data-section="unique-users"],
     [data-section="market-share"],
     [data-section="market-share"],
     [data-section="geo-breakdown"],
     [data-section="geo-breakdown"],
     [data-section="token-cost"],
     [data-section="token-cost"],
@@ -3264,10 +3291,12 @@
   background: #242424f2;
   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"]
 :root[data-stats-theme="dark"]
   [data-page="stats"]:not([data-theme="light"])
   [data-page="stats"]:not([data-theme="light"])
-  [data-section="top-models"]
+  :is([data-section="top-models"], [data-section="unique-users"])
   [data-component="chart-tooltip"] {
   [data-component="chart-tooltip"] {
   background: #242424f2;
   background: #242424f2;
   box-shadow:
   box-shadow:
@@ -3276,10 +3305,13 @@
     0 4px 8px #00000052;
     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"]
 :root[data-stats-theme="dark"]
   [data-page="stats"]:not([data-theme="light"])
   [data-page="stats"]:not([data-theme="light"])
-  [data-section="top-models"]
+  :is([data-section="top-models"], [data-section="unique-users"])
   [data-component="chart-tooltip"]
   [data-component="chart-tooltip"]
   > span {
   > span {
   color: var(--stats-faint);
   color: var(--stats-faint);
@@ -3544,6 +3576,7 @@
 @media (max-width: 74rem) {
 @media (max-width: 74rem) {
   [data-page="stats"] [data-section="top-models"],
   [data-page="stats"] [data-section="top-models"],
   [data-page="stats"] [data-section="leaderboard"],
   [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="market-share"],
   [data-page="stats"] [data-section="geo-breakdown"],
   [data-page="stats"] [data-section="geo-breakdown"],
   [data-page="stats"] [data-section="token-cost"],
   [data-page="stats"] [data-section="token-cost"],
@@ -3703,6 +3736,7 @@
 @media (max-width: 47.999rem) {
 @media (max-width: 47.999rem) {
   [data-page="stats"] [data-section="top-models"],
   [data-page="stats"] [data-section="top-models"],
   [data-page="stats"] [data-section="leaderboard"],
   [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="market-share"],
   [data-page="stats"] [data-section="geo-breakdown"],
   [data-page="stats"] [data-section="geo-breakdown"],
   [data-page="stats"] [data-section="token-cost"],
   [data-page="stats"] [data-section="token-cost"],
@@ -4011,7 +4045,7 @@
     display: block;
     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;
     position: fixed;
     top: auto;
     top: auto;
     right: 12px;
     right: 12px;
@@ -4025,7 +4059,9 @@
     transform: none;
     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;
     right: 12px;
     left: 12px;
     left: 12px;
   }
   }

+ 53 - 5
packages/stats/app/src/routes/index.tsx

@@ -171,6 +171,7 @@ export default function StatsHome() {
               <>
               <>
                 <Hero updatedAt={stats().updatedAt} />
                 <Hero updatedAt={stats().updatedAt} />
                 <TopModelsSection data={stats().usage} leaderboard={stats().leaderboard} />
                 <TopModelsSection data={stats().usage} leaderboard={stats().leaderboard} />
+                <UniqueUsersSection data={stats().users} />
                 <SessionCostSection data={stats().sessionCost} />
                 <SessionCostSection data={stats().sessionCost} />
                 <TokenCostSection data={stats().tokenCost} catalog={catalog() ?? null} />
                 <TokenCostSection data={stats().tokenCost} catalog={catalog() ?? null} />
                 <CacheRatioSection data={stats().cacheRatio} />
                 <CacheRatioSection data={stats().cacheRatio} />
@@ -598,6 +599,8 @@ function FilterPills<T extends string>(props: {
 function TopModelsChart(props: {
 function TopModelsChart(props: {
   data: UsagePoint[]
   data: UsagePoint[]
   range: UsageRange
   range: UsageRange
+  metric?: "tokens" | "users"
+  ariaLabel?: string
   activeModel: string | undefined
   activeModel: string | undefined
   onActiveModelChange: (model: string | undefined) => void
   onActiveModelChange: (model: string | undefined) => void
 }) {
 }) {
@@ -606,6 +609,7 @@ function TopModelsChart(props: {
   const maxTotal = createMemo(() => getTopModelsMaxTotal(props.data))
   const maxTotal = createMemo(() => getTopModelsMaxTotal(props.data))
   const segmentOrder = createMemo(() => getTopModelsSegmentOrder(props.data))
   const segmentOrder = createMemo(() => getTopModelsSegmentOrder(props.data))
   const activePoint = createMemo(() => props.data[activeIndex() ?? -1])
   const activePoint = createMemo(() => props.data[activeIndex() ?? -1])
+  const metric = createMemo(() => props.metric ?? "tokens")
 
 
   createEffect(() => scrollDenseChartToEnd(chartRef, props.range, props.data.length))
   createEffect(() => scrollDenseChartToEnd(chartRef, props.range, props.data.length))
 
 
@@ -614,9 +618,10 @@ function TopModelsChart(props: {
       ref={chartRef}
       ref={chartRef}
       data-component="top-models-chart"
       data-component="top-models-chart"
       data-range={props.range}
       data-range={props.range}
+      data-metric={metric()}
       data-dense-labels={isDenseColumnRange(props.range) ? "true" : undefined}
       data-dense-labels={isDenseColumnRange(props.range) ? "true" : undefined}
       role="img"
       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}
       style={{ "--top-models-count": props.data.length } as JSX.CSSProperties}
       onPointerLeave={(event) => {
       onPointerLeave={(event) => {
         if (event.pointerType === "touch") return
         if (event.pointerType === "touch") return
@@ -633,7 +638,7 @@ function TopModelsChart(props: {
               data-mobile-hidden={isTopModelsMobileAxisHidden(index(), props.data.length) ? "true" : undefined}
               data-mobile-hidden={isTopModelsMobileAxisHidden(index(), props.data.length) ? "true" : undefined}
             >
             >
               <span data-slot="axis-label">
               <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">
                   <span data-slot="axis-date-full">{day.date}</span>
                   <span data-slot="axis-date-full">{day.date}</span>
                   <span data-slot="axis-date-mobile">{formatTopModelsMobileDate(day.date, props.range)}</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"
               data-slot="top-models-bar"
               role="button"
               role="button"
               tabIndex={0}
               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-active={activeIndex() === dayIndex() ? "true" : undefined}
               data-muted={activeIndex() !== undefined && activeIndex() !== dayIndex() ? "true" : undefined}
               data-muted={activeIndex() !== undefined && activeIndex() !== dayIndex() ? "true" : undefined}
               style={{ "--top-models-bar-height": `${getTopModelsBarHeight(usageTotal(day), maxTotal())}%` }}
               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"}
                     data-placement={dayIndex() > props.data.length * 0.62 ? "left" : "right"}
                   >
                   >
                     <strong>{point().date}</strong>
                     <strong>{point().date}</strong>
-                    <span>{formatTokens(usageTotal(point()))} total</span>
+                    <span>
+                      {formatUsageChartValue(usageTotal(point()), metric())} {usageChartTotalLabel(metric())}
+                    </span>
                     <div data-slot="tooltip-divider" />
                     <div data-slot="tooltip-divider" />
                     <For each={visibleTopModelsSegments(point())}>
                     <For each={visibleTopModelsSegments(point())}>
                       {(item) => (
                       {(item) => (
@@ -759,7 +766,7 @@ function TopModelsChart(props: {
                             />{" "}
                             />{" "}
                             {item.segment.model}
                             {item.segment.model}
                           </span>
                           </span>
-                          <b>{formatTokens(item.segment.value)}</b>
+                          <b>{formatUsageChartValue(item.segment.value, metric())}</b>
                         </p>
                         </p>
                       )}
                       )}
                     </For>
                     </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) {
 function isTopModelsBlankHover(bar: HTMLElement, clientY: number) {
   const stack = bar.querySelector<HTMLElement>('[data-slot="top-models-stack"]')
   const stack = bar.querySelector<HTMLElement>('[data-slot="top-models-stack"]')
   if (!stack) return true
   if (!stack) return true
@@ -864,6 +896,22 @@ function formatTokens(value: number) {
   return `${Math.round(value * 1000)}B`
   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: {
 function Leaderboard(props: {
   data: LeaderboardEntry[]
   data: LeaderboardEntry[]
   activeModel: string | undefined
   activeModel: string | undefined

+ 3 - 0
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;

+ 1 - 0
packages/stats/core/src/database/schema.ts

@@ -123,6 +123,7 @@ function metricColumns() {
   return {
   return {
     sessions: bigint({ mode: "number" }).notNull().default(0),
     sessions: bigint({ mode: "number" }).notNull().default(0),
     requests: 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),
     input_tokens: bigint({ mode: "number" }).notNull().default(0),
     output_tokens: bigint({ mode: "number" }).notNull().default(0),
     output_tokens: bigint({ mode: "number" }).notNull().default(0),
     reasoning_tokens: bigint({ mode: "number" }).notNull().default(0),
     reasoning_tokens: bigint({ mode: "number" }).notNull().default(0),

+ 1 - 0
packages/stats/core/src/domain/geo.ts

@@ -145,6 +145,7 @@ export class GeoStatRepo extends Context.Service<GeoStatRepo, GeoStatRepo.Servic
                       continent: inserted("continent"),
                       continent: inserted("continent"),
                       sessions: inserted("sessions"),
                       sessions: inserted("sessions"),
                       requests: inserted("requests"),
                       requests: inserted("requests"),
+                      unique_users: inserted("unique_users"),
                       input_tokens: inserted("input_tokens"),
                       input_tokens: inserted("input_tokens"),
                       output_tokens: inserted("output_tokens"),
                       output_tokens: inserted("output_tokens"),
                       reasoning_tokens: inserted("reasoning_tokens"),
                       reasoning_tokens: inserted("reasoning_tokens"),

+ 37 - 8
packages/stats/core/src/domain/home.ts

@@ -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 CacheRatioEntry = { model: string; ratio: number; cached: number; uncached: number; total: number }
 export type SessionCostEntry = { model: string; cost: number; tokens: 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 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 ModelMixEntry = { label: string; tokens: number; share: number }
 export type ModelPeerEntry = {
 export type ModelPeerEntry = {
   model: string
   model: string
@@ -82,6 +82,7 @@ export type StatsLabData = {
 export type StatsHomeData = {
 export type StatsHomeData = {
   updatedAt: string | null
   updatedAt: string | null
   usage: Record<UsageProduct, Record<UsageRange, UsagePoint[]>>
   usage: Record<UsageProduct, Record<UsageRange, UsagePoint[]>>
+  users: Record<UsageProduct, Record<UsageRange, UsagePoint[]>>
   leaderboard: Record<UsageProduct, Record<UsageRange, LeaderboardEntry[]>>
   leaderboard: Record<UsageProduct, Record<UsageRange, LeaderboardEntry[]>>
   market: Record<UsageRange, MarketDay[]>
   market: Record<UsageRange, MarketDay[]>
   tokenCost: Record<TokenProduct, TokenCostEntry[]>
   tokenCost: Record<TokenProduct, TokenCostEntry[]>
@@ -118,6 +119,7 @@ type ModelAggregate = {
   model: string
   model: string
   provider: string
   provider: string
   sessions: number
   sessions: number
+  uniqueUsers: number
   inputTokens: number
   inputTokens: number
   outputTokens: number
   outputTokens: number
   reasoningTokens: 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) =>
     leaderboard: createUsageProductRecord((product) =>
       createRangeRecord((range) => buildLeaderboard(normalized, product, getWindow("1W", earliest, latest))),
       createRangeRecord((range) => buildLeaderboard(normalized, product, getWindow("1W", earliest, latest))),
     ),
     ),
@@ -340,6 +354,7 @@ function emptyStatsHomeData(): StatsHomeData {
   return {
   return {
     updatedAt: null,
     updatedAt: null,
     usage: createUsageProductRecord(() => createRangeRecord(() => [])),
     usage: createUsageProductRecord(() => createRangeRecord(() => [])),
+    users: createUsageProductRecord(() => createRangeRecord(() => [])),
     leaderboard: createUsageProductRecord(() => createRangeRecord(() => [])),
     leaderboard: createUsageProductRecord(() => createRangeRecord(() => [])),
     market: createRangeRecord(() => []),
     market: createRangeRecord(() => []),
     tokenCost: createTokenProductRecord(() => []),
     tokenCost: createTokenProductRecord(() => []),
@@ -355,28 +370,39 @@ function buildUsagePoints(
   range: UsageRange,
   range: UsageRange,
   window: DateWindow,
   window: DateWindow,
   rankWindow: DateWindow,
   rankWindow: DateWindow,
+  metric: "tokens" | "users" = "tokens",
 ) {
 ) {
   const modelOrder = aggregateByModelName(rowsForProduct(rows, product, rankWindow.start, rankWindow.end))
   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)
     .slice(0, TOP_MODEL_SEGMENT_LIMIT)
     .map((item) => item.model)
     .map((item) => item.model)
 
 
   return createBuckets(window, range).map((bucket) => {
   return createBuckets(window, range).map((bucket) => {
     const bucketRows = aggregateByModelName(rowsForProduct(rows, product, bucket.start, bucket.end))
     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 {
     return {
       date: bucket.label,
       date: bucket.label,
       segments: [
       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) {
 function buildLeaderboard(rows: StatMetricRow[], product: UsageProduct, rankWindow: DateWindow) {
   const previous = new Map(
   const previous = new Map(
     aggregateByModelName(rowsForProduct(rows, product, rankWindow.previousStart, rankWindow.previousEnd)).map(
     aggregateByModelName(rowsForProduct(rows, product, rankWindow.previousStart, rankWindow.previousEnd)).map(
@@ -502,6 +528,7 @@ function buildModelUsage(rows: StatMetricRow[], window: DateWindow, range: Usage
     return {
     return {
       date: bucket.label,
       date: bucket.label,
       tokens: aggregate.totalTokens,
       tokens: aggregate.totalTokens,
+      users: aggregate.uniqueUsers,
       sessions: aggregate.sessions,
       sessions: aggregate.sessions,
       cost: round(microcentsToDollars(aggregate.totalCostMicrocents), 2),
       cost: round(microcentsToDollars(aggregate.totalCostMicrocents), 2),
     }
     }
@@ -601,6 +628,7 @@ function combineRowsForModel(model: string, rows: StatMetricRow[]): ModelAggrega
     model,
     model,
     provider: "unknown",
     provider: "unknown",
     sessions: 0,
     sessions: 0,
+    uniqueUsers: 0,
     inputTokens: 0,
     inputTokens: 0,
     outputTokens: 0,
     outputTokens: 0,
     reasoningTokens: 0,
     reasoningTokens: 0,
@@ -617,6 +645,7 @@ function combineModelAggregate(current: ModelAggregate | undefined, row: StatMet
     model: row.model,
     model: row.model,
     provider: row.provider,
     provider: row.provider,
     sessions: (current?.sessions ?? 0) + row.sessions,
     sessions: (current?.sessions ?? 0) + row.sessions,
+    uniqueUsers: (current?.uniqueUsers ?? 0) + row.uniqueUsers,
     inputTokens: (current?.inputTokens ?? 0) + row.inputTokens,
     inputTokens: (current?.inputTokens ?? 0) + row.inputTokens,
     outputTokens: (current?.outputTokens ?? 0) + row.outputTokens,
     outputTokens: (current?.outputTokens ?? 0) + row.outputTokens,
     reasoningTokens: (current?.reasoningTokens ?? 0) + row.reasoningTokens,
     reasoningTokens: (current?.reasoningTokens ?? 0) + row.reasoningTokens,

+ 5 - 0
packages/stats/core/src/domain/inference.ts

@@ -40,6 +40,7 @@ export function buildStatsQuery(periodStart: Date, periodEnd: Date, dimension: S
   const aggregateColumns = `
   const aggregateColumns = `
     COUNT(DISTINCT session) AS sessions,
     COUNT(DISTINCT session) AS sessions,
     COUNT(*) AS requests,
     COUNT(*) AS requests,
+    COUNT(DISTINCT user_key) AS unique_users,
     COALESCE(SUM(tokens_input), 0) AS input_tokens,
     COALESCE(SUM(tokens_input), 0) AS input_tokens,
     COALESCE(SUM(tokens_output), 0) AS output_tokens,
     COALESCE(SUM(tokens_output), 0) AS output_tokens,
     COALESCE(SUM(tokens_reasoning), 0) AS reasoning_tokens,
     COALESCE(SUM(tokens_reasoning), 0) AS reasoning_tokens,
@@ -70,6 +71,8 @@ WITH normalized AS (
     UPPER(COALESCE(NULLIF(cf_country, ''), 'ZZ')) AS country,
     UPPER(COALESCE(NULLIF(cf_country, ''), 'ZZ')) AS country,
     COALESCE(NULLIF(cf_continent, ''), '') AS continent,
     COALESCE(NULLIF(cf_continent, ''), '') AS continent,
     session,
     session,
+    COALESCE(NULLIF(workspace, ''), '') AS workspace,
+    COALESCE(NULLIF(api_key, ''), '') AS api_key,
     status,
     status,
     duration AS duration_ms,
     duration AS duration_ms,
     time_to_first_byte AS ttfb_ms,
     time_to_first_byte AS ttfb_ms,
@@ -108,6 +111,7 @@ WITH normalized AS (
     country,
     country,
     continent,
     continent,
     session,
     session,
+    COALESCE(NULLIF(workspace, ''), NULLIF(api_key, '')) AS user_key,
     status,
     status,
     duration_ms,
     duration_ms,
     ttfb_ms,
     ttfb_ms,
@@ -197,6 +201,7 @@ function toStatBaseAggregate(data: AthenaData): StatBaseAggregate[] {
       tier: normalizeTier(data.tier || "unknown"),
       tier: normalizeTier(data.tier || "unknown"),
       sessions: integer(data, "sessions"),
       sessions: integer(data, "sessions"),
       requests: integer(data, "requests"),
       requests: integer(data, "requests"),
+      unique_users: integer(data, "unique_users"),
       input_tokens: integer(data, "input_tokens"),
       input_tokens: integer(data, "input_tokens"),
       output_tokens: integer(data, "output_tokens"),
       output_tokens: integer(data, "output_tokens"),
       reasoning_tokens: integer(data, "reasoning_tokens"),
       reasoning_tokens: integer(data, "reasoning_tokens"),

+ 3 - 0
packages/stats/core/src/domain/model.ts

@@ -27,6 +27,7 @@ export type ModelStatMetric = {
   provider: string
   provider: string
   model: string
   model: string
   sessions: number
   sessions: number
+  uniqueUsers: number
   inputTokens: number
   inputTokens: number
   outputTokens: number
   outputTokens: number
   reasoningTokens: number
   reasoningTokens: number
@@ -64,6 +65,7 @@ export class ModelStatRepo extends Context.Service<ModelStatRepo, ModelStatRepo.
                 provider: modelStat.provider,
                 provider: modelStat.provider,
                 model: modelStat.model,
                 model: modelStat.model,
                 sessions: modelStat.sessions,
                 sessions: modelStat.sessions,
+                uniqueUsers: modelStat.unique_users,
                 inputTokens: modelStat.input_tokens,
                 inputTokens: modelStat.input_tokens,
                 outputTokens: modelStat.output_tokens,
                 outputTokens: modelStat.output_tokens,
                 reasoningTokens: modelStat.reasoning_tokens,
                 reasoningTokens: modelStat.reasoning_tokens,
@@ -101,6 +103,7 @@ export class ModelStatRepo extends Context.Service<ModelStatRepo, ModelStatRepo.
                       provider_model: inserted("provider_model"),
                       provider_model: inserted("provider_model"),
                       sessions: inserted("sessions"),
                       sessions: inserted("sessions"),
                       requests: inserted("requests"),
                       requests: inserted("requests"),
+                      unique_users: inserted("unique_users"),
                       input_tokens: inserted("input_tokens"),
                       input_tokens: inserted("input_tokens"),
                       output_tokens: inserted("output_tokens"),
                       output_tokens: inserted("output_tokens"),
                       reasoning_tokens: inserted("reasoning_tokens"),
                       reasoning_tokens: inserted("reasoning_tokens"),

+ 1 - 0
packages/stats/core/src/domain/provider.ts

@@ -115,6 +115,7 @@ export class ProviderStatRepo extends Context.Service<ProviderStatRepo, Provider
                     set: {
                     set: {
                       sessions: inserted("sessions"),
                       sessions: inserted("sessions"),
                       requests: inserted("requests"),
                       requests: inserted("requests"),
+                      unique_users: inserted("unique_users"),
                       input_tokens: inserted("input_tokens"),
                       input_tokens: inserted("input_tokens"),
                       output_tokens: inserted("output_tokens"),
                       output_tokens: inserted("output_tokens"),
                       reasoning_tokens: inserted("reasoning_tokens"),
                       reasoning_tokens: inserted("reasoning_tokens"),

+ 4 - 0
packages/stats/core/src/domain/stat.ts

@@ -12,6 +12,7 @@ export type StatBaseAggregate = {
   tier: string
   tier: string
   sessions: number
   sessions: number
   requests: number
   requests: number
+  unique_users: number
   input_tokens: number
   input_tokens: number
   output_tokens: number
   output_tokens: number
   reasoning_tokens: number
   reasoning_tokens: number
@@ -41,6 +42,7 @@ export type StatBaseRow = {
   source?: string
   source?: string
   sessions?: number
   sessions?: number
   requests?: number
   requests?: number
+  unique_users?: number
   input_tokens?: number
   input_tokens?: number
   output_tokens?: number
   output_tokens?: number
   reasoning_tokens?: number
   reasoning_tokens?: number
@@ -71,6 +73,7 @@ export function toStatBaseRow(data: StatBaseAggregate) {
     source: "all",
     source: "all",
     sessions: data.sessions,
     sessions: data.sessions,
     requests: data.requests,
     requests: data.requests,
+    unique_users: data.unique_users,
     input_tokens: data.input_tokens,
     input_tokens: data.input_tokens,
     output_tokens: data.output_tokens,
     output_tokens: data.output_tokens,
     reasoning_tokens: data.reasoning_tokens,
     reasoning_tokens: data.reasoning_tokens,
@@ -122,6 +125,7 @@ export function combineRows<T extends StatBaseRow>(left: T, right: T): T {
     ...left,
     ...left,
     sessions: (left.sessions ?? 0) + (right.sessions ?? 0),
     sessions: (left.sessions ?? 0) + (right.sessions ?? 0),
     requests: (left.requests ?? 0) + (right.requests ?? 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),
     input_tokens: (left.input_tokens ?? 0) + (right.input_tokens ?? 0),
     output_tokens: (left.output_tokens ?? 0) + (right.output_tokens ?? 0),
     output_tokens: (left.output_tokens ?? 0) + (right.output_tokens ?? 0),
     reasoning_tokens: (left.reasoning_tokens ?? 0) + (right.reasoning_tokens ?? 0),
     reasoning_tokens: (left.reasoning_tokens ?? 0) + (right.reasoning_tokens ?? 0),

+ 14 - 3
packages/stats/core/src/honeycomb-backfill.ts

@@ -242,6 +242,7 @@ function metricQuery(breakdowns: string[], limit: number, filters: ReturnType<ty
     calculations: [
     calculations: [
       { op: "COUNT_DISTINCT", column: "session" },
       { op: "COUNT_DISTINCT", column: "session" },
       { op: "COUNT" },
       { op: "COUNT" },
+      { op: "COUNT_DISTINCT", column: "workspace" },
       { op: "SUM", column: "tokens.input" },
       { op: "SUM", column: "tokens.input" },
       { op: "SUM", column: "tokens.output" },
       { op: "SUM", column: "tokens.output" },
       { op: "SUM", column: "tokens.reasoning" },
       { op: "SUM", column: "tokens.reasoning" },
@@ -374,9 +375,15 @@ function classifyRows(file: string, rows: RawRow[]): ImportKey {
 }
 }
 
 
 function hasMetricHeaders(headers: Set<string>) {
 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[]) {
 function hasHeader(headers: Set<string>, names: string[]) {
@@ -447,6 +454,7 @@ function baseAggregate(row: RawRow, grain: Grain, opts: ImportOptions): StatBase
     tier: tier(row),
     tier: tier(row),
     sessions: integer(row, "sessions", ["COUNT_DISTINCT(session)"]),
     sessions: integer(row, "sessions", ["COUNT_DISTINCT(session)"]),
     requests: integer(row, "requests", ["COUNT", "COUNT()"]),
     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)"]),
     input_tokens: integer(row, "input_tokens", ["SUM(tokens.input)", "SUM(tokens_input)"]),
     output_tokens: integer(row, "output_tokens", ["SUM(tokens.output)", "SUM(tokens_output)"]),
     output_tokens: integer(row, "output_tokens", ["SUM(tokens.output)", "SUM(tokens_output)"]),
     reasoning_tokens: integer(row, "reasoning_tokens", ["SUM(tokens.reasoning)", "SUM(tokens_reasoning)"]),
     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"),
           provider_model: inserted("provider_model"),
           sessions: inserted("sessions"),
           sessions: inserted("sessions"),
           requests: inserted("requests"),
           requests: inserted("requests"),
+          unique_users: inserted("unique_users"),
           input_tokens: inserted("input_tokens"),
           input_tokens: inserted("input_tokens"),
           output_tokens: inserted("output_tokens"),
           output_tokens: inserted("output_tokens"),
           reasoning_tokens: inserted("reasoning_tokens"),
           reasoning_tokens: inserted("reasoning_tokens"),
@@ -845,6 +854,7 @@ async function upsertProviderRows(db: ReturnType<typeof drizzle>, rows: Provider
         set: {
         set: {
           sessions: inserted("sessions"),
           sessions: inserted("sessions"),
           requests: inserted("requests"),
           requests: inserted("requests"),
+          unique_users: inserted("unique_users"),
           input_tokens: inserted("input_tokens"),
           input_tokens: inserted("input_tokens"),
           output_tokens: inserted("output_tokens"),
           output_tokens: inserted("output_tokens"),
           reasoning_tokens: inserted("reasoning_tokens"),
           reasoning_tokens: inserted("reasoning_tokens"),
@@ -887,6 +897,7 @@ async function upsertGeoRows(db: ReturnType<typeof drizzle>, rows: GeoStatRow[],
           continent: inserted("continent"),
           continent: inserted("continent"),
           sessions: inserted("sessions"),
           sessions: inserted("sessions"),
           requests: inserted("requests"),
           requests: inserted("requests"),
+          unique_users: inserted("unique_users"),
           input_tokens: inserted("input_tokens"),
           input_tokens: inserted("input_tokens"),
           output_tokens: inserted("output_tokens"),
           output_tokens: inserted("output_tokens"),
           reasoning_tokens: inserted("reasoning_tokens"),
           reasoning_tokens: inserted("reasoning_tokens"),

+ 7 - 1
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 DATALAKE_INGESTION_LAG_MS = 5 * 60_000
 const STATS_DATA_START_MS = new Date("2026-05-28T00:00:00.000Z").getTime()
 const STATS_DATA_START_MS = new Date("2026-05-28T00:00:00.000Z").getTime()
 const WEEK_MS = 7 * 86_400_000
 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 SyncStatsResult = { ok: true; rows: number; startedAt: string; periodStart: string; periodEnd: string }
 export type SyncStatsError = AthenaQueryError | AthenaQueryTimeoutError | DatabaseError
 export type SyncStatsError = AthenaQueryError | AthenaQueryTimeoutError | DatabaseError
@@ -23,7 +24,12 @@ export const syncStats: () => Effect.Effect<
   const startedAt = yield* DateTime.nowAsDate
   const startedAt = yield* DateTime.nowAsDate
   const periodEnd = new Date(Math.floor((startedAt.getTime() - DATALAKE_INGESTION_LAG_MS) / 60_000) * 60_000)
   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.
   // 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 athena = yield* Athena
   const modelStats = yield* ModelStatRepo
   const modelStats = yield* ModelStatRepo
   const providerStats = yield* ProviderStatRepo
   const providerStats = yield* ProviderStatRepo