Correct product analytics cohorts and reporting
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled

This commit is contained in:
Rocky
2026-08-21 17:50:35 +08:00
parent b25f5ae6e9
commit edd0d9feca
31 changed files with 1134 additions and 262 deletions
+21
View File
@@ -187,6 +187,10 @@ export interface TrendPoint {
} }
export interface Overview { export interface Overview {
period: {
from: string;
until: string;
};
totalUsers: number; totalUsers: number;
activeUsers: number; activeUsers: number;
newUsers: number; newUsers: number;
@@ -210,6 +214,10 @@ export interface ReferralRankingItem {
} }
export interface ReferralOverview { export interface ReferralOverview {
period: {
from: string;
until: string;
};
pendingBindings: number; pendingBindings: number;
ineligibleBindings: number; ineligibleBindings: number;
funnel: FunnelStep[]; funnel: FunnelStep[];
@@ -244,6 +252,12 @@ export interface AnalyticsFeatureUsage {
successes: number; successes: number;
} }
export interface AnalyticsLatencyBucket {
bucket: string;
successful: number;
failed: number;
}
export interface ProductAnalyticsOverview { export interface ProductAnalyticsOverview {
period: { period: {
from: string; from: string;
@@ -282,6 +296,8 @@ export interface ProductAnalyticsOverview {
conversion7d: AnalyticsRate; conversion7d: AnalyticsRate;
conversion30d: AnalyticsRate; conversion30d: AnalyticsRate;
repeatPurchaseRate: AnalyticsRate; repeatPurchaseRate: AnalyticsRate;
purchaseFunnel: FunnelStep[];
cancelledUsers: number;
}; };
growthFunnel: FunnelStep[]; growthFunnel: FunnelStep[];
retention: AnalyticsCohort[]; retention: AnalyticsCohort[];
@@ -305,11 +321,16 @@ export interface ProductAnalyticsOverview {
mixedLanguageSessions: number; mixedLanguageSessions: number;
otherOnlySessions: number; otherOnlySessions: number;
}; };
referralSignals: {
shared: number;
opened: number;
};
referralFunnel: FunnelStep[]; referralFunnel: FunnelStep[];
guardrails: { guardrails: {
clientAiSuccessRate: AnalyticsRate; clientAiSuccessRate: AnalyticsRate;
managedSuccessRate: AnalyticsRate; managedSuccessRate: AnalyticsRate;
creditBlockedUsers: number; creditBlockedUsers: number;
latencyBuckets: AnalyticsLatencyBucket[];
}; };
} }
@@ -6,8 +6,8 @@ type ChartTone = "primary" | "violet" | "success" | "warning";
export interface ComparisonBarItem { export interface ComparisonBarItem {
id?: string; id?: string;
label: string; label: string;
value: number; value: number | null;
secondaryValue?: number; secondaryValue?: number | null;
hint?: string; hint?: string;
} }
@@ -33,7 +33,7 @@ export function ComparisonBarChart({
} }
const maximum = Math.max( const maximum = Math.max(
...items.flatMap((item) => [item.value, item.secondaryValue ?? 0]), ...items.flatMap((item) => [item.value ?? 0, item.secondaryValue ?? 0]),
1, 1,
); );
const legend = [ const legend = [
@@ -56,15 +56,17 @@ export function ComparisonBarChart({
maximum={maximum} maximum={maximum}
tone={primaryTone} tone={primaryTone}
value={item.value} value={item.value}
valueLabel={valueFormatter(item.value)} valueLabel={item.value == null ? "—" : valueFormatter(item.value)}
/> />
{secondaryLabel != null && item.secondaryValue != null ? ( {secondaryLabel != null && item.secondaryValue !== undefined ? (
<Bar <Bar
label={`${item.label} ${secondaryLabel}`} label={`${item.label} ${secondaryLabel}`}
maximum={maximum} maximum={maximum}
tone={secondaryTone} tone={secondaryTone}
value={item.secondaryValue} value={item.secondaryValue}
valueLabel={valueFormatter(item.secondaryValue)} valueLabel={
item.secondaryValue == null ? "—" : valueFormatter(item.secondaryValue)
}
/> />
) : null} ) : null}
</div> </div>
@@ -84,7 +86,7 @@ function Bar({
label: string; label: string;
maximum: number; maximum: number;
tone: ChartTone; tone: ChartTone;
value: number; value: number | null;
valueLabel: string; valueLabel: string;
}) { }) {
return ( return (
@@ -92,8 +94,8 @@ function Bar({
<progress <progress
className={`bar-progress bar-progress--${tone} block h-2.5 min-w-0 flex-1 overflow-hidden rounded-full`} className={`bar-progress bar-progress--${tone} block h-2.5 min-w-0 flex-1 overflow-hidden rounded-full`}
max={maximum} max={maximum}
value={value} value={value ?? 0}
aria-label={`${label}${valueLabel}`} aria-label={value == null ? `${label}:暂无数据` : `${label}${valueLabel}`}
/> />
<strong className="w-16 shrink-0 text-right text-xs font-semibold tabular-nums text-foreground"> <strong className="w-16 shrink-0 text-right text-xs font-semibold tabular-nums text-foreground">
{valueLabel} {valueLabel}
@@ -12,7 +12,7 @@ export function FunnelChart({
return <p className="p-8 text-center text-sm text-muted">{emptyText}</p>; return <p className="p-8 text-center text-sm text-muted">{emptyText}</p>;
} }
const maximum = Math.max(steps[0]?.count ?? 0, ...steps.map((step) => step.count), 1); const maximum = Math.max(steps[0]?.count ?? 0, 1);
return ( return (
<div className="space-y-5 p-5 sm:p-6"> <div className="space-y-5 p-5 sm:p-6">
@@ -7,11 +7,11 @@ export function RadialMetric({
tone = "primary", tone = "primary",
}: { }: {
label: string; label: string;
percent: number; percent: number | null;
detail?: string; detail?: string;
tone?: RadialTone; tone?: RadialTone;
}) { }) {
const value = Math.min(Math.max(percent, 0), 100); const value = percent == null ? null : Math.min(Math.max(percent, 0), 100);
return ( return (
<div className="flex items-center gap-5"> <div className="flex items-center gap-5">
@@ -19,7 +19,7 @@ export function RadialMetric({
className="size-28 shrink-0" className="size-28 shrink-0"
viewBox="0 0 120 120" viewBox="0 0 120 120"
role="img" role="img"
aria-label={`${label}${value.toFixed(1)}%`} aria-label={value == null ? `${label}:暂无数据` : `${label}${value.toFixed(1)}%`}
> >
<circle className="radial-track" cx="60" cy="60" r="48" pathLength="100" /> <circle className="radial-track" cx="60" cy="60" r="48" pathLength="100" />
<circle <circle
@@ -28,11 +28,11 @@ export function RadialMetric({
cy="60" cy="60"
r="48" r="48"
pathLength="100" pathLength="100"
strokeDasharray={`${value} ${100 - value}`} strokeDasharray={`${value ?? 0} ${100 - (value ?? 0)}`}
transform="rotate(-90 60 60)" transform="rotate(-90 60 60)"
/> />
<text className="radial-label" x="60" y="65" textAnchor="middle"> <text className="radial-label" x="60" y="65" textAnchor="middle">
{Math.round(value)}% {value == null ? "—" : `${Math.round(value)}%`}
</text> </text>
</svg> </svg>
<div> <div>
@@ -0,0 +1,20 @@
export function PeriodCaption({
from,
until,
}: {
from: string;
until: string;
}) {
return (
<p className="text-xs text-muted" aria-label="实际统计周期">
{utcLabel(from)} {utcLabel(until)}UTC
</p>
);
}
function utcLabel(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return date.toISOString().replace("T", " ").slice(0, 16);
}
@@ -23,6 +23,7 @@ import { ComparisonBarChart } from "../../components/charts/comparison-bar-chart
import { FunnelChart } from "../../components/charts/funnel-chart"; import { FunnelChart } from "../../components/charts/funnel-chart";
import { FilterControl, ToggleFilter } from "../../components/filter-control"; import { FilterControl, ToggleFilter } from "../../components/filter-control";
import { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives"; import { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives";
import { PeriodCaption } from "../../components/period-caption";
import { RangeControl } from "../../components/range-control"; import { RangeControl } from "../../components/range-control";
import { SortControl } from "../../components/sort-control"; import { SortControl } from "../../components/sort-control";
import { formatNumber } from "../../lib/format"; import { formatNumber } from "../../lib/format";
@@ -110,7 +111,12 @@ export function AnalyticsPage() {
eyebrow="CEO Dashboard" eyebrow="CEO Dashboard"
title="产品增长与留存" title="产品增长与留存"
description="围绕成功使用 AI 的核心价值事件,观察增长质量、留存、消耗与付费。" description="围绕成功使用 AI 的核心价值事件,观察增长质量、留存、消耗与付费。"
actions={<RangeControl value={range} onChange={setRange} />} actions={
<div className="flex flex-col items-end gap-2">
<RangeControl value={range} onChange={setRange} />
<PeriodCaption from={data.period.from} until={data.period.until} />
</div>
}
/> />
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4" aria-label="北极星指标"> <section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4" aria-label="北极星指标">
@@ -151,7 +157,7 @@ export function AnalyticsPage() {
<Card className="overflow-hidden"> <Card className="overflow-hidden">
<SectionHeader <SectionHeader
title="增长激活漏斗" title="增长激活漏斗"
description="首次启动到首次 AI 成功,显示逐步及累计转化" description="同一批已完成 24 小时观察的新安装,所有步骤必须在首次启动后 24 小时内完成"
icon={Target} icon={Target}
/> />
<FunnelChart steps={data.growthFunnel} /> <FunnelChart steps={data.growthFunnel} />
@@ -241,7 +247,7 @@ export function AnalyticsPage() {
<Card className="overflow-hidden"> <Card className="overflow-hidden">
<SectionHeader <SectionHeader
title="活跃与消耗" title="活跃与消耗"
description="托管调用以服务端结算为准" description="DAU / WAU / MAU 为截至统计截止时刻的滚动 1 / 7 / 30 日 AI 价值活跃用户"
icon={Activity} icon={Activity}
/> />
<ComparisonBarChart <ComparisonBarChart
@@ -335,20 +341,24 @@ export function AnalyticsPage() {
<section className="grid gap-6 xl:grid-cols-3"> <section className="grid gap-6 xl:grid-cols-3">
<Card className="overflow-hidden"> <Card className="overflow-hidden">
<SectionHeader title="付费转化" description="StoreKit 已验证交易" icon={BadgeDollarSign} /> <SectionHeader
title="付费转化"
description="所选周期内完成 7 / 30 天观察窗的注册 cohort;购买以 StoreKit 验证为准"
icon={BadgeDollarSign}
/>
<ComparisonBarChart <ComparisonBarChart
items={[ items={[
{ {
label: "7 天免费转付费", label: "7 天免费转付费",
value: data.monetization.conversion7d.percent ?? 0, value: data.monetization.conversion7d.percent ?? null,
}, },
{ {
label: "30 天免费转付费", label: "30 天免费转付费",
value: data.monetization.conversion30d.percent ?? 0, value: data.monetization.conversion30d.percent ?? null,
}, },
{ {
label: "复购率", label: "复购率",
value: data.monetization.repeatPurchaseRate.percent ?? 0, value: data.monetization.repeatPurchaseRate.percent ?? null,
}, },
]} ]}
primaryLabel="转化率" primaryLabel="转化率"
@@ -367,9 +377,15 @@ export function AnalyticsPage() {
<Card className="overflow-hidden"> <Card className="overflow-hidden">
<SectionHeader <SectionHeader
title="推荐增长漏斗" title="推荐增长漏斗"
description="分享打开、绑定、激活与奖励" description="严格绑定 cohort分享打开仅作为独立方向信号"
icon={Target} icon={Target}
/> />
<MetricRows
rows={[
["客户端分享信号", formatNumber(data.referralSignals.shared)],
["邀请打开信号", formatNumber(data.referralSignals.opened)],
]}
/>
<FunnelChart steps={data.referralFunnel} /> <FunnelChart steps={data.referralFunnel} />
</Card> </Card>
@@ -379,11 +395,11 @@ export function AnalyticsPage() {
items={[ items={[
{ {
label: "客户端 AI", label: "客户端 AI",
value: data.guardrails.clientAiSuccessRate.percent ?? 0, value: data.guardrails.clientAiSuccessRate.percent ?? null,
}, },
{ {
label: "托管请求", label: "托管请求",
value: data.guardrails.managedSuccessRate.percent ?? 0, value: data.guardrails.managedSuccessRate.percent ?? null,
}, },
]} ]}
primaryLabel="成功率" primaryLabel="成功率"
@@ -399,6 +415,44 @@ export function AnalyticsPage() {
</Card> </Card>
</section> </section>
<section className="grid gap-6 xl:grid-cols-2">
<Card className="overflow-hidden">
<SectionHeader
title="购买意向漏斗"
description="同一安装依次浏览、发起购买,并由 StoreKit 服务端验证"
icon={BadgeDollarSign}
/>
<FunnelChart
steps={data.monetization.purchaseFunnel}
emptyText="客户端购买事件暂无样本"
/>
<MetricRows
rows={[["取消购买用户", formatNumber(data.monetization.cancelledUsers)]]}
/>
</Card>
<Card className="overflow-hidden">
<SectionHeader
title="AI 终态延迟分布"
description="按客户端白名单耗时桶聚合;不推算虚假的精确分位数"
icon={Gauge}
/>
<ComparisonBarChart
items={data.guardrails.latencyBuckets.map((item) => ({
id: item.bucket,
label: latencyBucketLabel(item.bucket),
value: item.successful,
secondaryValue: item.failed,
}))}
primaryLabel="成功"
secondaryLabel="失败"
primaryTone="success"
secondaryTone="warning"
emptyText="客户端 AI 终态事件暂无样本"
/>
</Card>
</section>
<Card className="overflow-hidden"> <Card className="overflow-hidden">
<SectionHeader title="渠道质量" description="新增不是终点,重点比较 24 小时激活" icon={Users} /> <SectionHeader title="渠道质量" description="新增不是终点,重点比较 24 小时激活" icon={Users} />
<ChartToolbar label="渠道质量筛选与排序"> <ChartToolbar label="渠道质量筛选与排序">
@@ -406,7 +460,7 @@ export function AnalyticsPage() {
value={channelSort} value={channelSort}
order={channelOrder} order={channelOrder}
options={[ options={[
{ value: "installs", label: "新增安装" }, { value: "installs", label: "已完成观察安装" },
{ value: "activated", label: "激活人数" }, { value: "activated", label: "激活人数" },
{ value: "rate", label: "激活率" }, { value: "rate", label: "激活率" },
]} ]}
@@ -425,7 +479,7 @@ export function AnalyticsPage() {
secondaryValue: channel.activated, secondaryValue: channel.activated,
hint: `激活率 ${rateLabel(channel.activationRate)}`, hint: `激活率 ${rateLabel(channel.activationRate)}`,
}))} }))}
primaryLabel="新增安装" primaryLabel="已完成 24h 观察安装"
secondaryLabel="24 小时激活" secondaryLabel="24 小时激活"
emptyText="当前筛选条件下暂无渠道归因数据" emptyText="当前筛选条件下暂无渠道归因数据"
/> />
@@ -535,3 +589,13 @@ function executionModeLabel(mode: string): string {
BYOK: "BYOK", BYOK: "BYOK",
}[mode] ?? mode; }[mode] ?? mode;
} }
function latencyBucketLabel(bucket: string): string {
return {
LT_1S: "< 1 秒",
S1_TO_3: "13 秒",
S3_TO_10: "310 秒",
S10_TO_30: "1030 秒",
GTE_30S: "≥ 30 秒",
}[bucket] ?? bucket;
}
@@ -14,6 +14,7 @@ import { RadialMetric } from "../../components/charts/radial-metric";
import { TrendChart } from "../../components/charts/trend-chart"; import { TrendChart } from "../../components/charts/trend-chart";
import { ToggleFilter } from "../../components/filter-control"; import { ToggleFilter } from "../../components/filter-control";
import { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives"; import { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives";
import { PeriodCaption } from "../../components/period-caption";
import { RangeControl } from "../../components/range-control"; import { RangeControl } from "../../components/range-control";
import { SortControl } from "../../components/sort-control"; import { SortControl } from "../../components/sort-control";
import { formatNumber, usageTypeLabel } from "../../lib/format"; import { formatNumber, usageTypeLabel } from "../../lib/format";
@@ -58,7 +59,7 @@ export function OverviewPage() {
if (!data) return <LoadingState label="加载运营总览" />; if (!data) return <LoadingState label="加载运营总览" />;
const activeRate = const activeRate =
data.totalUsers > 0 ? Math.round((data.activeUsers / data.totalUsers) * 100) : 0; data.totalUsers > 0 ? Math.round((data.activeUsers / data.totalUsers) * 100) : null;
const usageRequests = data.usage.reduce((sum, item) => sum + item.requests, 0); const usageRequests = data.usage.reduce((sum, item) => sum + item.requests, 0);
return ( return (
@@ -66,8 +67,13 @@ export function OverviewPage() {
<PageHeader <PageHeader
eyebrow="核心指标" eyebrow="核心指标"
title="运营总览" title="运营总览"
description="聚合用户增长、活跃度与积分流转,快速识别业务变化。" description="活跃用户指周期内成功使用 AI 或产生手动键盘输入的注册用户。"
actions={<RangeControl value={range} onChange={setRange} />} actions={
<div className="flex flex-col items-end gap-2">
<RangeControl value={range} onChange={setRange} />
<PeriodCaption from={data.period.from} until={data.period.until} />
</div>
}
/> />
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4" aria-label="关键指标"> <section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4" aria-label="关键指标">
@@ -80,7 +86,7 @@ export function OverviewPage() {
<StatCard <StatCard
label="活跃用户" label="活跃用户"
value={formatNumber(data.activeUsers)} value={formatNumber(data.activeUsers)}
hint={`活跃率 ${activeRate}%`} hint={`活跃率 ${activeRate == null ? "—" : `${activeRate}%`}`}
icon={Activity} icon={Activity}
tone="success" tone="success"
/> />
@@ -105,7 +111,9 @@ export function OverviewPage() {
<div className="mb-7 flex flex-wrap items-start justify-between gap-4"> <div className="mb-7 flex flex-wrap items-start justify-between gap-4">
<div> <div>
<h2 className="text-base font-bold text-foreground"></h2> <h2 className="text-base font-bold text-foreground"></h2>
<p className="mt-1 text-xs text-muted"> UTC </p> <p className="mt-1 text-xs text-muted">
UTC
</p>
</div> </div>
<ChartLegend <ChartLegend
items={[ items={[
@@ -21,6 +21,7 @@ import {
PageHeader, PageHeader,
StatCard, StatCard,
} from "../../components/primitives"; } from "../../components/primitives";
import { PeriodCaption } from "../../components/period-caption";
import { RangeControl } from "../../components/range-control"; import { RangeControl } from "../../components/range-control";
import { SortControl } from "../../components/sort-control"; import { SortControl } from "../../components/sort-control";
import { formatNumber } from "../../lib/format"; import { formatNumber } from "../../lib/format";
@@ -115,7 +116,7 @@ export function ReferralsPage() {
const first = data.funnel.at(0)?.count ?? 0; const first = data.funnel.at(0)?.count ?? 0;
const last = data.funnel.at(-1)?.count ?? 0; const last = data.funnel.at(-1)?.count ?? 0;
const conversion = first > 0 ? Math.round((last / first) * 100) : 0; const conversion = first > 0 ? Math.round((last / first) * 100) : null;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -123,7 +124,12 @@ export function ReferralsPage() {
eyebrow="增长分析" eyebrow="增长分析"
title="裂变与排行" title="裂变与排行"
description="奖励以有效使用为前提,关注真实转化而不是单纯注册量。" description="奖励以有效使用为前提,关注真实转化而不是单纯注册量。"
actions={<RangeControl value={range} onChange={setRange} />} actions={
<div className="flex flex-col items-end gap-2">
<RangeControl value={range} onChange={setRange} />
<PeriodCaption from={data.period.from} until={data.period.until} />
</div>
}
/> />
<section className="grid gap-4 sm:grid-cols-3"> <section className="grid gap-4 sm:grid-cols-3">
@@ -143,7 +149,7 @@ export function ReferralsPage() {
/> />
<StatCard <StatCard
label="漏斗转化率" label="漏斗转化率"
value={`${conversion}%`} value={conversion == null ? "—" : `${conversion}%`}
hint="首环节至最终有效使用" hint="首环节至最终有效使用"
icon={TrendingUp} icon={TrendingUp}
tone="success" tone="success"
@@ -184,7 +190,7 @@ export function ReferralsPage() {
<div className="flex items-center justify-between border-b border-border p-5 sm:p-6"> <div className="flex items-center justify-between border-b border-border p-5 sm:p-6">
<div> <div>
<h2 className="text-base font-bold"></h2> <h2 className="text-base font-bold"></h2>
<p className="mt-1 text-xs text-muted">使</p> <p className="mt-1 text-xs text-muted"> AI </p>
</div> </div>
<span className="grid size-10 place-items-center rounded-2xl bg-primary-soft text-primary"> <span className="grid size-10 place-items-center rounded-2xl bg-primary-soft text-primary">
<GitBranch className="size-4" aria-hidden /> <GitBranch className="size-4" aria-hidden />
@@ -194,7 +200,7 @@ export function ReferralsPage() {
<RadialMetric <RadialMetric
label="整体有效转化" label="整体有效转化"
percent={conversion} percent={conversion}
detail={`${formatNumber(first)}起点行为,最终形成 ${formatNumber(last)}有效使用`} detail={`${formatNumber(first)}绑定,最终形成 ${formatNumber(last)}奖励`}
tone="success" tone="success"
/> />
</div> </div>
@@ -205,7 +211,9 @@ export function ReferralsPage() {
<div className="flex items-center justify-between border-b border-border px-5 py-5 sm:px-6"> <div className="flex items-center justify-between border-b border-border px-5 py-5 sm:px-6">
<div> <div>
<h2 className="text-base font-bold"></h2> <h2 className="text-base font-bold"></h2>
<p className="mt-1 text-xs text-muted"></p> <p className="mt-1 text-xs text-muted">
Top 8 {limit}
</p>
</div> </div>
<span className="grid size-10 place-items-center rounded-2xl bg-warning-soft text-warning"> <span className="grid size-10 place-items-center rounded-2xl bg-warning-soft text-warning">
<Award className="size-4" aria-hidden /> <Award className="size-4" aria-hidden />
+14 -1
View File
@@ -233,6 +233,10 @@ describe("React 管理页面", () => {
expect(screen.getByText("中文活跃用户")).toBeTruthy(); expect(screen.getByText("中文活跃用户")).toBeTruthy();
expect(screen.getByText("中英混合")).toBeTruthy(); expect(screen.getByText("中英混合")).toBeTruthy();
expect(screen.getByText("7 天免费转付费")).toBeTruthy(); expect(screen.getByText("7 天免费转付费")).toBeTruthy();
expect(screen.getByText("购买意向漏斗")).toBeTruthy();
expect(screen.getByText("AI 终态延迟分布")).toBeTruthy();
expect(screen.getByText("客户端分享信号")).toBeTruthy();
expect(screen.getByLabelText("实际统计周期").textContent).toContain("包含今日未完整数据");
}); });
it("产品图表可按执行模式筛选并按用户数稳定排序", async () => { it("产品图表可按执行模式筛选并按用户数稳定排序", async () => {
@@ -318,6 +322,12 @@ function analyticsOverview(): ProductAnalyticsOverview {
conversion7d: rate(8, 70, 11.4), conversion7d: rate(8, 70, 11.4),
conversion30d: rate(10, 50, 20), conversion30d: rate(10, 50, 20),
repeatPurchaseRate: rate(2, 10, 20), repeatPurchaseRate: rate(2, 10, 20),
purchaseFunnel: [
{ label: "浏览购买页", count: 30 },
{ label: "发起购买", count: 15 },
{ label: "StoreKit 验证完成", count: 10 },
],
cancelledUsers: 4,
}, },
growthFunnel: [ growthFunnel: [
{ label: "首次启动", count: 100 }, { label: "首次启动", count: 100 },
@@ -353,14 +363,17 @@ function analyticsOverview(): ProductAnalyticsOverview {
mixedLanguageSessions: 30, mixedLanguageSessions: 30,
otherOnlySessions: 10, otherOnlySessions: 10,
}, },
referralSignals: { shared: 20, opened: 15 },
referralFunnel: [ referralFunnel: [
{ label: "发起分享", count: 20 }, { label: "完成绑定", count: 10 },
{ label: "绑定后首次 AI 成功", count: 8 },
{ label: "完成奖励", count: 5 }, { label: "完成奖励", count: 5 },
], ],
guardrails: { guardrails: {
clientAiSuccessRate: rate(90, 100, 90), clientAiSuccessRate: rate(90, 100, 90),
managedSuccessRate: rate(95, 100, 95), managedSuccessRate: rate(95, 100, 95),
creditBlockedUsers: 3, creditBlockedUsers: 3,
latencyBuckets: [{ bucket: "S1_TO_3", successful: 80, failed: 5 }],
}, },
}; };
} }
+7 -1
View File
@@ -36,7 +36,10 @@ describe("高风险交互与 CSP", () => {
const { container } = render( const { container } = render(
<> <>
<ComparisonBarChart <ComparisonBarChart
items={[{ label: "自然量", value: 100, secondaryValue: 60 }]} items={[
{ label: "自然量", value: 100, secondaryValue: 60 },
{ label: "样本不足", value: null },
]}
primaryLabel="新增安装" primaryLabel="新增安装"
secondaryLabel="24 小时激活" secondaryLabel="24 小时激活"
/> />
@@ -51,6 +54,7 @@ describe("高风险交互与 CSP", () => {
]} ]}
/> />
<RadialMetric label="用户活跃率" percent={50} /> <RadialMetric label="用户活跃率" percent={50} />
<RadialMetric label="无样本活跃率" percent={null} />
</>, </>,
); );
@@ -58,6 +62,8 @@ describe("高风险交互与 CSP", () => {
expect(screen.getByRole("progressbar", { name: /首次启动:100/ })).toBeTruthy(); expect(screen.getByRole("progressbar", { name: /首次启动:100/ })).toBeTruthy();
expect(screen.getByText("D1、D7、D30 价值留存 cohort 热力图")).toBeTruthy(); expect(screen.getByText("D1、D7、D30 价值留存 cohort 热力图")).toBeTruthy();
expect(screen.getByRole("img", { name: "用户活跃率:50.0%" })).toBeTruthy(); expect(screen.getByRole("img", { name: "用户活跃率:50.0%" })).toBeTruthy();
expect(screen.getByRole("progressbar", { name: "样本不足 新增安装:暂无数据" })).toBeTruthy();
expect(screen.getByRole("img", { name: "无样本活跃率:暂无数据" })).toBeTruthy();
expect(container.querySelector("[style]")).toBeNull(); expect(container.querySelector("[style]")).toBeNull();
}); });
+46 -15
View File
@@ -4,6 +4,10 @@ This document is the canonical definition of product metrics. All dates and
cohorts use UTC calendar boundaries. Counts are based on distinct accounts when cohorts use UTC calendar boundaries. Counts are based on distinct accounts when
an installation is linked, otherwise on the pseudonymous installation. an installation is linked, otherwise on the pseudonymous installation.
Admin presets cover exactly 7, 30, or 90 UTC calendar dates, starting at 00:00
on the first date and ending at the current instant. The current UTC date is
therefore explicitly partial.
## North-star metric ## North-star metric
### Weekly AI active users (WAIU) ### Weekly AI active users (WAIU)
@@ -39,9 +43,10 @@ Accounts whose `accounts.created_at` falls in the selected period.
### 24-hour AI activation rate ### 24-hour AI activation rate
The percentage of new installations that successfully complete any AI feature The percentage of new installations that have completed their full 24-hour
within 24 hours of their first open. The numerator uses the same value-event observation window and successfully complete any AI feature within 24 hours of
rules as WAIU. their first open. Unmatured installations are excluded from both numerator and
denominator. Managed usage before that installation's first open is ignored.
### Time to first value ### Time to first value
@@ -49,6 +54,14 @@ Elapsed time from `FIRST_OPEN` to the first successful AI feature. The dashboard
reports the median in minutes. Users without a successful AI feature are not reports the median in minutes. Users without a successful AI feature are not
included in the median and remain visible in the activation denominator. included in the median and remain visible in the activation denominator.
### 24-hour growth funnel
A strict cohort of installations with a completed 24-hour observation window:
first open, account registration after first open, first AI value event after
registration, and first server-verified purchase after that value event. Every
downstream step must occur within 24 hours of first open. D7 belongs only to the
retention report and is not mixed into this funnel.
## Activity ## Activity
### AI DAU, WAU and MAU ### AI DAU, WAU and MAU
@@ -69,6 +82,13 @@ Managed client success events are excluded from this total.
`successful AI requests / distinct value-active users` for the selected period. `successful AI requests / distinct value-active users` for the selected period.
### Registered product-active users
The operations overview counts distinct registered accounts with either a
successful AI value event (managed, local, or BYOK) or a finalized manual
keyboard-input summary in the selected period. The displayed rate divides this
population by all registered accounts.
## Keyboard input usage ## Keyboard input usage
Keyboard input metrics use finalized UTC-day summaries produced on-device. Keyboard input metrics use finalized UTC-day summaries produced on-device.
@@ -175,8 +195,11 @@ server credits and are excluded.
### 7-day and 30-day free-to-paid conversion ### 7-day and 30-day free-to-paid conversion
The percentage of newly registered accounts with a first credited StoreKit The percentage of newly registered accounts with a first credited StoreKit
purchase no later than 7 or 30 days after registration. Cohorts whose conversion purchase no later than 7 or 30 days after registration. The selected report
window has not elapsed are reported separately from mature cohorts. period filters when each observation window matures: a 7-day report cohort uses
registrations shifted exactly 7 days earlier, and the 30-day cohort is shifted
30 days earlier. This keeps every denominator fully observed and makes the rate
available even when the selected preset is no longer than the conversion window.
### Paying users ### Paying users
@@ -187,21 +210,29 @@ Distinct accounts with at least one credited StoreKit purchase in the period.
The percentage of paying accounts with at least two credited StoreKit purchases The percentage of paying accounts with at least two credited StoreKit purchases
across their lifetime. across their lifetime.
### Purchase intent funnel
A strict installation cohort: `PURCHASE_VIEWED`, followed by
`PURCHASE_STARTED`, followed by a StoreKit purchase verified by the server for
the linked account. Each event must occur after the previous step and before the
report's `until`. `PURCHASE_CANCELLED` is a separate signal, not a funnel step.
StoreKit transaction count and granted credits are operational proxies. Net StoreKit transaction count and granted credits are operational proxies. Net
revenue, App Store commission and refunds require App Store financial data and revenue, App Store commission and refunds require App Store financial data and
are outside this service's first version. are outside this service's first version.
## Referral funnel ## Referral funnel
The ordered growth funnel is: The ordered cohort contains bindings created in the selected period:
1. `REFERRAL_SHARED` distinct sharing installations. 1. Referral binding created.
2. Invitation opens: accepted `INVITE_OPENED` client events plus anonymous 2. The same invitee reaches an AI value event after binding.
first-party invitation page views. Page views are aggregate requests rather 3. The same binding is rewarded before the report's `until`.
than distinct people and must be interpreted as a directional funnel signal.
3. Referral-bound accounts. `REFERRAL_SHARED` distinct installations and invitation opens are independent
4. Referral-bound accounts that reach their first value event. directional signals. Invitation opens combine accepted `INVITE_OPENED` events
5. Rewarded referral bindings. with anonymous first-party page-view counters, so they are not people and must
never be placed in the ordered conversion funnel.
Pending and ineligible bindings are parallel status counts, not sequential Pending and ineligible bindings are parallel status counts, not sequential
funnel steps. funnel steps.
@@ -212,8 +243,8 @@ funnel steps.
terminal success or failure event. terminal success or failure event.
- Managed request failure rate: terminal non-settled `provider_requests` divided - Managed request failure rate: terminal non-settled `provider_requests` divided
by terminal managed requests. by terminal managed requests.
- P50/P95 latency: client duration bucket distribution for all modes; exact - Client latency: successful and failed terminal events grouped by declared
server duration percentiles may be added later. duration bucket. Exact P50/P95 values are not inferred from buckets.
- Credit-blocked users: distinct installations reporting - Credit-blocked users: distinct installations reporting
`INSUFFICIENT_CREDITS` during the period. `INSUFFICIENT_CREDITS` during the period.
+4 -1
View File
@@ -28,7 +28,10 @@ regardless of account linkage.
- Authentication is optional so first-open and pre-login events can be - Authentication is optional so first-open and pre-login events can be
measured. Invalid bearer credentials are rejected. measured. Invalid bearer credentials are rejected.
- `installationId` must be a client-generated UUID stored in the containing app - `installationId` must be a client-generated UUID stored in the containing app
and shared with the keyboard extension through the App Group. and shared with the keyboard extension through the App Group. New clients send
it once at the batch root. During the migration window, the server also accepts
released clients that repeat one identical `installationId` on every event;
missing, incomplete, or conflicting identities reject the entire batch.
- When a valid account session is present, the installation is linked to that - When a valid account session is present, the installation is linked to that
account. An installation cannot later be linked to a different account. account. An installation cannot later be linked to a different account.
- Every `clientEventId` is a client-generated UUID. The pair - Every `clientEventId` is a client-generated UUID. The pair
+93 -21
View File
@@ -85,6 +85,9 @@ paths:
session is supplied, the installation is linked to the account and is session is supplied, the installation is linked to the account and is
deleted with that account. Audio, user text, prompts, transcripts, deleted with that account. Audio, user text, prompts, transcripts,
model output, credentials and arbitrary properties are never accepted. model output, credentials and arbitrary properties are never accepted.
New clients must send installationId once at the batch level. During
migration, legacy clients that send the same installationId on every
event remain accepted; conflicting or incomplete identities are rejected.
requestBody: requestBody:
required: true required: true
content: content:
@@ -1314,6 +1317,7 @@ components:
AdminRange: AdminRange:
name: range name: range
in: query in: query
description: Covers exactly 7, 30, or 90 UTC calendar dates, from 00:00 on the first date through the current instant. The current UTC date is partial.
schema: { type: string, enum: [7d, 30d, 90d], default: 30d } schema: { type: string, enum: [7d, 30d, 90d], default: 30d }
AdminFrom: AdminFrom:
name: from name: from
@@ -1438,12 +1442,20 @@ components:
minLength: 1 minLength: 1
maxLength: 32 maxLength: 32
pattern: "^[A-Za-z0-9._+-]+$" pattern: "^[A-Za-z0-9._+-]+$"
installationId:
type: string
format: uuid
deprecated: true
description: Transitional legacy field; new clients must use the batch-level installationId.
ProductAnalyticsBatchRequest: ProductAnalyticsBatchRequest:
type: object type: object
additionalProperties: false additionalProperties: false
required: [installationId, events] required: [events]
properties: properties:
installationId: { type: string, format: uuid } installationId:
type: string
format: uuid
description: Required for new clients; legacy batches may instead repeat one identical ID on every event.
events: events:
type: array type: array
minItems: 1 minItems: 1
@@ -1793,10 +1805,18 @@ components:
date: { type: string, format: date } date: { type: string, format: date }
registrations: { type: integer, format: int64, minimum: 0 } registrations: { type: integer, format: int64, minimum: 0 }
creditsUsed: { type: integer, format: int64, minimum: 0 } creditsUsed: { type: integer, format: int64, minimum: 0 }
AdminStatsPeriod:
type: object
additionalProperties: false
required: [from, until]
properties:
from: { type: string, format: date-time, description: Inclusive UTC lower bound. }
until: { type: string, format: date-time, description: Exclusive current-instant upper bound. }
AdminOverview: AdminOverview:
type: object type: object
additionalProperties: false additionalProperties: false
required: required:
- period
- totalUsers - totalUsers
- activeUsers - activeUsers
- newUsers - newUsers
@@ -1806,8 +1826,13 @@ components:
- trend - trend
- usage - usage
properties: properties:
period: { $ref: "#/components/schemas/AdminStatsPeriod" }
totalUsers: { type: integer, format: int64, minimum: 0 } totalUsers: { type: integer, format: int64, minimum: 0 }
activeUsers: { type: integer, format: int64, minimum: 0 } activeUsers:
type: integer
format: int64
minimum: 0
description: Registered accounts with successful AI use or manually committed keyboard input in the period.
newUsers: { type: integer, format: int64, minimum: 0 } newUsers: { type: integer, format: int64, minimum: 0 }
totalCreditBalance: { type: integer, format: int64, minimum: 0 } totalCreditBalance: { type: integer, format: int64, minimum: 0 }
creditsGranted: { type: integer, format: int64, minimum: 0 } creditsGranted: { type: integer, format: int64, minimum: 0 }
@@ -1825,7 +1850,7 @@ components:
properties: properties:
label: label:
type: string type: string
enum: [邀请码创建, 成功绑定, 有效使用并奖励] enum: [成功绑定, 绑定后首次 AI 成功, 完成奖励]
count: { type: integer, format: int64, minimum: 0 } count: { type: integer, format: int64, minimum: 0 }
AdminReferralRank: AdminReferralRank:
type: object type: object
@@ -1839,8 +1864,9 @@ components:
AdminReferralOverview: AdminReferralOverview:
type: object type: object
additionalProperties: false additionalProperties: false
required: [pendingBindings, ineligibleBindings, funnel, ranking] required: [period, pendingBindings, ineligibleBindings, funnel, ranking]
properties: properties:
period: { $ref: "#/components/schemas/AdminStatsPeriod" }
pendingBindings: { type: integer, format: int64, minimum: 0 } pendingBindings: { type: integer, format: int64, minimum: 0 }
ineligibleBindings: { type: integer, format: int64, minimum: 0 } ineligibleBindings: { type: integer, format: int64, minimum: 0 }
funnel: funnel:
@@ -1856,7 +1882,11 @@ components:
properties: properties:
numerator: { type: integer, format: int64, minimum: 0 } numerator: { type: integer, format: int64, minimum: 0 }
denominator: { type: integer, format: int64, minimum: 0 } denominator: { type: integer, format: int64, minimum: 0 }
percent: { type: ["number", "null"], minimum: 0, maximum: 100 } percent:
type: ["number", "null"]
minimum: 0
maximum: 100
description: Null means unavailable, usually because the denominator is zero; clients must not render it as 0%.
AdminAnalyticsFunnelStep: AdminAnalyticsFunnelStep:
type: object type: object
additionalProperties: false additionalProperties: false
@@ -1872,7 +1902,11 @@ components:
channel: channel:
type: string type: string
enum: [APP_STORE_ORGANIC, REFERRAL, SOCIAL_CONTENT, UNKNOWN] enum: [APP_STORE_ORGANIC, REFERRAL, SOCIAL_CONTENT, UNKNOWN]
installations: { type: integer, format: int64, minimum: 0 } installations:
type: integer
format: int64
minimum: 0
description: Installations in this channel with a completed 24-hour observation window.
activated: { type: integer, format: int64, minimum: 0 } activated: { type: integer, format: int64, minimum: 0 }
activationRate: { $ref: "#/components/schemas/AdminAnalyticsRate" } activationRate: { $ref: "#/components/schemas/AdminAnalyticsRate" }
AdminAnalyticsCohort: AdminAnalyticsCohort:
@@ -1905,6 +1939,16 @@ components:
executionMode: { type: string, enum: [MANAGED, LOCAL, BYOK] } executionMode: { type: string, enum: [MANAGED, LOCAL, BYOK] }
users: { type: integer, format: int64, minimum: 0 } users: { type: integer, format: int64, minimum: 0 }
successes: { type: integer, format: int64, minimum: 0 } successes: { type: integer, format: int64, minimum: 0 }
AdminAnalyticsLatencyBucket:
type: object
additionalProperties: false
required: [bucket, successful, failed]
properties:
bucket:
type: string
enum: [LT_1S, S1_TO_3, S3_TO_10, S10_TO_30, GTE_30S]
successful: { type: integer, format: int64, minimum: 0 }
failed: { type: integer, format: int64, minimum: 0 }
AdminAnalyticsKeyboardUsage: AdminAnalyticsKeyboardUsage:
type: object type: object
additionalProperties: false additionalProperties: false
@@ -1955,16 +1999,11 @@ components:
- retention - retention
- aiFeatures - aiFeatures
- keyboardUsage - keyboardUsage
- referralSignals
- referralFunnel - referralFunnel
- guardrails - guardrails
properties: properties:
period: period: { $ref: "#/components/schemas/AdminStatsPeriod" }
type: object
additionalProperties: false
required: [from, until]
properties:
from: { type: string, format: date-time }
until: { type: string, format: date-time }
northStar: northStar:
type: object type: object
additionalProperties: false additionalProperties: false
@@ -1990,9 +2029,21 @@ components:
additionalProperties: false additionalProperties: false
required: [dau, wau, mau, successfulAiRequests] required: [dau, wau, mau, successfulAiRequests]
properties: properties:
dau: { type: integer, format: int64, minimum: 0 } dau:
wau: { type: integer, format: int64, minimum: 0 } type: integer
mau: { type: integer, format: int64, minimum: 0 } format: int64
minimum: 0
description: Distinct AI value-active identities in the rolling 1-day window ending at period.until.
wau:
type: integer
format: int64
minimum: 0
description: Distinct AI value-active identities in the rolling 7-day window ending at period.until.
mau:
type: integer
format: int64
minimum: 0
description: Distinct AI value-active identities in the rolling 30-day window ending at period.until.
stickinessPercent: { type: ["number", "null"], minimum: 0, maximum: 100 } stickinessPercent: { type: ["number", "null"], minimum: 0, maximum: 100 }
successfulAiRequests: { type: integer, format: int64, minimum: 0 } successfulAiRequests: { type: integer, format: int64, minimum: 0 }
successfulRequestsPerActiveUser: { type: ["number", "null"], minimum: 0 } successfulRequestsPerActiveUser: { type: ["number", "null"], minimum: 0 }
@@ -2009,14 +2060,23 @@ components:
type: object type: object
additionalProperties: false additionalProperties: false
required: required:
[payingUsers, purchases, creditsPurchased, conversion7d, conversion30d, repeatPurchaseRate] [payingUsers, purchases, creditsPurchased, conversion7d, conversion30d, repeatPurchaseRate, purchaseFunnel, cancelledUsers]
properties: properties:
payingUsers: { type: integer, format: int64, minimum: 0 } payingUsers: { type: integer, format: int64, minimum: 0 }
purchases: { type: integer, format: int64, minimum: 0 } purchases: { type: integer, format: int64, minimum: 0 }
creditsPurchased: { type: integer, format: int64, minimum: 0 } creditsPurchased: { type: integer, format: int64, minimum: 0 }
conversion7d: { $ref: "#/components/schemas/AdminAnalyticsRate" } conversion7d:
conversion30d: { $ref: "#/components/schemas/AdminAnalyticsRate" } $ref: "#/components/schemas/AdminAnalyticsRate"
description: Conversion for account cohorts whose full 7-day observation window matures inside the selected report period.
conversion30d:
$ref: "#/components/schemas/AdminAnalyticsRate"
description: Conversion for account cohorts whose full 30-day observation window matures inside the selected report period.
repeatPurchaseRate: { $ref: "#/components/schemas/AdminAnalyticsRate" } repeatPurchaseRate: { $ref: "#/components/schemas/AdminAnalyticsRate" }
purchaseFunnel:
type: array
description: Strict installation cohort from purchase view through server-verified StoreKit purchase.
items: { $ref: "#/components/schemas/AdminAnalyticsFunnelStep" }
cancelledUsers: { type: integer, format: int64, minimum: 0 }
growthFunnel: growthFunnel:
type: array type: array
items: { $ref: "#/components/schemas/AdminAnalyticsFunnelStep" } items: { $ref: "#/components/schemas/AdminAnalyticsFunnelStep" }
@@ -2028,17 +2088,29 @@ components:
items: { $ref: "#/components/schemas/AdminAnalyticsFeatureUsage" } items: { $ref: "#/components/schemas/AdminAnalyticsFeatureUsage" }
keyboardUsage: keyboardUsage:
$ref: "#/components/schemas/AdminAnalyticsKeyboardUsage" $ref: "#/components/schemas/AdminAnalyticsKeyboardUsage"
referralSignals:
type: object
additionalProperties: false
required: [shared, opened]
description: Directional signals only; these counts are not funnel stages.
properties:
shared: { type: integer, format: int64, minimum: 0 }
opened: { type: integer, format: int64, minimum: 0 }
referralFunnel: referralFunnel:
type: array type: array
items: { $ref: "#/components/schemas/AdminAnalyticsFunnelStep" } items: { $ref: "#/components/schemas/AdminAnalyticsFunnelStep" }
guardrails: guardrails:
type: object type: object
additionalProperties: false additionalProperties: false
required: [clientAiSuccessRate, managedSuccessRate, creditBlockedUsers] required: [clientAiSuccessRate, managedSuccessRate, creditBlockedUsers, latencyBuckets]
properties: properties:
clientAiSuccessRate: { $ref: "#/components/schemas/AdminAnalyticsRate" } clientAiSuccessRate: { $ref: "#/components/schemas/AdminAnalyticsRate" }
managedSuccessRate: { $ref: "#/components/schemas/AdminAnalyticsRate" } managedSuccessRate: { $ref: "#/components/schemas/AdminAnalyticsRate" }
creditBlockedUsers: { type: integer, format: int64, minimum: 0 } creditBlockedUsers: { type: integer, format: int64, minimum: 0 }
latencyBuckets:
type: array
description: Client AI terminal events grouped into declared duration buckets.
items: { $ref: "#/components/schemas/AdminAnalyticsLatencyBucket" }
AdminUserSummary: AdminUserSummary:
type: object type: object
additionalProperties: false additionalProperties: false
@@ -604,7 +604,7 @@ private suspend fun AdminStatsService.getRange(
) )
} }
private fun parseAdminStatsRange(range: String?, clock: Clock): Pair<java.time.Instant, java.time.Instant>? { internal fun parseAdminStatsRange(range: String?, clock: Clock): Pair<java.time.Instant, java.time.Instant>? {
val days = when (range) { val days = when (range) {
null, "30d" -> 30L null, "30d" -> 30L
"7d" -> 7L "7d" -> 7L
@@ -612,7 +612,9 @@ private fun parseAdminStatsRange(range: String?, clock: Clock): Pair<java.time.I
else -> return null else -> return null
} }
val until = clock.instant() val until = clock.instant()
return until.minus(Duration.ofDays(days)) to until val firstIncludedDate = until.atZone(ZoneOffset.UTC).toLocalDate().minusDays(days - 1)
val from = firstIncludedDate.atStartOfDay(ZoneOffset.UTC).toInstant()
return from to until
} }
private data class AdminReferralQueryOptions( private data class AdminReferralQueryOptions(
@@ -953,6 +955,7 @@ private fun adminCookie(
private fun AdminStatsDto.toOverviewResponse(): AdminOverviewResponse { private fun AdminStatsDto.toOverviewResponse(): AdminOverviewResponse {
val consumedByDate = creditFlow.associateBy { it.date } val consumedByDate = creditFlow.associateBy { it.date }
return AdminOverviewResponse( return AdminOverviewResponse(
period = period,
totalUsers = overview.totalUsers, totalUsers = overview.totalUsers,
activeUsers = overview.activeUsers, activeUsers = overview.activeUsers,
newUsers = overview.registrations, newUsers = overview.registrations,
@@ -972,12 +975,13 @@ private fun AdminStatsDto.toOverviewResponse(): AdminOverviewResponse {
private fun AdminStatsDto.toReferralResponse(): AdminReferralResponse = private fun AdminStatsDto.toReferralResponse(): AdminReferralResponse =
AdminReferralResponse( AdminReferralResponse(
period = period,
pendingBindings = referralFunnel.pendingBindings, pendingBindings = referralFunnel.pendingBindings,
ineligibleBindings = referralFunnel.ineligibleBindings, ineligibleBindings = referralFunnel.ineligibleBindings,
funnel = listOf( funnel = listOf(
AdminFunnelResponse("邀请码创建", referralFunnel.codesCreated),
AdminFunnelResponse("成功绑定", referralFunnel.bindings), AdminFunnelResponse("成功绑定", referralFunnel.bindings),
AdminFunnelResponse("有效使用并奖励", referralFunnel.rewardedBindings), AdminFunnelResponse("绑定后首次 AI 成功", referralFunnel.activatedBindings),
AdminFunnelResponse("完成奖励", referralFunnel.rewardedBindings),
), ),
ranking = referralRanking.map { ranking = referralRanking.map {
AdminReferralRankResponse( AdminReferralRankResponse(
@@ -1137,6 +1141,7 @@ private data class PageResponse<T>(val items: List<T>, val nextCursor: String? =
@Serializable @Serializable
private data class AdminOverviewResponse( private data class AdminOverviewResponse(
val period: com.osglab.account.features.admin.stats.models.AdminStatsPeriodDto,
val totalUsers: Long, val totalUsers: Long,
val activeUsers: Long, val activeUsers: Long,
val newUsers: Long, val newUsers: Long,
@@ -1156,6 +1161,7 @@ private data class AdminTrendResponse(
@Serializable @Serializable
private data class AdminReferralResponse( private data class AdminReferralResponse(
val period: com.osglab.account.features.admin.stats.models.AdminStatsPeriodDto,
val pendingBindings: Long, val pendingBindings: Long,
val ineligibleBindings: Long, val ineligibleBindings: Long,
val funnel: List<AdminFunnelResponse>, val funnel: List<AdminFunnelResponse>,
@@ -34,6 +34,19 @@ data class AdminAnalyticsFeatureUsageDto(
val successes: Long, val successes: Long,
) )
@Serializable
data class AdminAnalyticsReferralSignalsDto(
val shared: Long,
val opened: Long,
)
@Serializable
data class AdminAnalyticsLatencyBucketDto(
val bucket: String,
val successful: Long,
val failed: Long,
)
@Serializable @Serializable
data class AdminAnalyticsFunnelStepDto( data class AdminAnalyticsFunnelStepDto(
val label: String, val label: String,
@@ -88,6 +101,8 @@ data class AdminAnalyticsMonetizationDto(
val conversion7d: AdminAnalyticsRateDto, val conversion7d: AdminAnalyticsRateDto,
val conversion30d: AdminAnalyticsRateDto, val conversion30d: AdminAnalyticsRateDto,
val repeatPurchaseRate: AdminAnalyticsRateDto, val repeatPurchaseRate: AdminAnalyticsRateDto,
val purchaseFunnel: List<AdminAnalyticsFunnelStepDto>,
val cancelledUsers: Long,
) )
@Serializable @Serializable
@@ -95,6 +110,7 @@ data class AdminAnalyticsGuardrailsDto(
val clientAiSuccessRate: AdminAnalyticsRateDto, val clientAiSuccessRate: AdminAnalyticsRateDto,
val managedSuccessRate: AdminAnalyticsRateDto, val managedSuccessRate: AdminAnalyticsRateDto,
val creditBlockedUsers: Long, val creditBlockedUsers: Long,
val latencyBuckets: List<AdminAnalyticsLatencyBucketDto>,
) )
@Serializable @Serializable
@@ -130,6 +146,7 @@ data class AdminProductAnalyticsDto(
val retention: List<AdminAnalyticsCohortDto>, val retention: List<AdminAnalyticsCohortDto>,
val aiFeatures: List<AdminAnalyticsFeatureUsageDto>, val aiFeatures: List<AdminAnalyticsFeatureUsageDto>,
val keyboardUsage: AdminAnalyticsKeyboardUsageDto, val keyboardUsage: AdminAnalyticsKeyboardUsageDto,
val referralSignals: AdminAnalyticsReferralSignalsDto,
val referralFunnel: List<AdminAnalyticsFunnelStepDto>, val referralFunnel: List<AdminAnalyticsFunnelStepDto>,
val guardrails: AdminAnalyticsGuardrailsDto, val guardrails: AdminAnalyticsGuardrailsDto,
) )
@@ -35,6 +35,7 @@ data class AdminCreditFlowPointDto(
data class AdminReferralFunnelDto( data class AdminReferralFunnelDto(
val codesCreated: Long, val codesCreated: Long,
val bindings: Long, val bindings: Long,
val activatedBindings: Long,
val rewardedBindings: Long, val rewardedBindings: Long,
val pendingBindings: Long, val pendingBindings: Long,
val ineligibleBindings: Long, val ineligibleBindings: Long,
@@ -0,0 +1,29 @@
package com.osglab.account.features.admin.stats.repositories
/**
* Canonical AI value events used by product analytics. Managed usage is sourced
* from immutable billing records; LOCAL and BYOK usage comes from terminal
* client events. No user content is selected.
*/
internal fun identityValueEventsCte(): String =
"""
WITH value_events AS (
SELECT
CONCAT('a:', user_id) AS identity_key,
created_at AS occurred_at
FROM credit_usage_records
UNION ALL
SELECT
COALESCE(
CONCAT('a:', i.account_id),
CONCAT('i:', e.installation_hash)
) AS identity_key,
e.occurred_at
FROM product_analytics_events e
JOIN product_analytics_installations i
ON i.installation_hash = e.installation_hash
WHERE e.event_name = 'AI_FEATURE_SUCCEEDED'
AND e.execution_mode IN ('LOCAL', 'BYOK')
)
""".trimIndent()
@@ -74,6 +74,19 @@ data class AdminAnalyticsGuardrailRow(
val creditBlockedUsers: Long, val creditBlockedUsers: Long,
) )
data class AdminAnalyticsLatencyRow(
val bucket: String,
val successful: Long,
val failed: Long,
)
data class AdminAnalyticsPurchaseFunnelRow(
val viewed: Long,
val started: Long,
val verified: Long,
val cancelled: Long,
)
data class AdminAnalyticsKeyboardUsageRow( data class AdminAnalyticsKeyboardUsageRow(
val activeUsers: Long, val activeUsers: Long,
val keyboardUsers: Long, val keyboardUsers: Long,
@@ -95,7 +108,6 @@ data class AdminAnalyticsGrowthFunnelRow(
val opened: Long, val opened: Long,
val registered: Long, val registered: Long,
val activated: Long, val activated: Long,
val retainedD7: Long,
val purchased: Long, val purchased: Long,
) )
@@ -120,6 +132,8 @@ data class AdminProductAnalyticsSnapshot(
val keyboardUsage: AdminAnalyticsKeyboardUsageRow, val keyboardUsage: AdminAnalyticsKeyboardUsageRow,
val referrals: AdminAnalyticsReferralRow, val referrals: AdminAnalyticsReferralRow,
val guardrails: AdminAnalyticsGuardrailRow, val guardrails: AdminAnalyticsGuardrailRow,
val latencyDistribution: List<AdminAnalyticsLatencyRow>,
val purchaseFunnel: AdminAnalyticsPurchaseFunnelRow,
) )
interface AdminProductAnalyticsRepository { interface AdminProductAnalyticsRepository {
@@ -142,7 +156,7 @@ class ExposedAdminProductAnalyticsRepository(
AdminProductAnalyticsSnapshot( AdminProductAnalyticsSnapshot(
currentWeeklyUsers = loadValueActiveUsers(currentWeek), currentWeeklyUsers = loadValueActiveUsers(currentWeek),
previousWeeklyUsers = loadValueActiveUsers(previousWeek), previousWeeklyUsers = loadValueActiveUsers(previousWeek),
newInstallations = activation.denominator, newInstallations = loadNewInstallations(range),
newAccounts = loadNewAccounts(range), newAccounts = loadNewAccounts(range),
activation24h = AdminAnalyticsCountRow(activation.activated, activation.denominator), activation24h = AdminAnalyticsCountRow(activation.activated, activation.denominator),
medianTimeToValueMinutes = activation.medianMinutes, medianTimeToValueMinutes = activation.medianMinutes,
@@ -166,12 +180,14 @@ class ExposedAdminProductAnalyticsRepository(
keyboardUsage = loadKeyboardUsage(range), keyboardUsage = loadKeyboardUsage(range),
referrals = loadReferrals(range), referrals = loadReferrals(range),
guardrails = loadGuardrails(range), guardrails = loadGuardrails(range),
latencyDistribution = loadLatencyDistribution(range),
purchaseFunnel = loadPurchaseFunnel(range),
) )
} }
private fun loadValueActiveUsers(window: AdminAnalyticsWindow): Long = private fun loadValueActiveUsers(window: AdminAnalyticsWindow): Long =
querySingle( querySingle(
valueEventsCte() + identityValueEventsCte() +
""" """
SELECT COUNT(DISTINCT identity_key) AS aggregate_value SELECT COUNT(DISTINCT identity_key) AS aggregate_value
FROM value_events FROM value_events
@@ -190,6 +206,21 @@ class ExposedAdminProductAnalyticsRepository(
range.arguments(), range.arguments(),
) { it.exactLong("aggregate_value") } ) { it.exactLong("aggregate_value") }
private fun loadNewInstallations(range: AdminAnalyticsWindow): Long =
querySingle(
"""
SELECT COUNT(*) AS aggregate_value
FROM (
SELECT installation_hash, MIN(occurred_at) AS opened_at
FROM product_analytics_events
WHERE event_name = 'FIRST_OPEN'
GROUP BY installation_hash
) first_open
WHERE opened_at >= ? AND opened_at < ?
""",
range.arguments(),
) { it.exactLong("aggregate_value") }
private fun loadActivation(range: AdminAnalyticsWindow): ActivationRow = private fun loadActivation(range: AdminAnalyticsWindow): ActivationRow =
querySingle( querySingle(
""" """
@@ -197,21 +228,30 @@ class ExposedAdminProductAnalyticsRepository(
SELECT installation_hash, MIN(occurred_at) AS opened_at SELECT installation_hash, MIN(occurred_at) AS opened_at
FROM product_analytics_events FROM product_analytics_events
WHERE event_name = 'FIRST_OPEN' WHERE event_name = 'FIRST_OPEN'
AND occurred_at >= ? AND occurred_at < ?
GROUP BY installation_hash GROUP BY installation_hash
HAVING opened_at >= ?
AND opened_at <= DATE_SUB(?, INTERVAL 24 HOUR)
), ),
client_value AS ( client_value AS (
SELECT installation_hash, MIN(occurred_at) AS value_at SELECT e.installation_hash, MIN(e.occurred_at) AS value_at
FROM product_analytics_events FROM first_open o
WHERE event_name = 'AI_FEATURE_SUCCEEDED' JOIN product_analytics_events e
AND execution_mode IN ('LOCAL', 'BYOK') ON e.installation_hash = o.installation_hash
GROUP BY installation_hash WHERE e.event_name = 'AI_FEATURE_SUCCEEDED'
AND e.execution_mode IN ('LOCAL', 'BYOK')
AND e.occurred_at >= o.opened_at
AND e.occurred_at <= DATE_ADD(o.opened_at, INTERVAL 24 HOUR)
GROUP BY e.installation_hash
), ),
managed_value AS ( managed_value AS (
SELECT i.installation_hash, MIN(u.created_at) AS value_at SELECT o.installation_hash, MIN(u.created_at) AS value_at
FROM product_analytics_installations i FROM first_open o
JOIN product_analytics_installations i
ON i.installation_hash = o.installation_hash
JOIN credit_usage_records u ON u.user_id = i.account_id JOIN credit_usage_records u ON u.user_id = i.account_id
GROUP BY i.installation_hash WHERE u.created_at >= o.opened_at
AND u.created_at <= DATE_ADD(o.opened_at, INTERVAL 24 HOUR)
GROUP BY o.installation_hash
), ),
first_value_by_install AS ( first_value_by_install AS (
SELECT installation_hash, MIN(value_at) AS value_at SELECT installation_hash, MIN(value_at) AS value_at
@@ -228,8 +268,6 @@ class ExposedAdminProductAnalyticsRepository(
TIMESTAMPDIFF(SECOND, o.opened_at, v.value_at) AS seconds_to_value TIMESTAMPDIFF(SECOND, o.opened_at, v.value_at) AS seconds_to_value
FROM first_open o FROM first_open o
JOIN first_value_by_install v ON v.installation_hash = o.installation_hash JOIN first_value_by_install v ON v.installation_hash = o.installation_hash
WHERE v.value_at >= o.opened_at
AND v.value_at <= DATE_ADD(o.opened_at, INTERVAL 24 HOUR)
), ),
ranked AS ( ranked AS (
SELECT SELECT
@@ -277,21 +315,30 @@ class ExposedAdminProductAnalyticsRepository(
) AS channel ) AS channel
FROM product_analytics_events e FROM product_analytics_events e
WHERE e.event_name = 'FIRST_OPEN' WHERE e.event_name = 'FIRST_OPEN'
AND e.occurred_at >= ? AND e.occurred_at < ?
GROUP BY e.installation_hash GROUP BY e.installation_hash
HAVING opened_at >= ?
AND opened_at <= DATE_SUB(?, INTERVAL 24 HOUR)
), ),
client_value AS ( client_value AS (
SELECT installation_hash, MIN(occurred_at) AS value_at SELECT e.installation_hash, MIN(e.occurred_at) AS value_at
FROM product_analytics_events FROM first_open o
WHERE event_name = 'AI_FEATURE_SUCCEEDED' JOIN product_analytics_events e
AND execution_mode IN ('LOCAL', 'BYOK') ON e.installation_hash = o.installation_hash
GROUP BY installation_hash WHERE e.event_name = 'AI_FEATURE_SUCCEEDED'
AND e.execution_mode IN ('LOCAL', 'BYOK')
AND e.occurred_at >= o.opened_at
AND e.occurred_at <= DATE_ADD(o.opened_at, INTERVAL 24 HOUR)
GROUP BY e.installation_hash
), ),
managed_value AS ( managed_value AS (
SELECT i.installation_hash, MIN(u.created_at) AS value_at SELECT o.installation_hash, MIN(u.created_at) AS value_at
FROM product_analytics_installations i FROM first_open o
JOIN product_analytics_installations i
ON i.installation_hash = o.installation_hash
JOIN credit_usage_records u ON u.user_id = i.account_id JOIN credit_usage_records u ON u.user_id = i.account_id
GROUP BY i.installation_hash WHERE u.created_at >= o.opened_at
AND u.created_at <= DATE_ADD(o.opened_at, INTERVAL 24 HOUR)
GROUP BY o.installation_hash
), ),
first_value_by_install AS ( first_value_by_install AS (
SELECT installation_hash, MIN(value_at) AS value_at SELECT installation_hash, MIN(value_at) AS value_at
@@ -307,9 +354,7 @@ class ExposedAdminProductAnalyticsRepository(
COUNT(*) AS installations, COUNT(*) AS installations,
SUM( SUM(
CASE CASE
WHEN v.value_at >= o.opened_at WHEN v.value_at IS NOT NULL THEN 1 ELSE 0
AND v.value_at <= DATE_ADD(o.opened_at, INTERVAL 24 HOUR)
THEN 1 ELSE 0
END END
) AS activated ) AS activated
FROM first_open o FROM first_open o
@@ -397,10 +442,8 @@ class ExposedAdminProductAnalyticsRepository(
) )
} }
private fun loadMonetization(range: AdminAnalyticsWindow): AdminAnalyticsMonetizationRow { private fun loadMonetization(range: AdminAnalyticsWindow): AdminAnalyticsMonetizationRow =
val sevenDayMaturity = range.until.minusSeconds(7 * DAY_SECONDS) querySingle(
val thirtyDayMaturity = range.until.minusSeconds(30 * DAY_SECONDS)
return querySingle(
""" """
WITH first_purchase AS ( WITH first_purchase AS (
SELECT user_id, MIN(purchased_at) AS first_purchased_at, COUNT(*) AS lifetime_purchases SELECT user_id, MIN(purchased_at) AS first_purchased_at, COUNT(*) AS lifetime_purchases
@@ -459,10 +502,10 @@ class ExposedAdminProductAnalyticsRepository(
""", """,
buildList { buildList {
addAll(range.arguments(repetitions = 3)) addAll(range.arguments(repetitions = 3))
addAll(maturedWindowArguments(range.from, sevenDayMaturity)) addAll(maturedCohortArguments(range, 7))
addAll(maturedWindowArguments(range.from, sevenDayMaturity)) addAll(maturedCohortArguments(range, 7))
addAll(maturedWindowArguments(range.from, thirtyDayMaturity)) addAll(maturedCohortArguments(range, 30))
addAll(maturedWindowArguments(range.from, thirtyDayMaturity)) addAll(maturedCohortArguments(range, 30))
}, },
) { ) {
AdminAnalyticsMonetizationRow( AdminAnalyticsMonetizationRow(
@@ -483,7 +526,6 @@ class ExposedAdminProductAnalyticsRepository(
), ),
) )
} }
}
private fun loadGrowthFunnel(range: AdminAnalyticsWindow): AdminAnalyticsGrowthFunnelRow = private fun loadGrowthFunnel(range: AdminAnalyticsWindow): AdminAnalyticsGrowthFunnelRow =
querySingle( querySingle(
@@ -492,8 +534,22 @@ class ExposedAdminProductAnalyticsRepository(
SELECT installation_hash, MIN(occurred_at) AS opened_at SELECT installation_hash, MIN(occurred_at) AS opened_at
FROM product_analytics_events FROM product_analytics_events
WHERE event_name = 'FIRST_OPEN' WHERE event_name = 'FIRST_OPEN'
AND occurred_at >= ? AND occurred_at < ?
GROUP BY installation_hash GROUP BY installation_hash
HAVING opened_at >= ?
AND opened_at <= DATE_SUB(?, INTERVAL 24 HOUR)
),
registered AS (
SELECT
o.installation_hash,
o.opened_at,
i.account_id,
a.created_at AS registered_at
FROM first_open o
JOIN product_analytics_installations i
ON i.installation_hash = o.installation_hash
JOIN accounts a ON a.id = i.account_id
WHERE a.created_at >= o.opened_at
AND a.created_at <= DATE_ADD(o.opened_at, INTERVAL 24 HOUR)
), ),
client_values AS ( client_values AS (
SELECT installation_hash, occurred_at SELECT installation_hash, occurred_at
@@ -513,63 +569,42 @@ class ExposedAdminProductAnalyticsRepository(
), ),
activated AS ( activated AS (
SELECT SELECT
o.installation_hash, r.installation_hash,
o.opened_at, r.opened_at,
r.account_id,
MIN(v.occurred_at) AS first_value_at MIN(v.occurred_at) AS first_value_at
FROM first_open o FROM registered r
JOIN values_by_install v ON v.installation_hash = o.installation_hash JOIN values_by_install v ON v.installation_hash = r.installation_hash
WHERE v.occurred_at >= o.opened_at WHERE v.occurred_at >= r.registered_at
AND v.occurred_at <= DATE_ADD(o.opened_at, INTERVAL 24 HOUR) AND v.occurred_at <= DATE_ADD(r.opened_at, INTERVAL 24 HOUR)
GROUP BY o.installation_hash, o.opened_at GROUP BY r.installation_hash, r.opened_at, r.account_id
),
purchased AS (
SELECT DISTINCT a.installation_hash
FROM activated a
JOIN storekit_credit_purchases p ON p.user_id = a.account_id
WHERE p.purchased_at >= a.first_value_at
AND p.purchased_at <= DATE_ADD(a.opened_at, INTERVAL 24 HOUR)
) )
SELECT SELECT
(SELECT COUNT(*) FROM first_open) AS opened, (SELECT COUNT(*) FROM first_open) AS opened,
( (SELECT COUNT(*) FROM registered) AS registered,
SELECT COUNT(*)
FROM first_open o
JOIN product_analytics_installations i
ON i.installation_hash = o.installation_hash
WHERE i.account_id IS NOT NULL
) AS registered,
(SELECT COUNT(*) FROM activated) AS activated, (SELECT COUNT(*) FROM activated) AS activated,
( (SELECT COUNT(*) FROM purchased) AS purchased
SELECT COUNT(*)
FROM activated a
WHERE EXISTS (
SELECT 1
FROM values_by_install v
WHERE v.installation_hash = a.installation_hash
AND DATE(v.occurred_at) = DATE_ADD(DATE(a.first_value_at), INTERVAL 7 DAY)
)
) AS retained_d7,
(
SELECT COUNT(*)
FROM first_open o
JOIN product_analytics_installations i
ON i.installation_hash = o.installation_hash
WHERE EXISTS (
SELECT 1
FROM storekit_credit_purchases p
WHERE p.user_id = i.account_id
AND p.purchased_at >= o.opened_at
AND p.purchased_at < ?
)
) AS purchased
""", """,
range.arguments() + listOf(INSTANT_COLUMN_TYPE to range.until), range.arguments(),
) { ) {
AdminAnalyticsGrowthFunnelRow( AdminAnalyticsGrowthFunnelRow(
opened = it.exactLong("opened"), opened = it.exactLong("opened"),
registered = it.exactLong("registered"), registered = it.exactLong("registered"),
activated = it.exactLong("activated"), activated = it.exactLong("activated"),
retainedD7 = it.exactLong("retained_d7"),
purchased = it.exactLong("purchased"), purchased = it.exactLong("purchased"),
) )
} }
private fun loadRetention(range: AdminAnalyticsWindow): List<AdminAnalyticsCohortRow> = private fun loadRetention(range: AdminAnalyticsWindow): List<AdminAnalyticsCohortRow> =
queryRows( queryRows(
valueEventsCte() + identityValueEventsCte() +
""" """
, first_value_by_identity AS ( , first_value_by_identity AS (
SELECT identity_key, MIN(occurred_at) AS first_value_at SELECT identity_key, MIN(occurred_at) AS first_value_at
@@ -737,7 +772,7 @@ class ExposedAdminProductAnalyticsRepository(
private fun loadReferrals(range: AdminAnalyticsWindow): AdminAnalyticsReferralRow = private fun loadReferrals(range: AdminAnalyticsWindow): AdminAnalyticsReferralRow =
querySingle( querySingle(
valueEventsCte() + identityValueEventsCte() +
""" """
SELECT SELECT
( (
@@ -755,7 +790,7 @@ class ExposedAdminProductAnalyticsRepository(
SELECT COALESCE(SUM(counter_value), 0) SELECT COALESCE(SUM(counter_value), 0)
FROM product_analytics_daily_counters FROM product_analytics_daily_counters
WHERE counter_name = 'INVITE_PAGE_OPENED' WHERE counter_name = 'INVITE_PAGE_OPENED'
AND counter_date >= DATE(?) AND counter_date < DATE(?) AND counter_date >= DATE(?) AND counter_date <= DATE(?)
) AS opened, ) AS opened,
( (
SELECT COUNT(*) SELECT COUNT(*)
@@ -779,12 +814,14 @@ class ExposedAdminProductAnalyticsRepository(
FROM referral_bindings FROM referral_bindings
WHERE bound_at >= ? AND bound_at < ? WHERE bound_at >= ? AND bound_at < ?
AND reward_status = 'REWARDED' AND reward_status = 'REWARDED'
AND rewarded_at < ?
) AS rewarded ) AS rewarded
""", """,
buildList { buildList {
addAll(range.arguments(repetitions = 5)) addAll(range.arguments(repetitions = 5))
add(INSTANT_COLUMN_TYPE to range.until) add(INSTANT_COLUMN_TYPE to range.until)
addAll(range.arguments()) addAll(range.arguments())
add(INSTANT_COLUMN_TYPE to range.until)
}, },
) { ) {
AdminAnalyticsReferralRow( AdminAnalyticsReferralRow(
@@ -796,6 +833,91 @@ class ExposedAdminProductAnalyticsRepository(
) )
} }
private fun loadLatencyDistribution(range: AdminAnalyticsWindow): List<AdminAnalyticsLatencyRow> =
queryRows(
"""
SELECT
duration_bucket,
SUM(CASE WHEN event_name = 'AI_FEATURE_SUCCEEDED' THEN 1 ELSE 0 END) AS successful,
SUM(CASE WHEN event_name = 'AI_FEATURE_FAILED' THEN 1 ELSE 0 END) AS failed
FROM product_analytics_events
WHERE occurred_at >= ? AND occurred_at < ?
AND event_name IN ('AI_FEATURE_SUCCEEDED', 'AI_FEATURE_FAILED')
AND duration_bucket IS NOT NULL
GROUP BY duration_bucket
ORDER BY FIELD(
duration_bucket,
'LT_1S',
'S1_TO_3',
'S3_TO_10',
'S10_TO_30',
'GTE_30S'
)
""",
range.arguments(),
) {
AdminAnalyticsLatencyRow(
bucket = it.getString("duration_bucket"),
successful = it.exactLong("successful"),
failed = it.exactLong("failed"),
)
}
private fun loadPurchaseFunnel(range: AdminAnalyticsWindow): AdminAnalyticsPurchaseFunnelRow =
querySingle(
"""
WITH viewed AS (
SELECT installation_hash, MIN(occurred_at) AS viewed_at
FROM product_analytics_events
WHERE event_name = 'PURCHASE_VIEWED'
AND occurred_at >= ? AND occurred_at < ?
GROUP BY installation_hash
),
started AS (
SELECT v.installation_hash, MIN(e.occurred_at) AS started_at
FROM viewed v
JOIN product_analytics_events e
ON e.installation_hash = v.installation_hash
AND e.event_name = 'PURCHASE_STARTED'
AND e.occurred_at >= v.viewed_at
AND e.occurred_at < ?
GROUP BY v.installation_hash
),
verified AS (
SELECT DISTINCT s.installation_hash
FROM started s
JOIN product_analytics_installations i
ON i.installation_hash = s.installation_hash
JOIN storekit_credit_purchases p ON p.user_id = i.account_id
WHERE p.purchased_at >= s.started_at
AND p.purchased_at < ?
)
SELECT
(SELECT COUNT(*) FROM viewed) AS viewed,
(SELECT COUNT(*) FROM started) AS started,
(SELECT COUNT(*) FROM verified) AS verified,
(
SELECT COUNT(DISTINCT installation_hash)
FROM product_analytics_events
WHERE event_name = 'PURCHASE_CANCELLED'
AND occurred_at >= ? AND occurred_at < ?
) AS cancelled
""",
buildList {
addAll(range.arguments())
add(INSTANT_COLUMN_TYPE to range.until)
add(INSTANT_COLUMN_TYPE to range.until)
addAll(range.arguments())
},
) {
AdminAnalyticsPurchaseFunnelRow(
viewed = it.exactLong("viewed"),
started = it.exactLong("started"),
verified = it.exactLong("verified"),
cancelled = it.exactLong("cancelled"),
)
}
private fun loadGuardrails(range: AdminAnalyticsWindow): AdminAnalyticsGuardrailRow = private fun loadGuardrails(range: AdminAnalyticsWindow): AdminAnalyticsGuardrailRow =
querySingle( querySingle(
""" """
@@ -861,28 +983,6 @@ private data class ActivationRow(
val medianMinutes: Double?, val medianMinutes: Double?,
) )
private fun valueEventsCte(): String =
"""
WITH value_events AS (
SELECT
CONCAT('a:', user_id) AS identity_key,
created_at AS occurred_at
FROM credit_usage_records
UNION ALL
SELECT
COALESCE(
CONCAT('a:', i.account_id),
CONCAT('i:', e.installation_hash)
) AS identity_key,
e.occurred_at
FROM product_analytics_events e
JOIN product_analytics_installations i
ON i.installation_hash = e.installation_hash
WHERE e.event_name = 'AI_FEATURE_SUCCEEDED'
AND e.execution_mode IN ('LOCAL', 'BYOK')
)
""".trimIndent()
private fun AdminAnalyticsWindow.arguments( private fun AdminAnalyticsWindow.arguments(
repetitions: Int = 1, repetitions: Int = 1,
): List<Pair<IColumnType<*>, Any?>> = buildList { ): List<Pair<IColumnType<*>, Any?>> = buildList {
@@ -892,13 +992,13 @@ private fun AdminAnalyticsWindow.arguments(
} }
} }
private fun maturedWindowArguments( private fun maturedCohortArguments(
from: Instant, range: AdminAnalyticsWindow,
maturityEnd: Instant, observationDays: Long,
): List<Pair<IColumnType<*>, Any?>> = ): List<Pair<IColumnType<*>, Any?>> =
listOf( listOf(
INSTANT_COLUMN_TYPE to from, INSTANT_COLUMN_TYPE to range.from.minusSeconds(observationDays * DAY_SECONDS),
INSTANT_COLUMN_TYPE to maxOf(from, maturityEnd), INSTANT_COLUMN_TYPE to range.until.minusSeconds(observationDays * DAY_SECONDS),
) )
private fun <T> querySingle( private fun <T> querySingle(
@@ -114,8 +114,28 @@ class ExposedAdminStatsRepository(
) AS registrations, ) AS registrations,
( (
SELECT COUNT(DISTINCT user_id) SELECT COUNT(DISTINCT user_id)
FROM credit_usage_records FROM (
WHERE created_at >= ? AND created_at < ? SELECT user_id
FROM credit_usage_records u
JOIN accounts a ON a.id = u.user_id
WHERE u.created_at >= ? AND u.created_at < ?
UNION
SELECT i.account_id AS user_id
FROM product_analytics_events e
JOIN product_analytics_installations i
ON i.installation_hash = e.installation_hash
WHERE e.occurred_at >= ? AND e.occurred_at < ?
AND e.event_name = 'AI_FEATURE_SUCCEEDED'
AND e.execution_mode IN ('LOCAL', 'BYOK')
AND i.account_id IS NOT NULL
UNION
SELECT i.account_id AS user_id
FROM keyboard_usage_daily_summaries s
JOIN product_analytics_installations i
ON i.installation_hash = s.installation_hash
WHERE s.summary_date >= DATE(?) AND s.summary_date < DATE(?)
AND i.account_id IS NOT NULL
) registered_activity
) AS active_users, ) AS active_users,
( (
SELECT COALESCE(SUM(balance), 0) SELECT COALESCE(SUM(balance), 0)
@@ -134,7 +154,7 @@ class ExposedAdminStatsRepository(
WHERE created_at >= ? AND created_at < ? WHERE created_at >= ? AND created_at < ?
) AS consumed_credits ) AS consumed_credits
""", """,
range.arguments(repetitions = 4), range.arguments(repetitions = 6),
) { result -> ) { result ->
AdminOverviewDto( AdminOverviewDto(
totalUsers = result.exactLong("total_users"), totalUsers = result.exactLong("total_users"),
@@ -148,7 +168,13 @@ class ExposedAdminStatsRepository(
private fun loadReferralFunnel(range: AdminStatsRange): AdminReferralFunnelDto = private fun loadReferralFunnel(range: AdminStatsRange): AdminReferralFunnelDto =
querySingle( querySingle(
""" identityValueEventsCte() +
"""
, binding_cohort AS (
SELECT *
FROM referral_bindings
WHERE bound_at >= ? AND bound_at < ?
)
SELECT SELECT
( (
SELECT COUNT(*) SELECT COUNT(*)
@@ -157,33 +183,47 @@ class ExposedAdminStatsRepository(
) AS codes_created, ) AS codes_created,
( (
SELECT COUNT(*) SELECT COUNT(*)
FROM referral_bindings FROM binding_cohort
WHERE bound_at >= ? AND bound_at < ?
) AS bindings, ) AS bindings,
( (
SELECT COUNT(*) SELECT COUNT(*)
FROM referral_bindings FROM binding_cohort r
WHERE EXISTS (
SELECT 1
FROM value_events v
WHERE v.identity_key = CONCAT('a:', r.invitee_user_id)
AND v.occurred_at >= r.bound_at
AND v.occurred_at < ?
)
) AS activated_bindings,
(
SELECT COUNT(*)
FROM binding_cohort
WHERE reward_status = 'REWARDED' WHERE reward_status = 'REWARDED'
AND rewarded_at >= ? AND rewarded_at < ? AND rewarded_at < ?
) AS rewarded_bindings, ) AS rewarded_bindings,
( (
SELECT COUNT(*) SELECT COUNT(*)
FROM referral_bindings FROM binding_cohort
WHERE reward_status = 'PENDING' WHERE reward_status = 'PENDING'
AND bound_at >= ? AND bound_at < ?
) AS pending_bindings, ) AS pending_bindings,
( (
SELECT COUNT(*) SELECT COUNT(*)
FROM referral_bindings FROM binding_cohort
WHERE reward_status = 'INELIGIBLE_BUDGET' WHERE reward_status = 'INELIGIBLE_BUDGET'
AND bound_at >= ? AND bound_at < ?
) AS ineligible_bindings ) AS ineligible_bindings
""", """,
range.arguments(repetitions = 5), buildList {
addAll(range.arguments())
addAll(range.arguments())
add(INSTANT_COLUMN_TYPE to range.until)
add(INSTANT_COLUMN_TYPE to range.until)
},
) { result -> ) { result ->
AdminReferralFunnelDto( AdminReferralFunnelDto(
codesCreated = result.exactLong("codes_created"), codesCreated = result.exactLong("codes_created"),
bindings = result.exactLong("bindings"), bindings = result.exactLong("bindings"),
activatedBindings = result.exactLong("activated_bindings"),
rewardedBindings = result.exactLong("rewarded_bindings"), rewardedBindings = result.exactLong("rewarded_bindings"),
pendingBindings = result.exactLong("pending_bindings"), pendingBindings = result.exactLong("pending_bindings"),
ineligibleBindings = result.exactLong("ineligible_bindings"), ineligibleBindings = result.exactLong("ineligible_bindings"),
@@ -197,23 +237,19 @@ class ExposedAdminStatsRepository(
""" """
SELECT SELECT
inviter_user_id, inviter_user_id,
SUM(CASE WHEN bound_at >= ? AND bound_at < ? THEN 1 ELSE 0 END) AS invited_users, COUNT(*) AS invited_users,
SUM( SUM(
CASE CASE
WHEN reward_status = 'REWARDED' WHEN reward_status = 'REWARDED'
AND rewarded_at >= ? AND rewarded_at < ? AND rewarded_at < ?
THEN 1 ELSE 0 THEN 1 ELSE 0
END END
) AS rewarded_users ) AS rewarded_users
FROM referral_bindings FROM referral_bindings
WHERE (bound_at >= ? AND bound_at < ?) WHERE bound_at >= ? AND bound_at < ?
OR (
reward_status = 'REWARDED'
AND rewarded_at >= ? AND rewarded_at < ?
)
GROUP BY inviter_user_id GROUP BY inviter_user_id
""", """,
range.arguments(repetitions = 4), listOf(INSTANT_COLUMN_TYPE to range.until) + range.arguments(),
) { result -> ) { result ->
ReferralBindingAggregateRow( ReferralBindingAggregateRow(
inviterUserId = result.getString("inviter_user_id"), inviterUserId = result.getString("inviter_user_id"),
@@ -225,14 +261,19 @@ class ExposedAdminStatsRepository(
private fun loadReferralCreditsByInviter(range: AdminStatsRange): Map<String, Long> = private fun loadReferralCreditsByInviter(range: AdminStatsRange): Map<String, Long> =
queryRows( queryRows(
""" """
SELECT user_id, COALESCE(SUM(amount_delta), 0) AS earned_credits SELECT
FROM credit_ledger r.inviter_user_id AS user_id,
WHERE created_at >= ? AND created_at < ? COALESCE(SUM(l.amount_delta), 0) AS earned_credits
AND entry_type = 'REFERRAL_INVITER' FROM referral_bindings r
AND amount_delta > 0 JOIN credit_ledger l
GROUP BY user_id ON l.reference_id = r.id
AND l.entry_type = 'REFERRAL_INVITER'
AND l.amount_delta > 0
WHERE r.bound_at >= ? AND r.bound_at < ?
AND l.created_at < ?
GROUP BY r.inviter_user_id
""", """,
range.arguments(), range.arguments() + listOf(INSTANT_COLUMN_TYPE to range.until),
) { result -> ) { result ->
result.getString("user_id") to result.exactLong("earned_credits") result.getString("user_id") to result.exactLong("earned_credits")
}.toMap() }.toMap()
@@ -312,13 +353,21 @@ private fun <T> queryRows(
sql: String, sql: String,
arguments: List<Pair<IColumnType<*>, Any?>>, arguments: List<Pair<IColumnType<*>, Any?>>,
transform: (ResultSet) -> T, transform: (ResultSet) -> T,
): List<T> = TransactionManager.current().exec(sql.trimIndent(), arguments) { result -> ): List<T> {
buildList { val normalized = sql.trimIndent()
while (result.next()) { val executable = if (normalized.startsWith("WITH ", ignoreCase = true)) {
add(transform(result)) "SELECT * FROM (\n$normalized\n) AS admin_stats_result"
} } else {
normalized
} }
} ?: emptyList() return TransactionManager.current().exec(executable, arguments) { result ->
buildList {
while (result.next()) {
add(transform(result))
}
}
} ?: emptyList()
}
private fun ResultSet.exactLong(column: String): Long = private fun ResultSet.exactLong(column: String): Long =
requireNotNull(getBigDecimal(column)) { "Aggregate column $column must not be null" } requireNotNull(getBigDecimal(column)) { "Aggregate column $column must not be null" }
@@ -9,10 +9,12 @@ import com.osglab.account.features.admin.stats.models.AdminAnalyticsFunnelStepDt
import com.osglab.account.features.admin.stats.models.AdminAnalyticsGrowthDto import com.osglab.account.features.admin.stats.models.AdminAnalyticsGrowthDto
import com.osglab.account.features.admin.stats.models.AdminAnalyticsGuardrailsDto import com.osglab.account.features.admin.stats.models.AdminAnalyticsGuardrailsDto
import com.osglab.account.features.admin.stats.models.AdminAnalyticsKeyboardUsageDto import com.osglab.account.features.admin.stats.models.AdminAnalyticsKeyboardUsageDto
import com.osglab.account.features.admin.stats.models.AdminAnalyticsLatencyBucketDto
import com.osglab.account.features.admin.stats.models.AdminAnalyticsMonetizationDto import com.osglab.account.features.admin.stats.models.AdminAnalyticsMonetizationDto
import com.osglab.account.features.admin.stats.models.AdminAnalyticsNorthStarDto import com.osglab.account.features.admin.stats.models.AdminAnalyticsNorthStarDto
import com.osglab.account.features.admin.stats.models.AdminAnalyticsPeriodDto import com.osglab.account.features.admin.stats.models.AdminAnalyticsPeriodDto
import com.osglab.account.features.admin.stats.models.AdminAnalyticsRateDto import com.osglab.account.features.admin.stats.models.AdminAnalyticsRateDto
import com.osglab.account.features.admin.stats.models.AdminAnalyticsReferralSignalsDto
import com.osglab.account.features.admin.stats.models.AdminProductAnalyticsDto import com.osglab.account.features.admin.stats.models.AdminProductAnalyticsDto
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsCountRow import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsCountRow
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsWindow import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsWindow
@@ -100,13 +102,18 @@ class AdminProductAnalyticsService(
conversion7d = snapshot.monetization.conversion7d.toRate(), conversion7d = snapshot.monetization.conversion7d.toRate(),
conversion30d = snapshot.monetization.conversion30d.toRate(), conversion30d = snapshot.monetization.conversion30d.toRate(),
repeatPurchaseRate = snapshot.monetization.repeatPurchase.toRate(), repeatPurchaseRate = snapshot.monetization.repeatPurchase.toRate(),
purchaseFunnel = listOf(
AdminAnalyticsFunnelStepDto("浏览购买页", snapshot.purchaseFunnel.viewed),
AdminAnalyticsFunnelStepDto("发起购买", snapshot.purchaseFunnel.started),
AdminAnalyticsFunnelStepDto("StoreKit 验证完成", snapshot.purchaseFunnel.verified),
),
cancelledUsers = snapshot.purchaseFunnel.cancelled,
), ),
growthFunnel = listOf( growthFunnel = listOf(
AdminAnalyticsFunnelStepDto("首次启动", growth.opened), AdminAnalyticsFunnelStepDto("已完成 24h 观察的新安装", growth.opened),
AdminAnalyticsFunnelStepDto("完成注册", growth.registered), AdminAnalyticsFunnelStepDto("24 小时内完成注册", growth.registered),
AdminAnalyticsFunnelStepDto("24 小时内首次 AI 成功", growth.activated), AdminAnalyticsFunnelStepDto("24 小时内首次 AI 成功", growth.activated),
AdminAnalyticsFunnelStepDto("D7 再次使用 AI", growth.retainedD7), AdminAnalyticsFunnelStepDto("24 小时内完成首购", growth.purchased),
AdminAnalyticsFunnelStepDto("首次购买", growth.purchased),
), ),
retention = snapshot.retention.map { cohort -> retention = snapshot.retention.map { cohort ->
AdminAnalyticsCohortDto( AdminAnalyticsCohortDto(
@@ -160,17 +167,26 @@ class AdminProductAnalyticsService(
mixedLanguageSessions = keyboard.mixedLanguageSessions, mixedLanguageSessions = keyboard.mixedLanguageSessions,
otherOnlySessions = keyboard.otherOnlySessions, otherOnlySessions = keyboard.otherOnlySessions,
), ),
referralSignals = AdminAnalyticsReferralSignalsDto(
shared = referrals.shared,
opened = referrals.opened,
),
referralFunnel = listOf( referralFunnel = listOf(
AdminAnalyticsFunnelStepDto("发起分享", referrals.shared),
AdminAnalyticsFunnelStepDto("打开邀请", referrals.opened),
AdminAnalyticsFunnelStepDto("完成绑定", referrals.bound), AdminAnalyticsFunnelStepDto("完成绑定", referrals.bound),
AdminAnalyticsFunnelStepDto("首次 AI 成功", referrals.activated), AdminAnalyticsFunnelStepDto("绑定后首次 AI 成功", referrals.activated),
AdminAnalyticsFunnelStepDto("完成奖励", referrals.rewarded), AdminAnalyticsFunnelStepDto("完成奖励", referrals.rewarded),
), ),
guardrails = AdminAnalyticsGuardrailsDto( guardrails = AdminAnalyticsGuardrailsDto(
clientAiSuccessRate = snapshot.guardrails.clientSuccess.toRate(), clientAiSuccessRate = snapshot.guardrails.clientSuccess.toRate(),
managedSuccessRate = snapshot.guardrails.managedSuccess.toRate(), managedSuccessRate = snapshot.guardrails.managedSuccess.toRate(),
creditBlockedUsers = snapshot.guardrails.creditBlockedUsers, creditBlockedUsers = snapshot.guardrails.creditBlockedUsers,
latencyBuckets = snapshot.latencyDistribution.map {
AdminAnalyticsLatencyBucketDto(
bucket = it.bucket,
successful = it.successful,
failed = it.failed,
)
},
), ),
) )
} }
@@ -12,7 +12,7 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
data class AnalyticsBatchRequest( data class AnalyticsBatchRequest(
val installationId: String, val installationId: String? = null,
val events: List<AnalyticsEventRequest>, val events: List<AnalyticsEventRequest>,
) { ) {
override fun toString(): String = override fun toString(): String =
@@ -32,6 +32,8 @@ data class AnalyticsEventRequest(
val durationBucket: AnalyticsDurationBucket? = null, val durationBucket: AnalyticsDurationBucket? = null,
val appVersion: String? = null, val appVersion: String? = null,
val osVersion: String? = null, val osVersion: String? = null,
// Transitional compatibility for clients released before installationId moved to the batch.
val installationId: String? = null,
) { ) {
override fun toString(): String = "AnalyticsEventRequest([REDACTED])" override fun toString(): String = "AnalyticsEventRequest([REDACTED])"
} }
@@ -49,7 +49,7 @@ class DefaultAnalyticsService(
if (request.events.size !in MIN_BATCH_SIZE..MAX_BATCH_SIZE) { if (request.events.size !in MIN_BATCH_SIZE..MAX_BATCH_SIZE) {
throw InvalidRequestException("events must contain between 1 and 50 items") throw InvalidRequestException("events must contain between 1 and 50 items")
} }
val installationId = parseUuid(request.installationId, "installationId") val installationId = parseUuid(resolveInstallationId(request), "installationId")
val now = clock.instant() val now = clock.instant()
val events = request.events.map { validateAndMap(it, now) } val events = request.events.map { validateAndMap(it, now) }
return repository.ingest( return repository.ingest(
@@ -62,6 +62,25 @@ class DefaultAnalyticsService(
) )
} }
private fun resolveInstallationId(request: AnalyticsBatchRequest): String {
val batchInstallationId = request.installationId
val eventInstallationIds = request.events.map(AnalyticsEventRequest::installationId)
if (batchInstallationId == null) {
if (eventInstallationIds.any { it == null }) {
throw InvalidRequestException("installationId is required")
}
val distinctIds = eventInstallationIds.filterNotNull().toSet()
if (distinctIds.size != 1) {
throw InvalidRequestException("event installationId values must match")
}
return distinctIds.single()
}
if (eventInstallationIds.filterNotNull().any { it != batchInstallationId }) {
throw InvalidRequestException("event installationId must match the batch")
}
return batchInstallationId
}
override suspend fun ingestKeyboardUsage( override suspend fun ingestKeyboardUsage(
accountId: UUID?, accountId: UUID?,
request: KeyboardUsageBatchRequest, request: KeyboardUsageBatchRequest,
@@ -0,0 +1,3 @@
CREATE INDEX idx_referral_bindings_bound_at
ON referral_bindings (bound_at);
@@ -0,0 +1,26 @@
package com.osglab.account.features.admin.routes
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
class AdminStatsRangeTest : FunSpec({
val now = Instant.parse("2026-08-20T15:30:00Z")
val clock = Clock.fixed(now, ZoneOffset.UTC)
test("range presets cover exactly N UTC dates including the partial current date") {
parseAdminStatsRange("7d", clock) shouldBe
(Instant.parse("2026-08-14T00:00:00Z") to now)
parseAdminStatsRange("30d", clock) shouldBe
(Instant.parse("2026-07-22T00:00:00Z") to now)
parseAdminStatsRange("90d", clock) shouldBe
(Instant.parse("2026-05-23T00:00:00Z") to now)
}
test("unknown range is rejected") {
parseAdminStatsRange("31d", clock) shouldBe null
}
})
@@ -9,6 +9,8 @@ import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsGrowth
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsGuardrailRow import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsGuardrailRow
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsKeyboardUsageRow import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsKeyboardUsageRow
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsMonetizationRow import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsMonetizationRow
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsLatencyRow
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsPurchaseFunnelRow
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsReferralRow import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsReferralRow
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsWindow import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsWindow
import com.osglab.account.features.admin.stats.repositories.AdminProductAnalyticsRepository import com.osglab.account.features.admin.stats.repositories.AdminProductAnalyticsRepository
@@ -51,12 +53,14 @@ class AdminProductAnalyticsServiceTest : FunSpec({
result.retention.first().d7?.percent shouldBe 30.0 result.retention.first().d7?.percent shouldBe 30.0
result.retention.first().d30 shouldBe null result.retention.first().d30 shouldBe null
result.growthFunnel.map { it.label } shouldBe listOf( result.growthFunnel.map { it.label } shouldBe listOf(
"首次启动", "已完成 24h 观察的新安装",
"完成注册", "24 小时内完成注册",
"24 小时内首次 AI 成功", "24 小时内首次 AI 成功",
"D7 再次使用 AI", "24 小时内完成首购",
"首次购买",
) )
result.referralSignals.shared shouldBe 20
result.monetization.purchaseFunnel.last().count shouldBe 4
result.guardrails.latencyBuckets.single().successful shouldBe 7
captured.single().second.from shouldBe Instant.parse("2026-08-17T00:00:00Z") captured.single().second.from shouldBe Instant.parse("2026-08-17T00:00:00Z")
captured.single().third.from shouldBe Instant.parse("2026-08-10T00:00:00Z") captured.single().third.from shouldBe Instant.parse("2026-08-10T00:00:00Z")
} }
@@ -141,7 +145,7 @@ private fun snapshot(): AdminProductAnalyticsSnapshot =
conversion30d = AdminAnalyticsCountRow(10, 50), conversion30d = AdminAnalyticsCountRow(10, 50),
repeatPurchase = AdminAnalyticsCountRow(2, 10), repeatPurchase = AdminAnalyticsCountRow(2, 10),
), ),
growthFunnel = AdminAnalyticsGrowthFunnelRow(100, 80, 60, 20, 10), growthFunnel = AdminAnalyticsGrowthFunnelRow(100, 80, 60, 10),
retention = listOf( retention = listOf(
AdminAnalyticsCohortRow( AdminAnalyticsCohortRow(
cohortDate = LocalDate.parse("2026-08-01"), cohortDate = LocalDate.parse("2026-08-01"),
@@ -176,4 +180,13 @@ private fun snapshot(): AdminProductAnalyticsSnapshot =
managedSuccess = AdminAnalyticsCountRow(95, 100), managedSuccess = AdminAnalyticsCountRow(95, 100),
creditBlockedUsers = 3, creditBlockedUsers = 3,
), ),
latencyDistribution = listOf(
AdminAnalyticsLatencyRow("S1_TO_3", successful = 7, failed = 1),
),
purchaseFunnel = AdminAnalyticsPurchaseFunnelRow(
viewed = 12,
started = 8,
verified = 4,
cancelled = 2,
),
) )
@@ -95,7 +95,10 @@ class AdminStatsRepositoryIntegrationTest : FunSpec({
populatedStats.overview.grantedCredits shouldBeExactly 100 populatedStats.overview.grantedCredits shouldBeExactly 100
populatedStats.grantedCreditsByDate.values.single() shouldBeExactly 100 populatedStats.grantedCreditsByDate.values.single() shouldBeExactly 100
factory.query { seedProductAnalytics() } factory.query {
seedProductAnalytics()
seedAnalyticsCorrectness()
}
val populated = ExposedAdminProductAnalyticsRepository(factory).load( val populated = ExposedAdminProductAnalyticsRepository(factory).load(
range = AdminAnalyticsWindow( range = AdminAnalyticsWindow(
from = Instant.parse("2026-08-10T00:00:00Z"), from = Instant.parse("2026-08-10T00:00:00Z"),
@@ -111,19 +114,84 @@ class AdminStatsRepositoryIntegrationTest : FunSpec({
), ),
) )
populated.currentWeeklyUsers shouldBeExactly 1 populated.currentWeeklyUsers shouldBeExactly 4
populated.newInstallations shouldBeExactly 1 populated.newInstallations shouldBeExactly 4
populated.activation24h shouldBe populated.activation24h shouldBe
com.osglab.account.features.admin.stats.repositories.AdminAnalyticsCountRow(2, 3)
populated.periodActiveUsers shouldBeExactly 4
populated.successfulAiRequests shouldBeExactly 5
populated.features.single().successes shouldBeExactly 5
populated.retention
.first { it.cohortDate.toString() == "2026-08-11" }
.d1 shouldBeExactly 1
populated.keyboardUsage.activeUsers shouldBeExactly 3
populated.keyboardUsage.keyboardUsers shouldBeExactly 3
populated.keyboardUsage.chineseCharacters shouldBeExactly 140
populated.keyboardUsage.englishCharacters shouldBeExactly 80
populated.keyboardUsage.inputSessions shouldBeExactly 6
populated.growthFunnel.opened shouldBeExactly 3
populated.growthFunnel.registered shouldBeExactly 1
populated.growthFunnel.activated shouldBeExactly 1
populated.growthFunnel.purchased shouldBeExactly 1
populated.referrals.bound shouldBeExactly 1
populated.referrals.activated shouldBeExactly 1
populated.referrals.rewarded shouldBeExactly 1
populated.purchaseFunnel.viewed shouldBeExactly 1
populated.purchaseFunnel.started shouldBeExactly 1
populated.purchaseFunnel.verified shouldBeExactly 1
populated.purchaseFunnel.cancelled shouldBeExactly 1
populated.latencyDistribution.sumOf { it.successful } shouldBeExactly 5
val sevenDayMatured = ExposedAdminProductAnalyticsRepository(factory).load(
range = analyticsWindow(
"2026-08-17T12:00:00Z",
"2026-08-18T12:00:00Z",
),
currentWeek = analyticsWindow(
"2026-08-17T12:00:00Z",
"2026-08-18T12:00:00Z",
),
previousWeek = analyticsWindow(
"2026-08-10T12:00:00Z",
"2026-08-11T12:00:00Z",
),
)
// Account 600...001 completes its seven-day observation window
// inside this report period, despite registering a week earlier.
sevenDayMatured.monetization.conversion7d shouldBe
com.osglab.account.features.admin.stats.repositories.AdminAnalyticsCountRow(1, 1) com.osglab.account.features.admin.stats.repositories.AdminAnalyticsCountRow(1, 1)
populated.periodActiveUsers shouldBeExactly 1
populated.successfulAiRequests shouldBeExactly 2 val thirtyDayMatured = ExposedAdminProductAnalyticsRepository(factory).load(
populated.features.single().successes shouldBeExactly 2 range = analyticsWindow(
populated.retention.single().d1 shouldBeExactly 1 "2026-09-09T12:00:00Z",
populated.keyboardUsage.activeUsers shouldBeExactly 1 "2026-09-10T12:00:00Z",
populated.keyboardUsage.keyboardUsers shouldBeExactly 1 ),
populated.keyboardUsage.chineseCharacters shouldBeExactly 100 currentWeek = analyticsWindow(
populated.keyboardUsage.englishCharacters shouldBeExactly 50 "2026-09-09T12:00:00Z",
populated.keyboardUsage.inputSessions shouldBeExactly 4 "2026-09-10T12:00:00Z",
),
previousWeek = analyticsWindow(
"2026-09-02T12:00:00Z",
"2026-09-03T12:00:00Z",
),
)
thirtyDayMatured.monetization.conversion30d shouldBe
com.osglab.account.features.admin.stats.repositories.AdminAnalyticsCountRow(1, 1)
val overview = ExposedAdminStatsRepository(factory).load(
AdminStatsRange(
from = Instant.parse("2026-08-10T00:00:00Z"),
until = Instant.parse("2026-08-17T00:00:00Z"),
),
)
overview.overview.totalUsers shouldBeExactly 3
overview.overview.activeUsers shouldBeExactly 2
overview.referralFunnel.bindings shouldBeExactly 1
overview.referralFunnel.activatedBindings shouldBeExactly 1
overview.referralFunnel.rewardedBindings shouldBeExactly 1
overview.referralRanking.single().invitedUsers shouldBeExactly 1
overview.referralRanking.single().rewardedUsers shouldBeExactly 1
overview.referralRanking.single().earnedCredits shouldBeExactly 25
} }
} finally { } finally {
factory.close() factory.close()
@@ -222,3 +290,233 @@ private fun seedProductAnalytics() {
""".trimIndent(), """.trimIndent(),
) )
} }
private fun seedAnalyticsCorrectness() {
TransactionManager.current().exec(
"""
INSERT INTO accounts (id, apple_sub, created_at, updated_at) VALUES
(
'60000000-0000-0000-0000-000000000001', 'stats-apple-1',
'2026-08-11 00:05:00.000000', '2026-08-11 00:05:00.000000'
),
(
'60000000-0000-0000-0000-000000000002', 'stats-apple-2',
'2026-08-10 00:00:00.000000', '2026-08-10 00:00:00.000000'
),
(
'60000000-0000-0000-0000-000000000003', 'stats-apple-3',
'2026-08-10 00:00:00.000000', '2026-08-10 00:00:00.000000'
)
""".trimIndent(),
)
TransactionManager.current().exec(
"""
INSERT INTO product_analytics_installations (
installation_hash, account_id, created_at, updated_at
) VALUES
(
'${"b".repeat(64)}', '60000000-0000-0000-0000-000000000001',
'2026-08-11 00:00:00.000000', '2026-08-11 00:20:00.000000'
),
(
'${"c".repeat(64)}', '60000000-0000-0000-0000-000000000002',
'2026-08-11 00:00:00.000000', '2026-08-11 00:20:00.000000'
),
(
'${"d".repeat(64)}', NULL,
'2026-08-16 18:00:00.000000', '2026-08-16 18:10:00.000000'
),
(
'${"f".repeat(64)}', NULL,
'2026-08-10 23:50:00.000000', '2026-08-11 00:00:00.000000'
)
""".trimIndent(),
)
listOf(
eventValues("b", "101", "FIRST_OPEN", "2026-08-11 00:00:00", channel = "REFERRAL"),
eventValues(
"b",
"102",
"AI_FEATURE_SUCCEEDED",
"2026-08-11 00:10:00",
feature = "POLISH",
executionMode = "LOCAL",
durationBucket = "LT_1S",
),
eventValues("b", "103", "PURCHASE_VIEWED", "2026-08-11 00:12:00"),
eventValues("b", "104", "PURCHASE_STARTED", "2026-08-11 00:13:00"),
eventValues(
"b",
"105",
"PURCHASE_CANCELLED",
"2026-08-11 00:13:30",
failureCategory = "CANCELLED",
),
eventValues("d", "106", "FIRST_OPEN", "2026-08-16 18:00:00"),
eventValues(
"d",
"107",
"AI_FEATURE_SUCCEEDED",
"2026-08-16 18:10:00",
feature = "POLISH",
executionMode = "LOCAL",
durationBucket = "S3_TO_10",
),
eventValues(
"f",
"108",
"AI_FEATURE_SUCCEEDED",
"2026-08-10 23:50:00",
feature = "POLISH",
executionMode = "LOCAL",
durationBucket = "S1_TO_3",
),
eventValues("f", "109", "FIRST_OPEN", "2026-08-11 00:00:00"),
).forEach { values ->
TransactionManager.current().exec(
"""
INSERT INTO product_analytics_events (
installation_hash, client_event_id, event_name, occurred_at, surface,
acquisition_channel, feature, execution_mode, failure_category,
duration_bucket, app_version, os_version, payload_hash, received_at
) VALUES $values
""".trimIndent(),
)
}
TransactionManager.current().exec(
"""
INSERT INTO keyboard_usage_daily_summaries (
installation_hash, client_summary_id, summary_date,
chinese_character_count, english_character_count, other_character_count,
input_session_count, chinese_only_session_count, english_only_session_count,
mixed_language_session_count, other_only_session_count,
app_version, os_version, payload_hash, received_at
) VALUES
(
'${"b".repeat(64)}', '60000000-0000-0000-0000-000000000101', '2026-08-11',
20, 10, 0, 1, 0, 0, 1, 0,
'1.0', '18.6', '${"b".repeat(64)}', '2026-08-12 00:01:00.000000'
),
(
'${"c".repeat(64)}', '60000000-0000-0000-0000-000000000102', '2026-08-11',
20, 20, 0, 1, 0, 0, 1, 0,
'1.0', '18.6', '${"c".repeat(64)}', '2026-08-12 00:01:00.000000'
)
""".trimIndent(),
)
TransactionManager.current().exec(
"""
INSERT INTO referral_codes (id, owner_user_id, code, created_at)
VALUES (
'60000000-0000-0000-0000-000000000110',
'60000000-0000-0000-0000-000000000002',
'STATS-CODE',
'2026-08-11 00:06:00.000000'
)
""".trimIndent(),
)
TransactionManager.current().exec(
"""
INSERT INTO referral_bindings (
id, inviter_user_id, invitee_user_id, code_id, bound_at,
rewarded_at, reward_settlement_id, reward_status
) VALUES
(
'60000000-0000-0000-0000-000000000111',
'60000000-0000-0000-0000-000000000002',
'60000000-0000-0000-0000-000000000001',
'60000000-0000-0000-0000-000000000110',
'2026-08-11 00:07:00.000000',
'2026-08-11 00:20:00.000000',
'60000000-0000-0000-0000-000000000112',
'REWARDED'
),
(
'60000000-0000-0000-0000-000000000113',
'60000000-0000-0000-0000-000000000002',
'60000000-0000-0000-0000-000000000003',
'60000000-0000-0000-0000-000000000110',
'2026-08-09 00:07:00.000000',
'2026-08-11 00:20:00.000000',
'60000000-0000-0000-0000-000000000114',
'REWARDED'
)
""".trimIndent(),
)
TransactionManager.current().exec(
"""
INSERT INTO credit_ledger (
id, user_id, entry_type, amount_delta, balance_after,
idempotency_key, reference_id, created_at
) VALUES
(
'60000000-0000-0000-0000-000000000201',
'60000000-0000-0000-0000-000000000002',
'REFERRAL_INVITER', 25, 25, 'stats-referral-credit',
'60000000-0000-0000-0000-000000000111',
'2026-08-11 00:20:00.000000'
),
(
'60000000-0000-0000-0000-000000000202',
'60000000-0000-0000-0000-000000000001',
'STOREKIT_PURCHASE', 100, 100, 'stats-storekit-credit',
'60000000-0000-0000-0000-000000000203',
'2026-08-11 00:14:00.000000'
)
""".trimIndent(),
)
TransactionManager.current().exec(
"""
INSERT INTO storekit_credit_purchases (
id, transaction_id, original_transaction_id, user_id, app_account_token,
product_id, environment, credits_granted, ledger_entry_id,
signed_transaction_sha256, purchased_at, signed_at, created_at
) VALUES (
'60000000-0000-0000-0000-000000000203',
'stats-transaction', 'stats-original',
'60000000-0000-0000-0000-000000000001',
'60000000-0000-0000-0000-000000000001',
'com.osglab.credits.test', 'SANDBOX', 100,
'60000000-0000-0000-0000-000000000202',
'${"9".repeat(64)}',
'2026-08-11 00:14:00.000000',
'2026-08-11 00:14:00.000000',
'2026-08-11 00:14:00.000000'
)
""".trimIndent(),
)
}
private fun analyticsWindow(
from: String,
until: String,
): AdminAnalyticsWindow =
AdminAnalyticsWindow(
from = Instant.parse(from),
until = Instant.parse(until),
)
private fun eventValues(
hashCharacter: String,
eventSuffix: String,
eventName: String,
occurredAt: String,
channel: String? = null,
feature: String? = null,
executionMode: String? = null,
failureCategory: String? = null,
durationBucket: String? = null,
): String {
val quoted = { value: String? -> value?.let { "'$it'" } ?: "NULL" }
return """
(
'${hashCharacter.repeat(64)}',
'60000000-0000-0000-0000-000000000$eventSuffix',
'$eventName', '$occurredAt.000000', 'APP',
${quoted(channel)}, ${quoted(feature)}, ${quoted(executionMode)},
${quoted(failureCategory)}, ${quoted(durationBucket)},
'1.0', '18.6', '${eventSuffix.padStart(64, '0')}',
'$occurredAt.000001'
)
""".trimIndent()
}
@@ -52,6 +52,7 @@ class AdminStatsRepositoryTest : FunSpec({
referralFunnel = AdminReferralFunnelDto( referralFunnel = AdminReferralFunnelDto(
codesCreated = 5, codesCreated = 5,
bindings = 7, bindings = 7,
activatedBindings = 5,
rewardedBindings = 4, rewardedBindings = 4,
pendingBindings = 2, pendingBindings = 2,
ineligibleBindings = 1, ineligibleBindings = 1,
@@ -111,6 +112,7 @@ class AdminStatsRepositoryTest : FunSpec({
referralFunnel = AdminReferralFunnelDto( referralFunnel = AdminReferralFunnelDto(
codesCreated = 0, codesCreated = 0,
bindings = 0, bindings = 0,
activatedBindings = 0,
rewardedBindings = 0, rewardedBindings = 0,
pendingBindings = 3, pendingBindings = 3,
ineligibleBindings = 2, ineligibleBindings = 2,
@@ -182,7 +184,7 @@ class AdminStatsRepositoryTest : FunSpec({
registrationsByDate = emptyMap(), registrationsByDate = emptyMap(),
grantedCreditsByDate = emptyMap(), grantedCreditsByDate = emptyMap(),
consumedCreditsByDate = emptyMap(), consumedCreditsByDate = emptyMap(),
referralFunnel = AdminReferralFunnelDto(0, 0, 0, 0, 0), referralFunnel = AdminReferralFunnelDto(0, 0, 0, 0, 0, 0),
referralRanking = listOf( referralRanking = listOf(
AdminReferralRankDto("user-c", 2, 1, 20), AdminReferralRankDto("user-c", 2, 1, 20),
AdminReferralRankDto("user-a", 3, 1, 20), AdminReferralRankDto("user-a", 3, 1, 20),
@@ -108,8 +108,9 @@ class AnalyticsRepositoryIntegrationTest : FunSpec({
} }
concurrentResults.sumOf(AnalyticsIngestResult::accepted) shouldBe 1 concurrentResults.sumOf(AnalyticsIngestResult::accepted) shouldBe 1
concurrentResults.sumOf(AnalyticsIngestResult::replayed) shouldBe 7 concurrentResults.sumOf(AnalyticsIngestResult::replayed) shouldBe 7
val concurrentInstallationId = requireNotNull(concurrentRequest.installationId)
val concurrentKeyboardUsage = KeyboardUsageBatchRequest( val concurrentKeyboardUsage = KeyboardUsageBatchRequest(
installationId = concurrentRequest.installationId, installationId = concurrentInstallationId,
summaries = listOf( summaries = listOf(
keyboardSummary().copy( keyboardSummary().copy(
clientSummaryId = "50000000-0000-0000-0000-000000000099", clientSummaryId = "50000000-0000-0000-0000-000000000099",
@@ -135,14 +136,14 @@ class AnalyticsRepositoryIntegrationTest : FunSpec({
keyboardSummaryCount(config) shouldBe 1 keyboardSummaryCount(config) shouldBe 1
markInstallationUpdatedAt( markInstallationUpdatedAt(
config, config,
concurrentRequest.installationId.sha256Hex(), concurrentInstallationId.sha256Hex(),
now.minusSeconds(91L * 24 * 60 * 60), now.minusSeconds(91L * 24 * 60 * 60),
) )
repository.purgeAnonymousInstallations( repository.purgeAnonymousInstallations(
before = now.minusSeconds(90L * 24 * 60 * 60), before = now.minusSeconds(90L * 24 * 60 * 60),
limit = 100, limit = 100,
) shouldBe 1 ) shouldBe 1
installationCount(config, concurrentRequest.installationId.sha256Hex()) shouldBe 0 installationCount(config, concurrentInstallationId.sha256Hex()) shouldBe 0
shouldThrow<ConflictException> { shouldThrow<ConflictException> {
service.ingest( service.ingest(
@@ -129,18 +129,7 @@ class AnalyticsRoutesTest {
} }
private fun validBody(): String = private fun validBody(): String =
""" checkNotNull(javaClass.getResource("/contracts/analytics-events-v1.json")).readText()
{
"installationId":"$INSTALLATION_ID",
"events":[{
"clientEventId":"40000000-0000-0000-0000-000000000001",
"eventType":"SESSION_STARTED",
"occurredAt":"2026-08-20T01:00:00Z",
"surface":"APP",
"appVersion":"1.0"
}]
}
""".trimIndent()
private fun validKeyboardUsageBody(): String = private fun validKeyboardUsageBody(): String =
""" """
@@ -54,6 +54,47 @@ class AnalyticsServiceTest {
request.toString() shouldNotContain firstOpen().clientEventId request.toString() shouldNotContain firstOpen().clientEventId
} }
@Test
fun `legacy event installation IDs are accepted only when the whole batch agrees`(): Unit =
kotlinx.coroutines.runBlocking {
val repository = InMemoryAnalyticsRepository()
val service = service(repository)
val legacyEvents = listOf(
sessionStarted(uuid(70)).copy(installationId = installationId),
sessionStarted(uuid(71)).copy(installationId = installationId),
)
service.ingest(
accountId = null,
request = AnalyticsBatchRequest(events = legacyEvents),
) shouldBe AnalyticsIngestResult(accepted = 2, replayed = 0)
repository.lastBatch?.installationHash shouldBe installationId.sha256Hex()
listOf(
AnalyticsBatchRequest(events = listOf(sessionStarted(uuid(72)))),
AnalyticsBatchRequest(
events = listOf(
sessionStarted(uuid(73)).copy(installationId = installationId),
sessionStarted(uuid(74)).copy(
installationId = "10000000-0000-0000-0000-000000000002"
),
),
),
AnalyticsBatchRequest(
installationId = installationId,
events = listOf(
sessionStarted(uuid(75)).copy(
installationId = "10000000-0000-0000-0000-000000000002"
)
),
),
).forEach { invalid ->
shouldThrow<InvalidRequestException> {
service.ingest(null, invalid)
}.code shouldBe "invalid_request"
}
}
@Test @Test
fun `authenticated ingestion links an anonymous installation and rejects another account`(): Unit = fun `authenticated ingestion links an anonymous installation and rejects another account`(): Unit =
kotlinx.coroutines.runBlocking { kotlinx.coroutines.runBlocking {
@@ -0,0 +1,12 @@
{
"installationId": "10000000-0000-0000-0000-000000000001",
"events": [
{
"clientEventId": "40000000-0000-0000-0000-000000000001",
"eventType": "SESSION_STARTED",
"occurredAt": "2026-08-20T01:00:00Z",
"surface": "APP",
"appVersion": "1.0"
}
]
}