Correct product analytics cohorts and reporting
This commit is contained in:
@@ -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: "1–3 秒",
|
||||||
|
S3_TO_10: "3–10 秒",
|
||||||
|
S10_TO_30: "10–30 秒",
|
||||||
|
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 />
|
||||||
|
|||||||
@@ -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 }],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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>,
|
||||||
|
|||||||
+17
@@ -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,
|
||||||
|
|||||||
+29
@@ -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()
|
||||||
|
|
||||||
+205
-105
@@ -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(
|
||||||
|
|||||||
+83
-34
@@ -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" }
|
||||||
|
|||||||
+23
-7
@@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
+18
-5
@@ -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,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
+310
-12
@@ -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),
|
||||||
|
|||||||
+4
-3
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user