Add privacy-safe product analytics
Establish an idempotent analytics pipeline and internal decision dashboard while keeping event metadata allowlisted and account deletion enforceable.
This commit is contained in:
@@ -8,7 +8,10 @@ Kotlin 2.4.10 / Ktor 3.5.2 managed AI gateway and invitation page.
|
|||||||
- Volcengine SAUC v3 ASR over WebSocket with a strict binary frame codec.
|
- Volcengine SAUC v3 ASR over WebSocket with a strict binary frame codec.
|
||||||
- Credit reservation, settlement, release, identity, grant, and metadata persistence ports.
|
- Credit reservation, settlement, release, identity, grant, and metadata persistence ports.
|
||||||
- Apple DeviceCheck trial enforcement and App Attest attestation/assertion validation.
|
- Apple DeviceCheck trial enforcement and App Attest attestation/assertion validation.
|
||||||
- Bilingual invitation page at `GET /i/{code}` with no analytics, tracking, or fingerprinting.
|
- Bilingual invitation page at `GET /i/{code}` with no third-party analytics,
|
||||||
|
tracking scripts, or fingerprinting.
|
||||||
|
- Privacy-minimized, idempotent product event ingestion and an internal growth,
|
||||||
|
retention, AI usage, monetization, and referral dashboard.
|
||||||
- Flyway migrations for provider metadata, metered usage, gateway grants, and integrity state.
|
- Flyway migrations for provider metadata, metered usage, gateway grants, and integrity state.
|
||||||
|
|
||||||
Prompts, audio, transcripts, and provider response bodies are never sent to the usage persistence port.
|
Prompts, audio, transcripts, and provider response bodies are never sent to the usage persistence port.
|
||||||
@@ -26,6 +29,7 @@ Application logging must also keep request/response body logging disabled.
|
|||||||
- `POST /v1/integrity/assert`
|
- `POST /v1/integrity/assert`
|
||||||
- `POST /v1/auth/apple`
|
- `POST /v1/auth/apple`
|
||||||
- `POST /v1/auth/refresh`, `POST /v1/auth/logout`
|
- `POST /v1/auth/refresh`, `POST /v1/auth/logout`
|
||||||
|
- `POST /v1/analytics/events`
|
||||||
- `GET/DELETE /v1/account`
|
- `GET/DELETE /v1/account`
|
||||||
- `POST /v1/apple/events`
|
- `POST /v1/apple/events`
|
||||||
- `GET /v1/credits/{balance|ledger|rates}`
|
- `GET /v1/credits/{balance|ledger|rates}`
|
||||||
@@ -34,6 +38,7 @@ Application logging must also keep request/response body logging disabled.
|
|||||||
- `GET /i/{code}`
|
- `GET /i/{code}`
|
||||||
- `GET /.well-known/apple-app-site-association`
|
- `GET /.well-known/apple-app-site-association`
|
||||||
- `GET /apple-app-site-association`
|
- `GET /apple-app-site-association`
|
||||||
|
- `GET /v1/admin/analytics` through the private mTLS-protected admin edge
|
||||||
|
|
||||||
See `docs/openapi.yaml` for request limits and response formats.
|
See `docs/openapi.yaml` for request limits and response formats.
|
||||||
|
|
||||||
@@ -298,9 +303,13 @@ Gateway tests cover account-scoped replay exclusion, cross-account isolation, pr
|
|||||||
settlement pending behavior, reconciliation, whole-call timeout, forged audio duration, malformed SAUC
|
settlement pending behavior, reconciliation, whole-call timeout, forged audio duration, malformed SAUC
|
||||||
sequences, and final-frame-only duration.
|
sequences, and final-frame-only duration.
|
||||||
|
|
||||||
The MySQL Testcontainers suite validates all migrations, account-deletion cascades, stale access-token
|
The MySQL Testcontainers suite validates all migrations, account-deletion
|
||||||
rejection, real InnoDB concurrent balance locking, and exactly-once referral rewards. It is automatically
|
cascades (including linked analytics events), stale access-token rejection, real
|
||||||
skipped when Docker is unavailable; CI requires Docker and runs it on every build.
|
InnoDB concurrent balance locking, and exactly-once referral rewards. Analytics
|
||||||
|
tests additionally cover event validation, idempotent replay, conflict handling,
|
||||||
|
offline timestamps, and privacy-minimized responses. It is automatically
|
||||||
|
skipped when Docker is unavailable; CI requires Docker and runs it on every
|
||||||
|
build.
|
||||||
|
|
||||||
## Staged acceptance
|
## Staged acceptance
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
LedgerEntry,
|
LedgerEntry,
|
||||||
Overview,
|
Overview,
|
||||||
PageResult,
|
PageResult,
|
||||||
|
ProductAnalyticsOverview,
|
||||||
ReferralOverview,
|
ReferralOverview,
|
||||||
SessionResponse,
|
SessionResponse,
|
||||||
UserDetail,
|
UserDetail,
|
||||||
@@ -165,6 +166,9 @@ export const adminApi = {
|
|||||||
referrals: (range: string) =>
|
referrals: (range: string) =>
|
||||||
request<ReferralOverview>(`/referrals${query({ range })}`),
|
request<ReferralOverview>(`/referrals${query({ range })}`),
|
||||||
|
|
||||||
|
productAnalytics: (range: string) =>
|
||||||
|
request<ProductAnalyticsOverview>(`/analytics${query({ range })}`),
|
||||||
|
|
||||||
users: (search = "", cursor?: string) =>
|
users: (search = "", cursor?: string) =>
|
||||||
request<PageResult<UserSummary>>(
|
request<PageResult<UserSummary>>(
|
||||||
`/users${query({ q: search.trim(), cursor })}`,
|
`/users${query({ q: search.trim(), cursor })}`,
|
||||||
|
|||||||
@@ -52,6 +52,84 @@ export interface ReferralOverview {
|
|||||||
ranking: ReferralRankingItem[];
|
ranking: ReferralRankingItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsRate {
|
||||||
|
numerator: number;
|
||||||
|
denominator: number;
|
||||||
|
percent?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsChannel {
|
||||||
|
channel: string;
|
||||||
|
installations: number;
|
||||||
|
activated: number;
|
||||||
|
activationRate: AnalyticsRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsCohort {
|
||||||
|
cohortDate: string;
|
||||||
|
size: number;
|
||||||
|
d1?: AnalyticsRate;
|
||||||
|
d7?: AnalyticsRate;
|
||||||
|
d30?: AnalyticsRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsFeatureUsage {
|
||||||
|
feature: string;
|
||||||
|
executionMode: string;
|
||||||
|
users: number;
|
||||||
|
successes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductAnalyticsOverview {
|
||||||
|
period: {
|
||||||
|
from: string;
|
||||||
|
until: string;
|
||||||
|
};
|
||||||
|
northStar: {
|
||||||
|
weeklyAiActiveUsers: number;
|
||||||
|
previousWeeklyAiActiveUsers: number;
|
||||||
|
weekOverWeekPercent?: number;
|
||||||
|
};
|
||||||
|
growth: {
|
||||||
|
newInstallations: number;
|
||||||
|
newAccounts: number;
|
||||||
|
activation24h: AnalyticsRate;
|
||||||
|
medianTimeToValueMinutes?: number;
|
||||||
|
channels: AnalyticsChannel[];
|
||||||
|
};
|
||||||
|
activity: {
|
||||||
|
dau: number;
|
||||||
|
wau: number;
|
||||||
|
mau: number;
|
||||||
|
stickinessPercent?: number;
|
||||||
|
successfulAiRequests: number;
|
||||||
|
successfulRequestsPerActiveUser?: number;
|
||||||
|
};
|
||||||
|
consumption: {
|
||||||
|
totalCredits: number;
|
||||||
|
averageDailyCreditsPerActiveUser?: number;
|
||||||
|
medianUserDailyCredits?: number;
|
||||||
|
averageCreditsPerManagedRequest?: number;
|
||||||
|
};
|
||||||
|
monetization: {
|
||||||
|
payingUsers: number;
|
||||||
|
purchases: number;
|
||||||
|
creditsPurchased: number;
|
||||||
|
conversion7d: AnalyticsRate;
|
||||||
|
conversion30d: AnalyticsRate;
|
||||||
|
repeatPurchaseRate: AnalyticsRate;
|
||||||
|
};
|
||||||
|
growthFunnel: FunnelStep[];
|
||||||
|
retention: AnalyticsCohort[];
|
||||||
|
aiFeatures: AnalyticsFeatureUsage[];
|
||||||
|
referralFunnel: FunnelStep[];
|
||||||
|
guardrails: {
|
||||||
|
clientAiSuccessRate: AnalyticsRate;
|
||||||
|
managedSuccessRate: AnalyticsRate;
|
||||||
|
creditBlockedUsers: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserSummary {
|
export interface UserSummary {
|
||||||
userId: string;
|
userId: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
BookOpenCheck,
|
BookOpenCheck,
|
||||||
|
ChartNoAxesCombined,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Coins,
|
Coins,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
@@ -49,6 +50,11 @@ const ReferralsPage = lazy(() =>
|
|||||||
default: module.ReferralsPage,
|
default: module.ReferralsPage,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
const AnalyticsPage = lazy(() =>
|
||||||
|
import("./features/analytics/analytics-page").then((module) => ({
|
||||||
|
default: module.AnalyticsPage,
|
||||||
|
})),
|
||||||
|
);
|
||||||
const UsersPage = lazy(() =>
|
const UsersPage = lazy(() =>
|
||||||
import("./features/users/users-page").then((module) => ({
|
import("./features/users/users-page").then((module) => ({
|
||||||
default: module.UsersPage,
|
default: module.UsersPage,
|
||||||
@@ -89,6 +95,13 @@ const navigation: NavItem[] = [
|
|||||||
icon: Activity,
|
icon: Activity,
|
||||||
roles: allRoles,
|
roles: allRoles,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/analytics",
|
||||||
|
label: "产品分析",
|
||||||
|
description: "增长、留存与付费",
|
||||||
|
icon: ChartNoAxesCombined,
|
||||||
|
roles: allRoles,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/referrals",
|
path: "/referrals",
|
||||||
label: "裂变分析",
|
label: "裂变分析",
|
||||||
@@ -185,6 +198,7 @@ function AuthenticatedApp({ role }: { role: AdminRole }) {
|
|||||||
<Suspense fallback={<LoadingState label="加载页面" />}>
|
<Suspense fallback={<LoadingState label="加载页面" />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/overview" element={<OverviewPage />} />
|
<Route path="/overview" element={<OverviewPage />} />
|
||||||
|
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||||
<Route path="/referrals" element={<ReferralsPage />} />
|
<Route path="/referrals" element={<ReferralsPage />} />
|
||||||
{supportRoles.includes(role) ? (
|
{supportRoles.includes(role) ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
const ranges = [
|
||||||
|
{ value: "7d", label: "7 天" },
|
||||||
|
{ value: "30d", label: "30 天" },
|
||||||
|
{ value: "90d", label: "90 天" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function RangeControl({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex rounded-xl border border-border bg-surface-muted p-1">
|
||||||
|
{ranges.map((range) => (
|
||||||
|
<button
|
||||||
|
key={range.value}
|
||||||
|
className={`min-h-8 rounded-lg px-3 text-xs font-semibold transition ${
|
||||||
|
value === range.value
|
||||||
|
? "bg-surface text-foreground shadow-sm"
|
||||||
|
: "text-muted hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
onClick={() => onChange(range.value)}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={value === range.value}
|
||||||
|
>
|
||||||
|
{range.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
BadgeDollarSign,
|
||||||
|
BrainCircuit,
|
||||||
|
ChartNoAxesCombined,
|
||||||
|
Gauge,
|
||||||
|
Repeat2,
|
||||||
|
Sparkles,
|
||||||
|
Target,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { adminApi } from "../../api/client";
|
||||||
|
import type {
|
||||||
|
AnalyticsRate,
|
||||||
|
FunnelStep,
|
||||||
|
ProductAnalyticsOverview,
|
||||||
|
} from "../../api/types";
|
||||||
|
import { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives";
|
||||||
|
import { RangeControl } from "../../components/range-control";
|
||||||
|
import { formatNumber } from "../../lib/format";
|
||||||
|
|
||||||
|
export function AnalyticsPage() {
|
||||||
|
const [range, setRange] = useState("30d");
|
||||||
|
const [data, setData] = useState<ProductAnalyticsOverview>();
|
||||||
|
const [error, setError] = useState<unknown>();
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setError(undefined);
|
||||||
|
try {
|
||||||
|
setData(await adminApi.productAnalytics(range));
|
||||||
|
} catch (requestError) {
|
||||||
|
setError(requestError);
|
||||||
|
}
|
||||||
|
}, [range]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
if (error) return <ErrorState error={error} retry={() => void load()} />;
|
||||||
|
if (!data) return <LoadingState label="加载产品数据" />;
|
||||||
|
|
||||||
|
const weeklyGrowth = data.northStar.weekOverWeekPercent;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="CEO Dashboard"
|
||||||
|
title="产品增长与留存"
|
||||||
|
description="围绕成功使用 AI 的核心价值事件,观察增长质量、留存、消耗与付费。"
|
||||||
|
actions={<RangeControl value={range} onChange={setRange} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4" aria-label="北极星指标">
|
||||||
|
<StatCard
|
||||||
|
label="周 AI 活跃用户"
|
||||||
|
value={formatNumber(data.northStar.weeklyAiActiveUsers)}
|
||||||
|
hint={
|
||||||
|
weeklyGrowth == null
|
||||||
|
? "暂无可比上周数据"
|
||||||
|
: `较上周 ${signedPercent(weeklyGrowth)}`
|
||||||
|
}
|
||||||
|
icon={Sparkles}
|
||||||
|
tone="success"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="24 小时激活率"
|
||||||
|
value={rateLabel(data.growth.activation24h)}
|
||||||
|
hint={`${formatNumber(data.growth.activation24h.numerator)} / ${formatNumber(data.growth.activation24h.denominator)} 位新用户`}
|
||||||
|
icon={Target}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="D7 价值留存"
|
||||||
|
value={latestMatureRetention(data, "d7")}
|
||||||
|
hint="激活后第 7 天再次成功使用 AI"
|
||||||
|
icon={Repeat2}
|
||||||
|
tone="violet"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="每活跃用户日均消耗"
|
||||||
|
value={optionalNumber(data.consumption.averageDailyCreditsPerActiveUser)}
|
||||||
|
hint={`周期总消耗 ${formatNumber(data.consumption.totalCredits)} 积分`}
|
||||||
|
icon={Gauge}
|
||||||
|
tone="warning"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-6 xl:grid-cols-2">
|
||||||
|
<FunnelCard
|
||||||
|
title="增长激活漏斗"
|
||||||
|
description="首次启动到首次 AI 成功"
|
||||||
|
steps={data.growthFunnel}
|
||||||
|
/>
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<SectionHeader
|
||||||
|
title="留存 Cohort"
|
||||||
|
description="按首次 AI 成功日分组,未成熟窗口显示为 —"
|
||||||
|
icon={ChartNoAxesCombined}
|
||||||
|
/>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[560px] border-collapse text-sm">
|
||||||
|
<caption className="sr-only">D1、D7、D30 价值留存 cohort</caption>
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-surface-muted/70 text-left text-xs text-muted">
|
||||||
|
<th className="px-5 py-3 font-semibold" scope="col">激活日期</th>
|
||||||
|
<th className="px-4 py-3 text-right font-semibold" scope="col">用户</th>
|
||||||
|
<th className="px-4 py-3 text-right font-semibold" scope="col">D1</th>
|
||||||
|
<th className="px-4 py-3 text-right font-semibold" scope="col">D7</th>
|
||||||
|
<th className="px-5 py-3 text-right font-semibold" scope="col">D30</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.retention.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td className="px-5 py-10 text-center text-muted" colSpan={5}>
|
||||||
|
当前周期暂无已激活 cohort
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
data.retention.map((cohort) => (
|
||||||
|
<tr className="border-b border-border last:border-0" key={cohort.cohortDate}>
|
||||||
|
<th className="px-5 py-3 text-left font-medium" scope="row">
|
||||||
|
{cohort.cohortDate}
|
||||||
|
</th>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums">{formatNumber(cohort.size)}</td>
|
||||||
|
<RetentionCell rate={cohort.d1} />
|
||||||
|
<RetentionCell rate={cohort.d7} />
|
||||||
|
<RetentionCell rate={cohort.d30} last />
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-6 xl:grid-cols-[1.25fr_0.75fr]">
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<SectionHeader
|
||||||
|
title="AI 使用结构"
|
||||||
|
description="客户端功能与执行模式,仅包含白名单元数据"
|
||||||
|
icon={BrainCircuit}
|
||||||
|
/>
|
||||||
|
{data.aiFeatures.length === 0 ? (
|
||||||
|
<p className="p-8 text-center text-sm text-muted">等待客户端接入事件后显示</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid divide-y divide-border sm:grid-cols-2 sm:divide-x sm:divide-y-0">
|
||||||
|
{data.aiFeatures.map((item) => (
|
||||||
|
<div className="p-5" key={`${item.feature}-${item.executionMode}`}>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<strong className="text-sm">{featureLabel(item.feature)}</strong>
|
||||||
|
<span className="rounded-full bg-violet-soft px-2.5 py-1 text-xs font-semibold text-violet">
|
||||||
|
{executionModeLabel(item.executionMode)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-2xl font-bold tabular-nums">
|
||||||
|
{formatNumber(item.successes)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted">
|
||||||
|
{formatNumber(item.users)} 位成功用户
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<SectionHeader
|
||||||
|
title="活跃与消耗"
|
||||||
|
description="托管调用以服务端结算为准"
|
||||||
|
icon={Activity}
|
||||||
|
/>
|
||||||
|
<MetricRows
|
||||||
|
rows={[
|
||||||
|
["AI DAU / WAU / MAU", `${data.activity.dau} / ${data.activity.wau} / ${data.activity.mau}`],
|
||||||
|
["DAU / MAU", optionalPercent(data.activity.stickinessPercent)],
|
||||||
|
["成功 AI 次数", formatNumber(data.activity.successfulAiRequests)],
|
||||||
|
["人均成功次数", optionalDecimal(data.activity.successfulRequestsPerActiveUser)],
|
||||||
|
["用户日消耗中位数", optionalNumber(data.consumption.medianUserDailyCredits)],
|
||||||
|
["单次托管请求积分", optionalDecimal(data.consumption.averageCreditsPerManagedRequest)],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-6 xl:grid-cols-3">
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<SectionHeader title="付费转化" description="StoreKit 已验证交易" icon={BadgeDollarSign} />
|
||||||
|
<MetricRows
|
||||||
|
rows={[
|
||||||
|
["7 天免费转付费", rateLabel(data.monetization.conversion7d)],
|
||||||
|
["30 天免费转付费", rateLabel(data.monetization.conversion30d)],
|
||||||
|
["付费用户", formatNumber(data.monetization.payingUsers)],
|
||||||
|
["购买次数", formatNumber(data.monetization.purchases)],
|
||||||
|
["复购率", rateLabel(data.monetization.repeatPurchaseRate)],
|
||||||
|
["购买积分", formatNumber(data.monetization.creditsPurchased)],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<FunnelCard
|
||||||
|
title="推荐增长漏斗"
|
||||||
|
description="分享、打开、绑定、激活与奖励"
|
||||||
|
steps={data.referralFunnel}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<SectionHeader title="体验护栏" description="避免增长被失败体验抵消" icon={Users} />
|
||||||
|
<MetricRows
|
||||||
|
rows={[
|
||||||
|
["客户端 AI 成功率", rateLabel(data.guardrails.clientAiSuccessRate)],
|
||||||
|
["托管请求成功率", rateLabel(data.guardrails.managedSuccessRate)],
|
||||||
|
["积分不足阻断用户", formatNumber(data.guardrails.creditBlockedUsers)],
|
||||||
|
["首次价值耗时中位数", optionalMinutes(data.growth.medianTimeToValueMinutes)],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<SectionHeader title="渠道质量" description="新增不是终点,重点比较 24 小时激活" icon={Users} />
|
||||||
|
<div className="grid divide-y divide-border sm:grid-cols-2 sm:divide-x sm:divide-y-0 xl:grid-cols-4">
|
||||||
|
{data.growth.channels.length === 0 ? (
|
||||||
|
<p className="col-span-full p-8 text-center text-sm text-muted">暂无渠道归因数据</p>
|
||||||
|
) : (
|
||||||
|
data.growth.channels.map((channel) => (
|
||||||
|
<div className="p-5" key={channel.channel}>
|
||||||
|
<strong className="text-sm">{channelLabel(channel.channel)}</strong>
|
||||||
|
<p className="mt-4 text-2xl font-bold tabular-nums">
|
||||||
|
{formatNumber(channel.installations)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted">
|
||||||
|
激活 {rateLabel(channel.activationRate)} · {formatNumber(channel.activated)} 人
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon: Icon,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: typeof Activity;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-5 py-5">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold">{title}</h2>
|
||||||
|
<p className="mt-1 text-xs text-muted">{description}</p>
|
||||||
|
</div>
|
||||||
|
<span className="grid size-10 place-items-center rounded-2xl bg-primary-soft text-primary">
|
||||||
|
<Icon className="size-4" aria-hidden />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FunnelCard({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
steps,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
steps: FunnelStep[];
|
||||||
|
}) {
|
||||||
|
const maximum = Math.max(steps[0]?.count ?? 0, 1);
|
||||||
|
return (
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<SectionHeader title={title} description={description} icon={Target} />
|
||||||
|
<div className="space-y-5 p-5">
|
||||||
|
{steps.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-sm text-muted">当前周期暂无漏斗数据</p>
|
||||||
|
) : (
|
||||||
|
steps.map((step) => (
|
||||||
|
<div key={step.label}>
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-3 text-sm">
|
||||||
|
<span className="text-muted">{step.label}</span>
|
||||||
|
<strong className="tabular-nums">{formatNumber(step.count)}</strong>
|
||||||
|
</div>
|
||||||
|
<progress
|
||||||
|
className="funnel-progress block h-2.5 w-full overflow-hidden rounded-full"
|
||||||
|
max={maximum}
|
||||||
|
value={step.count}
|
||||||
|
aria-label={`${step.label}:${formatNumber(step.count)}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricRows({ rows }: { rows: Array<[string, string]> }) {
|
||||||
|
return (
|
||||||
|
<dl className="divide-y divide-border">
|
||||||
|
{rows.map(([label, value]) => (
|
||||||
|
<div className="flex items-center justify-between gap-4 px-5 py-3.5" key={label}>
|
||||||
|
<dt className="text-sm text-muted">{label}</dt>
|
||||||
|
<dd className="text-right text-sm font-semibold tabular-nums">{value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RetentionCell({ rate, last = false }: { rate?: AnalyticsRate; last?: boolean }) {
|
||||||
|
return (
|
||||||
|
<td className={`${last ? "px-5" : "px-4"} py-3 text-right`}>
|
||||||
|
<span className={retentionTone(rate?.percent)}>{rateLabel(rate)}</span>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rateLabel(rate?: AnalyticsRate): string {
|
||||||
|
return rate?.percent == null ? "—" : `${rate.percent.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalPercent(value?: number): string {
|
||||||
|
return value == null ? "—" : `${value.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalDecimal(value?: number): string {
|
||||||
|
return value == null ? "—" : value.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalNumber(value?: number): string {
|
||||||
|
return value == null ? "—" : formatNumber(Math.round(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalMinutes(value?: number): string {
|
||||||
|
return value == null ? "—" : `${Math.round(value)} 分钟`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function signedPercent(value: number): string {
|
||||||
|
return `${value >= 0 ? "+" : ""}${value.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestMatureRetention(
|
||||||
|
data: ProductAnalyticsOverview,
|
||||||
|
key: "d1" | "d7" | "d30",
|
||||||
|
): string {
|
||||||
|
return rateLabel(data.retention.find((cohort) => cohort[key]?.percent != null)?.[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function retentionTone(percent?: number): string {
|
||||||
|
if (percent == null) return "text-muted";
|
||||||
|
if (percent >= 40) return "rounded-md bg-success-soft px-2 py-1 text-success";
|
||||||
|
if (percent >= 20) return "rounded-md bg-warning-soft px-2 py-1 text-warning";
|
||||||
|
return "rounded-md bg-danger-soft px-2 py-1 text-danger";
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelLabel(channel: string): string {
|
||||||
|
return {
|
||||||
|
APP_STORE_ORGANIC: "App Store 自然量",
|
||||||
|
REFERRAL: "用户邀请",
|
||||||
|
SOCIAL_CONTENT: "社交 / 内容",
|
||||||
|
UNKNOWN: "未知来源",
|
||||||
|
}[channel] ?? channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function featureLabel(feature: string): string {
|
||||||
|
return {
|
||||||
|
TRANSCRIPTION: "语音转写",
|
||||||
|
POLISH: "文字润色",
|
||||||
|
AI_ASSISTANT: "AI 助手",
|
||||||
|
AGENT: "Agent",
|
||||||
|
HOTWORD: "快捷指令",
|
||||||
|
OTHER: "其他",
|
||||||
|
}[feature] ?? feature;
|
||||||
|
}
|
||||||
|
|
||||||
|
function executionModeLabel(mode: string): string {
|
||||||
|
return {
|
||||||
|
MANAGED: "托管",
|
||||||
|
LOCAL: "本地",
|
||||||
|
BYOK: "BYOK",
|
||||||
|
}[mode] ?? mode;
|
||||||
|
}
|
||||||
@@ -11,14 +11,9 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import { adminApi } from "../../api/client";
|
import { adminApi } from "../../api/client";
|
||||||
import type { Overview, TrendPoint } from "../../api/types";
|
import type { Overview, TrendPoint } from "../../api/types";
|
||||||
import { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives";
|
import { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives";
|
||||||
|
import { RangeControl } from "../../components/range-control";
|
||||||
import { formatNumber, usageTypeLabel } from "../../lib/format";
|
import { formatNumber, usageTypeLabel } from "../../lib/format";
|
||||||
|
|
||||||
const ranges = [
|
|
||||||
{ value: "7d", label: "7 天" },
|
|
||||||
{ value: "30d", label: "30 天" },
|
|
||||||
{ value: "90d", label: "90 天" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export function OverviewPage() {
|
export function OverviewPage() {
|
||||||
const [range, setRange] = useState("30d");
|
const [range, setRange] = useState("30d");
|
||||||
const [data, setData] = useState<Overview>();
|
const [data, setData] = useState<Overview>();
|
||||||
@@ -164,34 +159,6 @@ export function OverviewPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RangeControl({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="inline-flex rounded-xl border border-border bg-surface-muted p-1">
|
|
||||||
{ranges.map((range) => (
|
|
||||||
<button
|
|
||||||
key={range.value}
|
|
||||||
className={`min-h-8 rounded-lg px-3 text-xs font-semibold transition ${
|
|
||||||
value === range.value
|
|
||||||
? "bg-surface text-foreground shadow-sm"
|
|
||||||
: "text-muted hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
onClick={() => onChange(range.value)}
|
|
||||||
type="button"
|
|
||||||
aria-pressed={value === range.value}
|
|
||||||
>
|
|
||||||
{range.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Legend({ color, label }: { color: string; label: string }) {
|
function Legend({ color, label }: { color: string; label: string }) {
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
PageHeader,
|
PageHeader,
|
||||||
StatCard,
|
StatCard,
|
||||||
} from "../../components/primitives";
|
} from "../../components/primitives";
|
||||||
|
import { RangeControl } from "../../components/range-control";
|
||||||
import { formatNumber } from "../../lib/format";
|
import { formatNumber } from "../../lib/format";
|
||||||
|
|
||||||
export function ReferralsPage() {
|
export function ReferralsPage() {
|
||||||
@@ -198,35 +199,3 @@ function Rank({ value }: { value: number }) {
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RangeControl({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="inline-flex rounded-xl border border-border bg-surface-muted p-1">
|
|
||||||
{[
|
|
||||||
["7d", "7 天"],
|
|
||||||
["30d", "30 天"],
|
|
||||||
["90d", "90 天"],
|
|
||||||
].map(([range, label]) => (
|
|
||||||
<button
|
|
||||||
key={range}
|
|
||||||
className={`min-h-8 rounded-lg px-3 text-xs font-semibold transition ${
|
|
||||||
value === range
|
|
||||||
? "bg-surface text-foreground shadow-sm"
|
|
||||||
: "text-muted hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
onClick={() => onChange(range ?? "30d")}
|
|
||||||
type="button"
|
|
||||||
aria-pressed={value === range}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ export function usageTypeLabel(usageType?: string): string {
|
|||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
polish: "润色",
|
polish: "润色",
|
||||||
asr: "ASR",
|
asr: "ASR",
|
||||||
|
ASR: "语音转写",
|
||||||
ai: "AI",
|
ai: "AI",
|
||||||
|
LLM: "AI 文本",
|
||||||
agent: "Agent",
|
agent: "Agent",
|
||||||
hotword: "热词",
|
hotword: "热词",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
AdminOperator,
|
AdminOperator,
|
||||||
AdminSecuritySummary,
|
AdminSecuritySummary,
|
||||||
LedgerEntry,
|
LedgerEntry,
|
||||||
|
ProductAnalyticsOverview,
|
||||||
UserSummary,
|
UserSummary,
|
||||||
} from "../api/types";
|
} from "../api/types";
|
||||||
|
|
||||||
@@ -85,6 +86,21 @@ describe("React 管理页面", () => {
|
|||||||
expect(await screen.findByText("support")).toBeTruthy();
|
expect(await screen.findByText("support")).toBeTruthy();
|
||||||
expect(screen.getByText("已加载 2 个账户")).toBeTruthy();
|
expect(screen.getByText("已加载 2 个账户")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("产品分析页展示北极星、留存与付费护栏", async () => {
|
||||||
|
mockSession("SUPPORT");
|
||||||
|
vi.spyOn(adminApi, "productAnalytics").mockResolvedValue(analyticsOverview());
|
||||||
|
window.location.hash = "#/analytics";
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
expect(await screen.findByRole("heading", { name: "产品增长与留存" })).toBeTruthy();
|
||||||
|
expect(screen.getByText("周 AI 活跃用户")).toBeTruthy();
|
||||||
|
expect(screen.getByText(/较上周 \+12\.5%/)).toBeTruthy();
|
||||||
|
expect(screen.getByText("2026-08-01")).toBeTruthy();
|
||||||
|
expect(screen.getByText("文字润色")).toBeTruthy();
|
||||||
|
expect(screen.getByText("7 天免费转付费")).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function mockSession(role: "SUPER_ADMIN" | "SUPPORT") {
|
function mockSession(role: "SUPER_ADMIN" | "SUPPORT") {
|
||||||
@@ -95,6 +111,82 @@ function mockSession(role: "SUPER_ADMIN" | "SUPPORT") {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function analyticsOverview(): ProductAnalyticsOverview {
|
||||||
|
const rate = (numerator: number, denominator: number, percent: number) => ({
|
||||||
|
numerator,
|
||||||
|
denominator,
|
||||||
|
percent,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
period: { from: "2026-08-01T00:00:00Z", until: "2026-08-20T00:00:00Z" },
|
||||||
|
northStar: {
|
||||||
|
weeklyAiActiveUsers: 90,
|
||||||
|
previousWeeklyAiActiveUsers: 80,
|
||||||
|
weekOverWeekPercent: 12.5,
|
||||||
|
},
|
||||||
|
growth: {
|
||||||
|
newInstallations: 100,
|
||||||
|
newAccounts: 80,
|
||||||
|
activation24h: rate(60, 100, 60),
|
||||||
|
medianTimeToValueMinutes: 8,
|
||||||
|
channels: [
|
||||||
|
{
|
||||||
|
channel: "APP_STORE_ORGANIC",
|
||||||
|
installations: 100,
|
||||||
|
activated: 60,
|
||||||
|
activationRate: rate(60, 100, 60),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
activity: {
|
||||||
|
dau: 20,
|
||||||
|
wau: 90,
|
||||||
|
mau: 150,
|
||||||
|
stickinessPercent: 13.3,
|
||||||
|
successfulAiRequests: 500,
|
||||||
|
successfulRequestsPerActiveUser: 3.3,
|
||||||
|
},
|
||||||
|
consumption: {
|
||||||
|
totalCredits: 1_200,
|
||||||
|
averageDailyCreditsPerActiveUser: 12,
|
||||||
|
medianUserDailyCredits: 8,
|
||||||
|
averageCreditsPerManagedRequest: 2.4,
|
||||||
|
},
|
||||||
|
monetization: {
|
||||||
|
payingUsers: 10,
|
||||||
|
purchases: 12,
|
||||||
|
creditsPurchased: 8_000,
|
||||||
|
conversion7d: rate(8, 70, 11.4),
|
||||||
|
conversion30d: rate(10, 50, 20),
|
||||||
|
repeatPurchaseRate: rate(2, 10, 20),
|
||||||
|
},
|
||||||
|
growthFunnel: [
|
||||||
|
{ label: "首次启动", count: 100 },
|
||||||
|
{ label: "24 小时内首次 AI 成功", count: 60 },
|
||||||
|
],
|
||||||
|
retention: [
|
||||||
|
{
|
||||||
|
cohortDate: "2026-08-01",
|
||||||
|
size: 20,
|
||||||
|
d1: rate(10, 20, 50),
|
||||||
|
d7: rate(6, 20, 30),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
aiFeatures: [
|
||||||
|
{ feature: "POLISH", executionMode: "MANAGED", users: 30, successes: 100 },
|
||||||
|
],
|
||||||
|
referralFunnel: [
|
||||||
|
{ label: "发起分享", count: 20 },
|
||||||
|
{ label: "完成奖励", count: 5 },
|
||||||
|
],
|
||||||
|
guardrails: {
|
||||||
|
clientAiSuccessRate: rate(90, 100, 90),
|
||||||
|
managedSuccessRate: rate(95, 100, 95),
|
||||||
|
creditBlockedUsers: 3,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function user(displayName: string, id: string): UserSummary {
|
function user(displayName: string, id: string): UserSummary {
|
||||||
return {
|
return {
|
||||||
userId: id,
|
userId: id,
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ GRANT SELECT ON osg_account_smoke.admin_sessions TO 'osg_smoke_runtime'@'%';
|
|||||||
GRANT SELECT ON osg_account_smoke.admin_audit_log TO 'osg_smoke_runtime'@'%';
|
GRANT SELECT ON osg_account_smoke.admin_audit_log TO 'osg_smoke_runtime'@'%';
|
||||||
GRANT SELECT ON osg_account_smoke.admin_credit_grants TO 'osg_smoke_runtime'@'%';
|
GRANT SELECT ON osg_account_smoke.admin_credit_grants TO 'osg_smoke_runtime'@'%';
|
||||||
GRANT SELECT ON osg_account_smoke.storekit_credit_purchases TO 'osg_smoke_runtime'@'%';
|
GRANT SELECT ON osg_account_smoke.storekit_credit_purchases TO 'osg_smoke_runtime'@'%';
|
||||||
|
GRANT SELECT ON osg_account_smoke.product_analytics_installations TO 'osg_smoke_runtime'@'%';
|
||||||
|
GRANT SELECT ON osg_account_smoke.product_analytics_events TO 'osg_smoke_runtime'@'%';
|
||||||
|
GRANT SELECT ON osg_account_smoke.product_analytics_daily_counters TO 'osg_smoke_runtime'@'%';
|
||||||
|
|
||||||
GRANT INSERT, UPDATE, DELETE ON osg_account_smoke.accounts TO 'osg_smoke_runtime'@'%';
|
GRANT INSERT, UPDATE, DELETE ON osg_account_smoke.accounts TO 'osg_smoke_runtime'@'%';
|
||||||
GRANT INSERT, UPDATE ON osg_account_smoke.apple_credentials TO 'osg_smoke_runtime'@'%';
|
GRANT INSERT, UPDATE ON osg_account_smoke.apple_credentials TO 'osg_smoke_runtime'@'%';
|
||||||
@@ -56,3 +59,8 @@ GRANT INSERT, UPDATE, DELETE ON osg_account_smoke.admin_sessions TO 'osg_smoke_r
|
|||||||
GRANT INSERT ON osg_account_smoke.admin_audit_log TO 'osg_smoke_runtime'@'%';
|
GRANT INSERT ON osg_account_smoke.admin_audit_log TO 'osg_smoke_runtime'@'%';
|
||||||
GRANT INSERT ON osg_account_smoke.admin_credit_grants TO 'osg_smoke_runtime'@'%';
|
GRANT INSERT ON osg_account_smoke.admin_credit_grants TO 'osg_smoke_runtime'@'%';
|
||||||
GRANT INSERT ON osg_account_smoke.storekit_credit_purchases TO 'osg_smoke_runtime'@'%';
|
GRANT INSERT ON osg_account_smoke.storekit_credit_purchases TO 'osg_smoke_runtime'@'%';
|
||||||
|
GRANT INSERT, UPDATE, DELETE ON osg_account_smoke.product_analytics_installations
|
||||||
|
TO 'osg_smoke_runtime'@'%';
|
||||||
|
GRANT INSERT ON osg_account_smoke.product_analytics_events TO 'osg_smoke_runtime'@'%';
|
||||||
|
GRANT INSERT, UPDATE ON osg_account_smoke.product_analytics_daily_counters
|
||||||
|
TO 'osg_smoke_runtime'@'%';
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
- Apple email and avatar data are not requested or stored.
|
- Apple email and avatar data are not requested or stored.
|
||||||
- Deleting an account removes its session, profile, referral, grant, and mutable
|
- Deleting an account removes its session, profile, referral, grant, and mutable
|
||||||
account records in the same local transaction before Apple revocation is retried.
|
account records in the same local transaction before Apple revocation is retried.
|
||||||
|
- Product analytics installations linked to the account and all of their events
|
||||||
|
are deleted by database cascade. The service stores only the digest of a
|
||||||
|
random installation UUID and never stores user content in analytics events.
|
||||||
- Pseudonymous immutable credit-ledger entries, StoreKit transaction audit data,
|
- Pseudonymous immutable credit-ledger entries, StoreKit transaction audit data,
|
||||||
and time-limited anti-abuse tombstones remain after deletion where required to
|
and time-limited anti-abuse tombstones remain after deletion where required to
|
||||||
prevent replay, preserve financial integrity, and stop repeated trial abuse.
|
prevent replay, preserve financial integrity, and stop repeated trial abuse.
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# OSGKeyboard product analytics metrics dictionary
|
||||||
|
|
||||||
|
This document is the canonical definition of product metrics. All dates and
|
||||||
|
cohorts use UTC calendar boundaries. Counts are based on distinct accounts when
|
||||||
|
an installation is linked, otherwise on the pseudonymous installation.
|
||||||
|
|
||||||
|
## North-star metric
|
||||||
|
|
||||||
|
### Weekly AI active users (WAIU)
|
||||||
|
|
||||||
|
The number of distinct users that successfully complete at least one AI feature
|
||||||
|
during a UTC calendar week.
|
||||||
|
|
||||||
|
- Managed AI and ASR use server-settled `credit_usage_records`.
|
||||||
|
- Local and BYOK use accepted `AI_FEATURE_SUCCEEDED` client events.
|
||||||
|
- Managed client success events provide feature breakdowns but are not added to
|
||||||
|
the server-settled total, preventing double counting.
|
||||||
|
- Week-over-week growth is `(current WAIU - previous WAIU) / previous WAIU`.
|
||||||
|
A missing previous population is reported without a percentage.
|
||||||
|
|
||||||
|
## Growth and activation
|
||||||
|
|
||||||
|
### New installations
|
||||||
|
|
||||||
|
Distinct installations whose first accepted `FIRST_OPEN` event occurred in the
|
||||||
|
selected period. Acquisition channel is fixed by the first non-`UNKNOWN`
|
||||||
|
channel observed for the installation.
|
||||||
|
|
||||||
|
Allowed channels:
|
||||||
|
|
||||||
|
- `APP_STORE_ORGANIC`
|
||||||
|
- `REFERRAL`
|
||||||
|
- `SOCIAL_CONTENT`
|
||||||
|
- `UNKNOWN`
|
||||||
|
|
||||||
|
### New accounts
|
||||||
|
|
||||||
|
Accounts whose `accounts.created_at` falls in the selected period.
|
||||||
|
|
||||||
|
### 24-hour AI activation rate
|
||||||
|
|
||||||
|
The percentage of new installations that successfully complete any AI feature
|
||||||
|
within 24 hours of their first open. The numerator uses the same value-event
|
||||||
|
rules as WAIU.
|
||||||
|
|
||||||
|
### Time to first value
|
||||||
|
|
||||||
|
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
|
||||||
|
included in the median and remain visible in the activation denominator.
|
||||||
|
|
||||||
|
## Activity
|
||||||
|
|
||||||
|
### AI DAU, WAU and MAU
|
||||||
|
|
||||||
|
Distinct value-active users in the last 1, 7 and 30 UTC days ending at the
|
||||||
|
report's `until` timestamp.
|
||||||
|
|
||||||
|
### DAU/MAU stickiness
|
||||||
|
|
||||||
|
`AI DAU / AI MAU`. The value is null when MAU is zero.
|
||||||
|
|
||||||
|
### Successful AI requests
|
||||||
|
|
||||||
|
The sum of settled managed requests and successful local/BYOK client events.
|
||||||
|
Managed client success events are excluded from this total.
|
||||||
|
|
||||||
|
### Successful AI requests per active user
|
||||||
|
|
||||||
|
`successful AI requests / distinct value-active users` for the selected period.
|
||||||
|
|
||||||
|
## Retention
|
||||||
|
|
||||||
|
The cohort date is the UTC date of a user's first successful AI feature.
|
||||||
|
Retention is value retention, not application-open retention.
|
||||||
|
|
||||||
|
- `D1`: active on cohort date + 1 day.
|
||||||
|
- `D7`: active on cohort date + 7 days.
|
||||||
|
- `D30`: active on cohort date + 30 days.
|
||||||
|
|
||||||
|
Each retention rate uses the original cohort size as denominator. A day that has
|
||||||
|
not fully elapsed at the report's `until` timestamp is returned as unavailable,
|
||||||
|
not zero. Channel and first-feature breakdowns are optional dimensions and must
|
||||||
|
not alter the base cohort definition.
|
||||||
|
|
||||||
|
## AI feature usage
|
||||||
|
|
||||||
|
Allowed feature types:
|
||||||
|
|
||||||
|
- `TRANSCRIPTION`
|
||||||
|
- `POLISH`
|
||||||
|
- `AI_ASSISTANT`
|
||||||
|
- `AGENT`
|
||||||
|
- `HOTWORD`
|
||||||
|
- `OTHER`
|
||||||
|
|
||||||
|
Allowed execution modes:
|
||||||
|
|
||||||
|
- `MANAGED`
|
||||||
|
- `LOCAL`
|
||||||
|
- `BYOK`
|
||||||
|
|
||||||
|
Feature distributions use accepted client events because server billing only
|
||||||
|
distinguishes `ASR` and `LLM`. Server-settled aggregates remain authoritative
|
||||||
|
for managed totals, credits, token counts and ASR duration.
|
||||||
|
|
||||||
|
## Credit consumption
|
||||||
|
|
||||||
|
### Daily total credit consumption
|
||||||
|
|
||||||
|
The sum of non-negative `credit_usage_records.charged_credits` by UTC date.
|
||||||
|
|
||||||
|
### Average daily credits per AI active user
|
||||||
|
|
||||||
|
For each UTC date, divide total settled credits by distinct managed AI users,
|
||||||
|
then average those daily values across days containing at least one active user.
|
||||||
|
|
||||||
|
### Median user-day credits
|
||||||
|
|
||||||
|
The median of per-account daily settled credits. This is shown beside the mean
|
||||||
|
to prevent a small number of heavy users from distorting typical consumption.
|
||||||
|
|
||||||
|
### Average credits per managed request
|
||||||
|
|
||||||
|
`settled credits / settled managed requests`. Local and BYOK events consume no
|
||||||
|
server credits and are excluded.
|
||||||
|
|
||||||
|
## Monetization
|
||||||
|
|
||||||
|
### 7-day and 30-day free-to-paid conversion
|
||||||
|
|
||||||
|
The percentage of newly registered accounts with a first credited StoreKit
|
||||||
|
purchase no later than 7 or 30 days after registration. Cohorts whose conversion
|
||||||
|
window has not elapsed are reported separately from mature cohorts.
|
||||||
|
|
||||||
|
### Paying users
|
||||||
|
|
||||||
|
Distinct accounts with at least one credited StoreKit purchase in the period.
|
||||||
|
|
||||||
|
### Repeat purchase rate
|
||||||
|
|
||||||
|
The percentage of paying accounts with at least two credited StoreKit purchases
|
||||||
|
across their lifetime.
|
||||||
|
|
||||||
|
StoreKit transaction count and granted credits are operational proxies. Net
|
||||||
|
revenue, App Store commission and refunds require App Store financial data and
|
||||||
|
are outside this service's first version.
|
||||||
|
|
||||||
|
## Referral funnel
|
||||||
|
|
||||||
|
The ordered growth funnel is:
|
||||||
|
|
||||||
|
1. `REFERRAL_SHARED` distinct sharing installations.
|
||||||
|
2. Invitation opens: accepted `INVITE_OPENED` client events plus anonymous
|
||||||
|
first-party invitation page views. Page views are aggregate requests rather
|
||||||
|
than distinct people and must be interpreted as a directional funnel signal.
|
||||||
|
3. Referral-bound accounts.
|
||||||
|
4. Referral-bound accounts that reach their first value event.
|
||||||
|
5. Rewarded referral bindings.
|
||||||
|
|
||||||
|
Pending and ineligible bindings are parallel status counts, not sequential
|
||||||
|
funnel steps.
|
||||||
|
|
||||||
|
## Experience guardrails
|
||||||
|
|
||||||
|
- AI success rate: successful client AI completions divided by starts with a
|
||||||
|
terminal success or failure event.
|
||||||
|
- Managed request failure rate: terminal non-settled `provider_requests` divided
|
||||||
|
by terminal managed requests.
|
||||||
|
- P50/P95 latency: client duration bucket distribution for all modes; exact
|
||||||
|
server duration percentiles may be added later.
|
||||||
|
- Credit-blocked users: distinct installations reporting
|
||||||
|
`INSUFFICIENT_CREDITS` during the period.
|
||||||
|
|
||||||
|
Guardrails are diagnostic and never count as value-active events.
|
||||||
@@ -26,6 +26,10 @@ It does not replace the app's local ASR, BYOK provider access, or iCloud sync.
|
|||||||
- `integrity` evaluates DeviceCheck and App Attest evidence.
|
- `integrity` evaluates DeviceCheck and App Attest evidence.
|
||||||
- `gateway` proxies Volcengine ASR and DeepSeek text requests.
|
- `gateway` proxies Volcengine ASR and DeepSeek text requests.
|
||||||
- `inviteweb` serves the first-party invitation landing page.
|
- `inviteweb` serves the first-party invitation landing page.
|
||||||
|
- `analytics` accepts a strict allowlist of product event metadata and links a
|
||||||
|
pseudonymous installation to an account only when a valid session is present.
|
||||||
|
- `admin/stats` combines accepted product events with authoritative billing,
|
||||||
|
StoreKit, account, and referral aggregates for the internal dashboard.
|
||||||
|
|
||||||
Modules communicate through narrow ports. Provider clients, Apple clients,
|
Modules communicate through narrow ports. Provider clients, Apple clients,
|
||||||
integrity clients, clocks, token generators, and repositories are replaceable in
|
integrity clients, clocks, token generators, and repositories are replaceable in
|
||||||
@@ -60,7 +64,10 @@ tests.
|
|||||||
claims required for replay protection.
|
claims required for replay protection.
|
||||||
- Usage metadata contains feature, model, metering units, latency, status, and
|
- Usage metadata contains feature, model, metering units, latency, status, and
|
||||||
rate-card version.
|
rate-card version.
|
||||||
|
- Product events contain enums, release versions, timestamps, and duration
|
||||||
|
buckets only. Installation UUIDs are stored as SHA-256 digests.
|
||||||
- Operational logs use request IDs and internal opaque IDs, never Apple subjects
|
- Operational logs use request IDs and internal opaque IDs, never Apple subjects
|
||||||
or bearer credentials.
|
or bearer credentials.
|
||||||
- Account deletion revokes credentials and removes user-linked records. Only
|
- Account deletion revokes credentials and cascades through linked product
|
||||||
non-identifying aggregate service metrics may remain.
|
installations and events. Only non-identifying aggregate service metrics may
|
||||||
|
remain.
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# Product analytics
|
||||||
|
|
||||||
|
OSGKeyboard analytics records product behavior metadata only. It is separate
|
||||||
|
from billing, provider execution and immutable credit ledgers.
|
||||||
|
|
||||||
|
## Privacy boundary
|
||||||
|
|
||||||
|
Never send or persist:
|
||||||
|
|
||||||
|
- audio or audio-derived content;
|
||||||
|
- keyboard input, prompts, context, transcripts or model output;
|
||||||
|
- Apple subjects, email addresses, names, tokens, API keys or provider
|
||||||
|
credentials;
|
||||||
|
- arbitrary property names or free-form text.
|
||||||
|
|
||||||
|
The service stores a SHA-256 digest of the random installation identifier, not
|
||||||
|
the identifier supplied by the client. Event DTO string representations are
|
||||||
|
redacted. Product analytics rows linked to an account are deleted with that
|
||||||
|
account. Anonymous installations that never link to an account are retained for
|
||||||
|
90 days and are eligible for scheduled deletion in a later operational job.
|
||||||
|
|
||||||
|
## Ingestion API
|
||||||
|
|
||||||
|
`POST /v1/analytics/events` accepts one batch of 1 to 50 events.
|
||||||
|
|
||||||
|
- Authentication is optional so first-open and pre-login events can be
|
||||||
|
measured. Invalid bearer credentials are rejected.
|
||||||
|
- `installationId` must be a client-generated UUID stored in the containing app
|
||||||
|
and shared with the keyboard extension through the App Group.
|
||||||
|
- When a valid account session is present, the installation is linked to that
|
||||||
|
account. An installation cannot later be linked to a different account.
|
||||||
|
- Every `clientEventId` is a client-generated UUID. The pair
|
||||||
|
`(installation, clientEventId)` is unique.
|
||||||
|
- Replaying an identical event is accepted and reported as replayed.
|
||||||
|
- Reusing an event ID with different values returns `409 conflict`.
|
||||||
|
- The whole batch is validated before persistence and committed atomically.
|
||||||
|
- `occurredAt` must be no more than 35 days old and no more than five minutes in
|
||||||
|
the future. UTC ISO-8601 timestamps are required.
|
||||||
|
|
||||||
|
The successful response reports `accepted` and `replayed` event counts. It does
|
||||||
|
not return account or installation identifiers.
|
||||||
|
|
||||||
|
## Event catalog
|
||||||
|
|
||||||
|
### Lifecycle events
|
||||||
|
|
||||||
|
`FIRST_OPEN`
|
||||||
|
|
||||||
|
- Required: `surface=APP`, `acquisitionChannel`.
|
||||||
|
- Optional: `appVersion`, `osVersion`.
|
||||||
|
- Must be emitted once per installation. Server idempotency protects retries.
|
||||||
|
|
||||||
|
`SESSION_STARTED`
|
||||||
|
|
||||||
|
- Required: `surface` (`APP` or `KEYBOARD`).
|
||||||
|
- Optional: `appVersion`, `osVersion`.
|
||||||
|
|
||||||
|
`KEYBOARD_ACTIVATED`
|
||||||
|
|
||||||
|
- Required: `surface=KEYBOARD`.
|
||||||
|
- Emit when the custom keyboard becomes active, not for every keystroke.
|
||||||
|
|
||||||
|
### AI events
|
||||||
|
|
||||||
|
`AI_FEATURE_STARTED`
|
||||||
|
|
||||||
|
- Required: `feature`, `executionMode`, `surface`.
|
||||||
|
- Optional: `durationBucket` is not allowed.
|
||||||
|
|
||||||
|
`AI_FEATURE_SUCCEEDED`
|
||||||
|
|
||||||
|
- Required: `feature`, `executionMode`, `surface`, `durationBucket`.
|
||||||
|
- This is the client-side core value event.
|
||||||
|
- Managed totals remain server-authoritative; the event provides feature and
|
||||||
|
surface detail.
|
||||||
|
|
||||||
|
`AI_FEATURE_FAILED`
|
||||||
|
|
||||||
|
- Required: `feature`, `executionMode`, `surface`, `failureCategory`.
|
||||||
|
- Optional: `durationBucket`.
|
||||||
|
- Never include provider response bodies or user content.
|
||||||
|
|
||||||
|
### Purchase events
|
||||||
|
|
||||||
|
`PURCHASE_VIEWED`
|
||||||
|
|
||||||
|
- Required: `surface=APP`.
|
||||||
|
|
||||||
|
`PURCHASE_STARTED`
|
||||||
|
|
||||||
|
- Required: `surface=APP`.
|
||||||
|
|
||||||
|
`PURCHASE_CANCELLED`
|
||||||
|
|
||||||
|
- Required: `surface=APP`, `failureCategory=CANCELLED`.
|
||||||
|
|
||||||
|
Successful purchases are derived from verified StoreKit transactions and must
|
||||||
|
not be duplicated as client success events.
|
||||||
|
|
||||||
|
### Referral events
|
||||||
|
|
||||||
|
`REFERRAL_SHARED`
|
||||||
|
|
||||||
|
- Required: `surface=APP`.
|
||||||
|
|
||||||
|
`INVITE_OPENED`
|
||||||
|
|
||||||
|
- Required: `surface=INVITE_WEB`, `acquisitionChannel=REFERRAL`.
|
||||||
|
- The app may emit this event after handling an invitation Universal Link.
|
||||||
|
- The first-party web route separately increments a UTC daily aggregate after
|
||||||
|
validating the referral code. It stores no IP address, user agent, cookie,
|
||||||
|
installation ID, referral code, or other request metadata.
|
||||||
|
- No third-party analytics script is permitted.
|
||||||
|
|
||||||
|
Referral binding, value qualification and reward are derived from server data.
|
||||||
|
|
||||||
|
## Allowed dimensions
|
||||||
|
|
||||||
|
`surface`
|
||||||
|
|
||||||
|
- `APP`
|
||||||
|
- `KEYBOARD`
|
||||||
|
- `INVITE_WEB`
|
||||||
|
|
||||||
|
`acquisitionChannel`
|
||||||
|
|
||||||
|
- `APP_STORE_ORGANIC`
|
||||||
|
- `REFERRAL`
|
||||||
|
- `SOCIAL_CONTENT`
|
||||||
|
- `UNKNOWN`
|
||||||
|
|
||||||
|
`feature`
|
||||||
|
|
||||||
|
- `TRANSCRIPTION`
|
||||||
|
- `POLISH`
|
||||||
|
- `AI_ASSISTANT`
|
||||||
|
- `AGENT`
|
||||||
|
- `HOTWORD`
|
||||||
|
- `OTHER`
|
||||||
|
|
||||||
|
`executionMode`
|
||||||
|
|
||||||
|
- `MANAGED`
|
||||||
|
- `LOCAL`
|
||||||
|
- `BYOK`
|
||||||
|
|
||||||
|
`failureCategory`
|
||||||
|
|
||||||
|
- `NETWORK`
|
||||||
|
- `PROVIDER`
|
||||||
|
- `TIMEOUT`
|
||||||
|
- `CANCELLED`
|
||||||
|
- `INSUFFICIENT_CREDITS`
|
||||||
|
- `VALIDATION`
|
||||||
|
- `UNKNOWN`
|
||||||
|
|
||||||
|
`durationBucket`
|
||||||
|
|
||||||
|
- `LT_1S`
|
||||||
|
- `S1_TO_3`
|
||||||
|
- `S3_TO_10`
|
||||||
|
- `S10_TO_30`
|
||||||
|
- `GTE_30S`
|
||||||
|
|
||||||
|
`appVersion` and `osVersion` are optional ASCII release identifiers of at most
|
||||||
|
32 characters. They may not contain spaces, user-generated values or device
|
||||||
|
names.
|
||||||
|
|
||||||
|
## Client delivery guidance
|
||||||
|
|
||||||
|
- Persist pending events in an append-only local queue.
|
||||||
|
- Retry with exponential backoff after network failures and HTTP 5xx.
|
||||||
|
- Drop events rejected with HTTP 400/422 after recording a local diagnostic
|
||||||
|
counter; do not retry malformed events indefinitely.
|
||||||
|
- Reuse the same `clientEventId` for every retry.
|
||||||
|
- Send at most 50 events per batch and remove events only after a successful
|
||||||
|
response.
|
||||||
|
- Do not block the AI interaction or purchase flow on analytics delivery.
|
||||||
|
|
||||||
|
## Reporting
|
||||||
|
|
||||||
|
The admin analytics endpoints combine:
|
||||||
|
|
||||||
|
- accepted product events for acquisition, local/BYOK usage and client funnel
|
||||||
|
detail;
|
||||||
|
- settled credit usage for managed AI counts and credit consumption;
|
||||||
|
- accounts, StoreKit and referrals for registration, monetization and referral
|
||||||
|
outcomes.
|
||||||
|
|
||||||
|
Metric formulas are defined in
|
||||||
|
[`ANALYTICS_METRICS_DICTIONARY.md`](ANALYTICS_METRICS_DICTIONARY.md).
|
||||||
@@ -40,6 +40,9 @@ GRANT SELECT ON osg_account.admin_sessions TO 'osg_account_runtime'@'10.20.%';
|
|||||||
GRANT SELECT ON osg_account.admin_audit_log TO 'osg_account_runtime'@'10.20.%';
|
GRANT SELECT ON osg_account.admin_audit_log TO 'osg_account_runtime'@'10.20.%';
|
||||||
GRANT SELECT ON osg_account.admin_credit_grants TO 'osg_account_runtime'@'10.20.%';
|
GRANT SELECT ON osg_account.admin_credit_grants TO 'osg_account_runtime'@'10.20.%';
|
||||||
GRANT SELECT ON osg_account.storekit_credit_purchases TO 'osg_account_runtime'@'10.20.%';
|
GRANT SELECT ON osg_account.storekit_credit_purchases TO 'osg_account_runtime'@'10.20.%';
|
||||||
|
GRANT SELECT ON osg_account.product_analytics_installations TO 'osg_account_runtime'@'10.20.%';
|
||||||
|
GRANT SELECT ON osg_account.product_analytics_events TO 'osg_account_runtime'@'10.20.%';
|
||||||
|
GRANT SELECT ON osg_account.product_analytics_daily_counters TO 'osg_account_runtime'@'10.20.%';
|
||||||
|
|
||||||
GRANT INSERT, UPDATE, DELETE ON osg_account.accounts TO 'osg_account_runtime'@'10.20.%';
|
GRANT INSERT, UPDATE, DELETE ON osg_account.accounts TO 'osg_account_runtime'@'10.20.%';
|
||||||
GRANT INSERT, UPDATE ON osg_account.apple_credentials TO 'osg_account_runtime'@'10.20.%';
|
GRANT INSERT, UPDATE ON osg_account.apple_credentials TO 'osg_account_runtime'@'10.20.%';
|
||||||
@@ -70,6 +73,11 @@ GRANT INSERT, UPDATE, DELETE ON osg_account.admin_sessions TO 'osg_account_runti
|
|||||||
GRANT INSERT ON osg_account.admin_audit_log TO 'osg_account_runtime'@'10.20.%';
|
GRANT INSERT ON osg_account.admin_audit_log TO 'osg_account_runtime'@'10.20.%';
|
||||||
GRANT INSERT ON osg_account.admin_credit_grants TO 'osg_account_runtime'@'10.20.%';
|
GRANT INSERT ON osg_account.admin_credit_grants TO 'osg_account_runtime'@'10.20.%';
|
||||||
GRANT INSERT ON osg_account.storekit_credit_purchases TO 'osg_account_runtime'@'10.20.%';
|
GRANT INSERT ON osg_account.storekit_credit_purchases TO 'osg_account_runtime'@'10.20.%';
|
||||||
|
GRANT INSERT, UPDATE, DELETE ON osg_account.product_analytics_installations
|
||||||
|
TO 'osg_account_runtime'@'10.20.%';
|
||||||
|
GRANT INSERT ON osg_account.product_analytics_events TO 'osg_account_runtime'@'10.20.%';
|
||||||
|
GRANT INSERT, UPDATE ON osg_account.product_analytics_daily_counters
|
||||||
|
TO 'osg_account_runtime'@'10.20.%';
|
||||||
|
|
||||||
-- Deliberately absent: global privileges, GRANT OPTION, FILE, PROCESS, SUPER,
|
-- Deliberately absent: global privileges, GRANT OPTION, FILE, PROCESS, SUPER,
|
||||||
-- CREATE USER, and UPDATE/DELETE on immutable ledger or usage-history tables.
|
-- CREATE USER, and UPDATE/DELETE on immutable ledger or usage-history tables.
|
||||||
|
|||||||
+258
-1
@@ -73,6 +73,33 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
"204": { description: Session revoked }
|
"204": { description: Session revoked }
|
||||||
default: { $ref: "#/components/responses/Error" }
|
default: { $ref: "#/components/responses/Error" }
|
||||||
|
/v1/analytics/events:
|
||||||
|
post:
|
||||||
|
security:
|
||||||
|
- {}
|
||||||
|
- bearerAuth: []
|
||||||
|
summary: Idempotently accept privacy-minimized product events
|
||||||
|
description: |
|
||||||
|
Accepts pre-login or authenticated client events. The random
|
||||||
|
installation UUID is stored only as a digest. When a valid bearer
|
||||||
|
session is supplied, the installation is linked to the account and is
|
||||||
|
deleted with that account. Audio, user text, prompts, transcripts,
|
||||||
|
model output, credentials and arbitrary properties are never accepted.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/ProductAnalyticsBatchRequest" }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Atomic batch acceptance and replay counts
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/ProductAnalyticsBatchResponse" }
|
||||||
|
"400": { $ref: "#/components/responses/Error" }
|
||||||
|
"409": { $ref: "#/components/responses/Error" }
|
||||||
|
"422": { $ref: "#/components/responses/Error" }
|
||||||
|
default: { $ref: "#/components/responses/Error" }
|
||||||
/v1/account:
|
/v1/account:
|
||||||
get:
|
get:
|
||||||
summary: Return the account profile
|
summary: Return the account profile
|
||||||
@@ -571,6 +598,22 @@ paths:
|
|||||||
schema: { $ref: "#/components/schemas/AdminReferralOverview" }
|
schema: { $ref: "#/components/schemas/AdminReferralOverview" }
|
||||||
"400": { description: Range is invalid }
|
"400": { description: Range is invalid }
|
||||||
"401": { description: Session is invalid }
|
"401": { description: Session is invalid }
|
||||||
|
/v1/admin/analytics:
|
||||||
|
get:
|
||||||
|
security:
|
||||||
|
- adminMtls: []
|
||||||
|
adminSession: []
|
||||||
|
summary: Return product growth, retention, usage and monetization analytics
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/AdminRange"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Privacy-minimized product analytics aggregates
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/AdminProductAnalytics" }
|
||||||
|
"400": { description: Range is invalid }
|
||||||
|
"401": { description: Session is invalid }
|
||||||
/v1/admin/users:
|
/v1/admin/users:
|
||||||
get:
|
get:
|
||||||
security:
|
security:
|
||||||
@@ -904,6 +947,69 @@ components:
|
|||||||
application/json:
|
application/json:
|
||||||
schema: { $ref: "#/components/schemas/GatewayError" }
|
schema: { $ref: "#/components/schemas/GatewayError" }
|
||||||
schemas:
|
schemas:
|
||||||
|
ProductAnalyticsEvent:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [clientEventId, eventType, occurredAt, surface]
|
||||||
|
properties:
|
||||||
|
clientEventId: { type: string, format: uuid }
|
||||||
|
eventType:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- FIRST_OPEN
|
||||||
|
- SESSION_STARTED
|
||||||
|
- KEYBOARD_ACTIVATED
|
||||||
|
- AI_FEATURE_STARTED
|
||||||
|
- AI_FEATURE_SUCCEEDED
|
||||||
|
- AI_FEATURE_FAILED
|
||||||
|
- PURCHASE_VIEWED
|
||||||
|
- PURCHASE_STARTED
|
||||||
|
- PURCHASE_CANCELLED
|
||||||
|
- REFERRAL_SHARED
|
||||||
|
- INVITE_OPENED
|
||||||
|
occurredAt: { type: string, format: date-time }
|
||||||
|
surface: { type: string, enum: [APP, KEYBOARD, INVITE_WEB] }
|
||||||
|
acquisitionChannel:
|
||||||
|
type: string
|
||||||
|
enum: [APP_STORE_ORGANIC, REFERRAL, SOCIAL_CONTENT, UNKNOWN]
|
||||||
|
feature:
|
||||||
|
type: string
|
||||||
|
enum: [TRANSCRIPTION, POLISH, AI_ASSISTANT, AGENT, HOTWORD, OTHER]
|
||||||
|
executionMode: { type: string, enum: [MANAGED, LOCAL, BYOK] }
|
||||||
|
failureCategory:
|
||||||
|
type: string
|
||||||
|
enum: [NETWORK, PROVIDER, TIMEOUT, CANCELLED, INSUFFICIENT_CREDITS, VALIDATION, UNKNOWN]
|
||||||
|
durationBucket:
|
||||||
|
type: string
|
||||||
|
enum: [LT_1S, S1_TO_3, S3_TO_10, S10_TO_30, GTE_30S]
|
||||||
|
appVersion:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
maxLength: 32
|
||||||
|
pattern: "^[A-Za-z0-9._+-]+$"
|
||||||
|
osVersion:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
maxLength: 32
|
||||||
|
pattern: "^[A-Za-z0-9._+-]+$"
|
||||||
|
ProductAnalyticsBatchRequest:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [installationId, events]
|
||||||
|
properties:
|
||||||
|
installationId: { type: string, format: uuid }
|
||||||
|
events:
|
||||||
|
type: array
|
||||||
|
minItems: 1
|
||||||
|
maxItems: 50
|
||||||
|
items: { $ref: "#/components/schemas/ProductAnalyticsEvent" }
|
||||||
|
ProductAnalyticsBatchResponse:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [accepted, replayed]
|
||||||
|
properties:
|
||||||
|
accepted: { type: integer, minimum: 0, maximum: 50 }
|
||||||
|
replayed: { type: integer, minimum: 0, maximum: 50 }
|
||||||
AdminSessionState:
|
AdminSessionState:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
@@ -978,7 +1084,7 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
label:
|
label:
|
||||||
type: string
|
type: string
|
||||||
enum: [邀请码创建, 成功绑定, 有效使用并奖励, 待资格确认, 不符合奖励条件]
|
enum: [邀请码创建, 成功绑定, 有效使用并奖励]
|
||||||
count: { type: integer, format: int64, minimum: 0 }
|
count: { type: integer, format: int64, minimum: 0 }
|
||||||
AdminReferralRank:
|
AdminReferralRank:
|
||||||
type: object
|
type: object
|
||||||
@@ -1002,6 +1108,157 @@ components:
|
|||||||
ranking:
|
ranking:
|
||||||
type: array
|
type: array
|
||||||
items: { $ref: "#/components/schemas/AdminReferralRank" }
|
items: { $ref: "#/components/schemas/AdminReferralRank" }
|
||||||
|
AdminAnalyticsRate:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [numerator, denominator]
|
||||||
|
properties:
|
||||||
|
numerator: { type: integer, format: int64, minimum: 0 }
|
||||||
|
denominator: { type: integer, format: int64, minimum: 0 }
|
||||||
|
percent: { type: ["number", "null"], minimum: 0, maximum: 100 }
|
||||||
|
AdminAnalyticsFunnelStep:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [label, count]
|
||||||
|
properties:
|
||||||
|
label: { type: string, maxLength: 64 }
|
||||||
|
count: { type: integer, format: int64, minimum: 0 }
|
||||||
|
AdminAnalyticsChannel:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [channel, installations, activated, activationRate]
|
||||||
|
properties:
|
||||||
|
channel:
|
||||||
|
type: string
|
||||||
|
enum: [APP_STORE_ORGANIC, REFERRAL, SOCIAL_CONTENT, UNKNOWN]
|
||||||
|
installations: { type: integer, format: int64, minimum: 0 }
|
||||||
|
activated: { type: integer, format: int64, minimum: 0 }
|
||||||
|
activationRate: { $ref: "#/components/schemas/AdminAnalyticsRate" }
|
||||||
|
AdminAnalyticsCohort:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [cohortDate, size]
|
||||||
|
properties:
|
||||||
|
cohortDate: { type: string, format: date }
|
||||||
|
size: { type: integer, format: int64, minimum: 0 }
|
||||||
|
d1:
|
||||||
|
anyOf:
|
||||||
|
- { $ref: "#/components/schemas/AdminAnalyticsRate" }
|
||||||
|
- { type: "null" }
|
||||||
|
d7:
|
||||||
|
anyOf:
|
||||||
|
- { $ref: "#/components/schemas/AdminAnalyticsRate" }
|
||||||
|
- { type: "null" }
|
||||||
|
d30:
|
||||||
|
anyOf:
|
||||||
|
- { $ref: "#/components/schemas/AdminAnalyticsRate" }
|
||||||
|
- { type: "null" }
|
||||||
|
AdminAnalyticsFeatureUsage:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [feature, executionMode, users, successes]
|
||||||
|
properties:
|
||||||
|
feature:
|
||||||
|
type: string
|
||||||
|
enum: [TRANSCRIPTION, POLISH, AI_ASSISTANT, AGENT, HOTWORD, OTHER]
|
||||||
|
executionMode: { type: string, enum: [MANAGED, LOCAL, BYOK] }
|
||||||
|
users: { type: integer, format: int64, minimum: 0 }
|
||||||
|
successes: { type: integer, format: int64, minimum: 0 }
|
||||||
|
AdminProductAnalytics:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- period
|
||||||
|
- northStar
|
||||||
|
- growth
|
||||||
|
- activity
|
||||||
|
- consumption
|
||||||
|
- monetization
|
||||||
|
- growthFunnel
|
||||||
|
- retention
|
||||||
|
- aiFeatures
|
||||||
|
- referralFunnel
|
||||||
|
- guardrails
|
||||||
|
properties:
|
||||||
|
period:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [from, until]
|
||||||
|
properties:
|
||||||
|
from: { type: string, format: date-time }
|
||||||
|
until: { type: string, format: date-time }
|
||||||
|
northStar:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [weeklyAiActiveUsers, previousWeeklyAiActiveUsers]
|
||||||
|
properties:
|
||||||
|
weeklyAiActiveUsers: { type: integer, format: int64, minimum: 0 }
|
||||||
|
previousWeeklyAiActiveUsers: { type: integer, format: int64, minimum: 0 }
|
||||||
|
weekOverWeekPercent: { type: ["number", "null"] }
|
||||||
|
growth:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [newInstallations, newAccounts, activation24h, channels]
|
||||||
|
properties:
|
||||||
|
newInstallations: { type: integer, format: int64, minimum: 0 }
|
||||||
|
newAccounts: { type: integer, format: int64, minimum: 0 }
|
||||||
|
activation24h: { $ref: "#/components/schemas/AdminAnalyticsRate" }
|
||||||
|
medianTimeToValueMinutes: { type: ["number", "null"], minimum: 0 }
|
||||||
|
channels:
|
||||||
|
type: array
|
||||||
|
items: { $ref: "#/components/schemas/AdminAnalyticsChannel" }
|
||||||
|
activity:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [dau, wau, mau, successfulAiRequests]
|
||||||
|
properties:
|
||||||
|
dau: { type: integer, format: int64, minimum: 0 }
|
||||||
|
wau: { type: integer, format: int64, minimum: 0 }
|
||||||
|
mau: { type: integer, format: int64, minimum: 0 }
|
||||||
|
stickinessPercent: { type: ["number", "null"], minimum: 0, maximum: 100 }
|
||||||
|
successfulAiRequests: { type: integer, format: int64, minimum: 0 }
|
||||||
|
successfulRequestsPerActiveUser: { type: ["number", "null"], minimum: 0 }
|
||||||
|
consumption:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [totalCredits]
|
||||||
|
properties:
|
||||||
|
totalCredits: { type: integer, format: int64, minimum: 0 }
|
||||||
|
averageDailyCreditsPerActiveUser: { type: ["number", "null"], minimum: 0 }
|
||||||
|
medianUserDailyCredits: { type: ["number", "null"], minimum: 0 }
|
||||||
|
averageCreditsPerManagedRequest: { type: ["number", "null"], minimum: 0 }
|
||||||
|
monetization:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
[payingUsers, purchases, creditsPurchased, conversion7d, conversion30d, repeatPurchaseRate]
|
||||||
|
properties:
|
||||||
|
payingUsers: { type: integer, format: int64, minimum: 0 }
|
||||||
|
purchases: { type: integer, format: int64, minimum: 0 }
|
||||||
|
creditsPurchased: { type: integer, format: int64, minimum: 0 }
|
||||||
|
conversion7d: { $ref: "#/components/schemas/AdminAnalyticsRate" }
|
||||||
|
conversion30d: { $ref: "#/components/schemas/AdminAnalyticsRate" }
|
||||||
|
repeatPurchaseRate: { $ref: "#/components/schemas/AdminAnalyticsRate" }
|
||||||
|
growthFunnel:
|
||||||
|
type: array
|
||||||
|
items: { $ref: "#/components/schemas/AdminAnalyticsFunnelStep" }
|
||||||
|
retention:
|
||||||
|
type: array
|
||||||
|
items: { $ref: "#/components/schemas/AdminAnalyticsCohort" }
|
||||||
|
aiFeatures:
|
||||||
|
type: array
|
||||||
|
items: { $ref: "#/components/schemas/AdminAnalyticsFeatureUsage" }
|
||||||
|
referralFunnel:
|
||||||
|
type: array
|
||||||
|
items: { $ref: "#/components/schemas/AdminAnalyticsFunnelStep" }
|
||||||
|
guardrails:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [clientAiSuccessRate, managedSuccessRate, creditBlockedUsers]
|
||||||
|
properties:
|
||||||
|
clientAiSuccessRate: { $ref: "#/components/schemas/AdminAnalyticsRate" }
|
||||||
|
managedSuccessRate: { $ref: "#/components/schemas/AdminAnalyticsRate" }
|
||||||
|
creditBlockedUsers: { type: integer, format: int64, minimum: 0 }
|
||||||
AdminUserSummary:
|
AdminUserSummary:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ import com.osglab.account.features.admin.services.AdminBootstrapService
|
|||||||
import com.osglab.account.features.admin.services.AdminOperatorService
|
import com.osglab.account.features.admin.services.AdminOperatorService
|
||||||
import com.osglab.account.features.admin.services.AdminSessionService
|
import com.osglab.account.features.admin.services.AdminSessionService
|
||||||
import com.osglab.account.features.admin.stats.repositories.AdminStatsRepository
|
import com.osglab.account.features.admin.stats.repositories.AdminStatsRepository
|
||||||
|
import com.osglab.account.features.admin.stats.repositories.AdminProductAnalyticsRepository
|
||||||
import com.osglab.account.features.admin.stats.repositories.ExposedAdminStatsRepository
|
import com.osglab.account.features.admin.stats.repositories.ExposedAdminStatsRepository
|
||||||
|
import com.osglab.account.features.admin.stats.repositories.ExposedAdminProductAnalyticsRepository
|
||||||
|
import com.osglab.account.features.admin.stats.services.AdminProductAnalyticsService
|
||||||
import com.osglab.account.features.admin.stats.services.AdminStatsService
|
import com.osglab.account.features.admin.stats.services.AdminStatsService
|
||||||
import com.osglab.account.features.admin.users.repositories.AdminUsersRepository
|
import com.osglab.account.features.admin.users.repositories.AdminUsersRepository
|
||||||
import com.osglab.account.features.admin.users.repositories.ExposedAdminUsersRepository
|
import com.osglab.account.features.admin.users.repositories.ExposedAdminUsersRepository
|
||||||
@@ -36,6 +39,12 @@ import com.osglab.account.features.account.AppleAccountReauthenticator
|
|||||||
import com.osglab.account.features.account.AppleRevocationOutboxProcessor
|
import com.osglab.account.features.account.AppleRevocationOutboxProcessor
|
||||||
import com.osglab.account.features.account.ExposedAccountRepository
|
import com.osglab.account.features.account.ExposedAccountRepository
|
||||||
import com.osglab.account.features.account.accountRoutes
|
import com.osglab.account.features.account.accountRoutes
|
||||||
|
import com.osglab.account.features.analytics.repositories.AnalyticsRepository
|
||||||
|
import com.osglab.account.features.analytics.repositories.ExposedAnalyticsRepository
|
||||||
|
import com.osglab.account.features.analytics.routes.analyticsRoutes
|
||||||
|
import com.osglab.account.features.analytics.services.AnalyticsMaintenanceService
|
||||||
|
import com.osglab.account.features.analytics.services.AnalyticsService
|
||||||
|
import com.osglab.account.features.analytics.services.DefaultAnalyticsService
|
||||||
import com.osglab.account.features.appleevents.AppleEventService
|
import com.osglab.account.features.appleevents.AppleEventService
|
||||||
import com.osglab.account.features.appleevents.AppleEventRepository
|
import com.osglab.account.features.appleevents.AppleEventRepository
|
||||||
import com.osglab.account.features.appleevents.AppleEventVerifier
|
import com.osglab.account.features.appleevents.AppleEventVerifier
|
||||||
@@ -102,6 +111,7 @@ import com.osglab.account.features.integrity.UnavailableAppleDeviceCheckClient
|
|||||||
import com.osglab.account.features.integrity.createDeviceCheckClient
|
import com.osglab.account.features.integrity.createDeviceCheckClient
|
||||||
import com.osglab.account.features.integrity.integrityRoutes
|
import com.osglab.account.features.integrity.integrityRoutes
|
||||||
import com.osglab.account.features.inviteweb.InviteWebConfig
|
import com.osglab.account.features.inviteweb.InviteWebConfig
|
||||||
|
import com.osglab.account.features.inviteweb.InviteOpenRecorder
|
||||||
import com.osglab.account.features.inviteweb.ReferralLookupPort
|
import com.osglab.account.features.inviteweb.ReferralLookupPort
|
||||||
import com.osglab.account.features.inviteweb.configureInviteWebRoutes
|
import com.osglab.account.features.inviteweb.configureInviteWebRoutes
|
||||||
import com.osglab.account.features.referrals.domain.ReferralException
|
import com.osglab.account.features.referrals.domain.ReferralException
|
||||||
@@ -261,6 +271,13 @@ fun Application.module() {
|
|||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// Durable outbox state is retried; never log sensitive token material.
|
// Durable outbox state is retried; never log sensitive token material.
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
koin.get<AnalyticsMaintenanceService>().purgeStaleAnonymousInstallations()
|
||||||
|
} catch (exception: CancellationException) {
|
||||||
|
throw exception
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Anonymous analytics cleanup is bounded and retried on the next cycle.
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
koin.get<GatewayReconciliationService>().reconcile()
|
koin.get<GatewayReconciliationService>().reconcile()
|
||||||
} catch (exception: CancellationException) {
|
} catch (exception: CancellationException) {
|
||||||
@@ -308,7 +325,8 @@ fun Application.module() {
|
|||||||
}
|
}
|
||||||
rateLimit(PUBLIC_RATE_LIMIT) {
|
rateLimit(PUBLIC_RATE_LIMIT) {
|
||||||
appleEventRoutes(koin.get())
|
appleEventRoutes(koin.get())
|
||||||
configureInviteWebRoutes(koin.get(), koin.get())
|
analyticsRoutes(koin.get())
|
||||||
|
configureInviteWebRoutes(koin.get(), koin.get(), koin.get())
|
||||||
integrityRoutes(koin.get())
|
integrityRoutes(koin.get())
|
||||||
}
|
}
|
||||||
if (appConfig.admin.enabled) {
|
if (appConfig.admin.enabled) {
|
||||||
@@ -319,6 +337,7 @@ fun Application.module() {
|
|||||||
authService = koin.get(),
|
authService = koin.get(),
|
||||||
sessionService = koin.get(),
|
sessionService = koin.get(),
|
||||||
statsService = koin.get(),
|
statsService = koin.get(),
|
||||||
|
productAnalyticsService = koin.get(),
|
||||||
usersService = koin.get(),
|
usersService = koin.get(),
|
||||||
grantService = koin.get(),
|
grantService = koin.get(),
|
||||||
operatorService = koin.get(),
|
operatorService = koin.get(),
|
||||||
@@ -393,6 +412,8 @@ fun accountServerModule(config: AppConfig): Module = module {
|
|||||||
single { AdminAuditService(get()) }
|
single { AdminAuditService(get()) }
|
||||||
single<AdminStatsRepository> { ExposedAdminStatsRepository(get()) }
|
single<AdminStatsRepository> { ExposedAdminStatsRepository(get()) }
|
||||||
single { AdminStatsService(get()) }
|
single { AdminStatsService(get()) }
|
||||||
|
single<AdminProductAnalyticsRepository> { ExposedAdminProductAnalyticsRepository(get()) }
|
||||||
|
single { AdminProductAnalyticsService(get()) }
|
||||||
single<AdminUsersRepository> { ExposedAdminUsersRepository(get()) }
|
single<AdminUsersRepository> { ExposedAdminUsersRepository(get()) }
|
||||||
single { AdminUsersService(get()) }
|
single { AdminUsersService(get()) }
|
||||||
single { AdminGrantService(get()) }
|
single { AdminGrantService(get()) }
|
||||||
@@ -422,6 +443,14 @@ fun accountServerModule(config: AppConfig): Module = module {
|
|||||||
single<AuthRepository> { ExposedAuthRepository(get()) }
|
single<AuthRepository> { ExposedAuthRepository(get()) }
|
||||||
single { SessionAccessAuthenticator(get(), get()) }
|
single { SessionAccessAuthenticator(get(), get()) }
|
||||||
single<AccountRepository> { ExposedAccountRepository(get(), get()) }
|
single<AccountRepository> { ExposedAccountRepository(get(), get()) }
|
||||||
|
single<AnalyticsRepository> { ExposedAnalyticsRepository(get()) }
|
||||||
|
single<AnalyticsService> { DefaultAnalyticsService(get()) }
|
||||||
|
single { AnalyticsMaintenanceService(get()) }
|
||||||
|
single<InviteOpenRecorder> {
|
||||||
|
InviteOpenRecorder {
|
||||||
|
get<AnalyticsRepository>().recordInvitePageOpen(Instant.now())
|
||||||
|
}
|
||||||
|
}
|
||||||
single<AppleEventRepository> { ExposedAppleEventRepository(get(), get(), config.antiAbuse) }
|
single<AppleEventRepository> { ExposedAppleEventRepository(get(), get(), config.antiAbuse) }
|
||||||
single { AppleEventVerifier(config.apple, get()) }
|
single { AppleEventVerifier(config.apple, get()) }
|
||||||
single { AppleEventService(get(), get()) }
|
single { AppleEventService(get(), get()) }
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import com.osglab.account.features.admin.services.AdminOperatorService
|
|||||||
import com.osglab.account.features.admin.services.AdminSessionService
|
import com.osglab.account.features.admin.services.AdminSessionService
|
||||||
import com.osglab.account.features.admin.stats.models.AdminStatsDto
|
import com.osglab.account.features.admin.stats.models.AdminStatsDto
|
||||||
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
|
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
|
||||||
|
import com.osglab.account.features.admin.stats.services.AdminProductAnalyticsService
|
||||||
import com.osglab.account.features.admin.stats.services.AdminStatsService
|
import com.osglab.account.features.admin.stats.services.AdminStatsService
|
||||||
import com.osglab.account.features.admin.users.models.AdminUserDetailDto
|
import com.osglab.account.features.admin.users.models.AdminUserDetailDto
|
||||||
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
|
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
|
||||||
@@ -59,6 +60,7 @@ fun Route.adminApiRoutes(
|
|||||||
authService: AdminAuthService,
|
authService: AdminAuthService,
|
||||||
sessionService: AdminSessionService,
|
sessionService: AdminSessionService,
|
||||||
statsService: AdminStatsService,
|
statsService: AdminStatsService,
|
||||||
|
productAnalyticsService: AdminProductAnalyticsService,
|
||||||
usersService: AdminUsersService,
|
usersService: AdminUsersService,
|
||||||
grantService: AdminGrantService,
|
grantService: AdminGrantService,
|
||||||
operatorService: AdminOperatorService,
|
operatorService: AdminOperatorService,
|
||||||
@@ -158,6 +160,16 @@ fun Route.adminApiRoutes(
|
|||||||
call.respond(stats.toReferralResponse())
|
call.respond(stats.toReferralResponse())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get("/analytics") {
|
||||||
|
if (call.requirePrincipal(sessionService) == null) return@get
|
||||||
|
val window = parseAdminStatsRange(call.request.queryParameters["range"], clock)
|
||||||
|
?: run {
|
||||||
|
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||||
|
return@get
|
||||||
|
}
|
||||||
|
call.respond(productAnalyticsService.get(window.first, window.second))
|
||||||
|
}
|
||||||
|
|
||||||
get("/users") {
|
get("/users") {
|
||||||
if (
|
if (
|
||||||
call.requireRole(
|
call.requireRole(
|
||||||
@@ -500,6 +512,11 @@ private suspend fun AdminStatsService.getRange(
|
|||||||
range: String?,
|
range: String?,
|
||||||
clock: Clock,
|
clock: Clock,
|
||||||
): AdminStatsDto? {
|
): AdminStatsDto? {
|
||||||
|
val window = parseAdminStatsRange(range, clock) ?: return null
|
||||||
|
return get(window.first, window.second)
|
||||||
|
}
|
||||||
|
|
||||||
|
private 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
|
||||||
@@ -507,7 +524,7 @@ private suspend fun AdminStatsService.getRange(
|
|||||||
else -> return null
|
else -> return null
|
||||||
}
|
}
|
||||||
val until = clock.instant()
|
val until = clock.instant()
|
||||||
return get(until.minus(Duration.ofDays(days)), until)
|
return until.minus(Duration.ofDays(days)) to until
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun ApplicationCall.requirePrincipal(
|
private suspend fun ApplicationCall.requirePrincipal(
|
||||||
@@ -657,8 +674,6 @@ private fun AdminStatsDto.toReferralResponse(): AdminReferralResponse =
|
|||||||
AdminFunnelResponse("邀请码创建", referralFunnel.codesCreated),
|
AdminFunnelResponse("邀请码创建", referralFunnel.codesCreated),
|
||||||
AdminFunnelResponse("成功绑定", referralFunnel.bindings),
|
AdminFunnelResponse("成功绑定", referralFunnel.bindings),
|
||||||
AdminFunnelResponse("有效使用并奖励", referralFunnel.rewardedBindings),
|
AdminFunnelResponse("有效使用并奖励", referralFunnel.rewardedBindings),
|
||||||
AdminFunnelResponse("待资格确认", referralFunnel.pendingBindings),
|
|
||||||
AdminFunnelResponse("不符合奖励条件", referralFunnel.ineligibleBindings),
|
|
||||||
),
|
),
|
||||||
ranking = referralRanking.map {
|
ranking = referralRanking.map {
|
||||||
AdminReferralRankResponse(
|
AdminReferralRankResponse(
|
||||||
|
|||||||
+113
@@ -0,0 +1,113 @@
|
|||||||
|
package com.osglab.account.features.admin.stats.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AdminAnalyticsRateDto(
|
||||||
|
val numerator: Long,
|
||||||
|
val denominator: Long,
|
||||||
|
val percent: Double?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AdminAnalyticsChannelDto(
|
||||||
|
val channel: String,
|
||||||
|
val installations: Long,
|
||||||
|
val activated: Long,
|
||||||
|
val activationRate: AdminAnalyticsRateDto,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AdminAnalyticsCohortDto(
|
||||||
|
val cohortDate: String,
|
||||||
|
val size: Long,
|
||||||
|
val d1: AdminAnalyticsRateDto?,
|
||||||
|
val d7: AdminAnalyticsRateDto?,
|
||||||
|
val d30: AdminAnalyticsRateDto?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AdminAnalyticsFeatureUsageDto(
|
||||||
|
val feature: String,
|
||||||
|
val executionMode: String,
|
||||||
|
val users: Long,
|
||||||
|
val successes: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AdminAnalyticsFunnelStepDto(
|
||||||
|
val label: String,
|
||||||
|
val count: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AdminAnalyticsPeriodDto(
|
||||||
|
val from: String,
|
||||||
|
val until: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AdminAnalyticsNorthStarDto(
|
||||||
|
val weeklyAiActiveUsers: Long,
|
||||||
|
val previousWeeklyAiActiveUsers: Long,
|
||||||
|
val weekOverWeekPercent: Double?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AdminAnalyticsGrowthDto(
|
||||||
|
val newInstallations: Long,
|
||||||
|
val newAccounts: Long,
|
||||||
|
val activation24h: AdminAnalyticsRateDto,
|
||||||
|
val medianTimeToValueMinutes: Double?,
|
||||||
|
val channels: List<AdminAnalyticsChannelDto>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AdminAnalyticsActivityDto(
|
||||||
|
val dau: Long,
|
||||||
|
val wau: Long,
|
||||||
|
val mau: Long,
|
||||||
|
val stickinessPercent: Double?,
|
||||||
|
val successfulAiRequests: Long,
|
||||||
|
val successfulRequestsPerActiveUser: Double?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AdminAnalyticsConsumptionDto(
|
||||||
|
val totalCredits: Long,
|
||||||
|
val averageDailyCreditsPerActiveUser: Double?,
|
||||||
|
val medianUserDailyCredits: Double?,
|
||||||
|
val averageCreditsPerManagedRequest: Double?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AdminAnalyticsMonetizationDto(
|
||||||
|
val payingUsers: Long,
|
||||||
|
val purchases: Long,
|
||||||
|
val creditsPurchased: Long,
|
||||||
|
val conversion7d: AdminAnalyticsRateDto,
|
||||||
|
val conversion30d: AdminAnalyticsRateDto,
|
||||||
|
val repeatPurchaseRate: AdminAnalyticsRateDto,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AdminAnalyticsGuardrailsDto(
|
||||||
|
val clientAiSuccessRate: AdminAnalyticsRateDto,
|
||||||
|
val managedSuccessRate: AdminAnalyticsRateDto,
|
||||||
|
val creditBlockedUsers: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AdminProductAnalyticsDto(
|
||||||
|
val period: AdminAnalyticsPeriodDto,
|
||||||
|
val northStar: AdminAnalyticsNorthStarDto,
|
||||||
|
val growth: AdminAnalyticsGrowthDto,
|
||||||
|
val activity: AdminAnalyticsActivityDto,
|
||||||
|
val consumption: AdminAnalyticsConsumptionDto,
|
||||||
|
val monetization: AdminAnalyticsMonetizationDto,
|
||||||
|
val growthFunnel: List<AdminAnalyticsFunnelStepDto>,
|
||||||
|
val retention: List<AdminAnalyticsCohortDto>,
|
||||||
|
val aiFeatures: List<AdminAnalyticsFeatureUsageDto>,
|
||||||
|
val referralFunnel: List<AdminAnalyticsFunnelStepDto>,
|
||||||
|
val guardrails: AdminAnalyticsGuardrailsDto,
|
||||||
|
)
|
||||||
+829
@@ -0,0 +1,829 @@
|
|||||||
|
package com.osglab.account.features.admin.stats.repositories
|
||||||
|
|
||||||
|
import com.osglab.account.config.DatabaseFactory
|
||||||
|
import org.jetbrains.exposed.v1.core.IColumnType
|
||||||
|
import org.jetbrains.exposed.v1.javatime.JavaInstantColumnType
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.sql.ResultSet
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
data class AdminAnalyticsWindow(
|
||||||
|
val from: Instant,
|
||||||
|
val until: Instant,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(from < until)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AdminAnalyticsCountRow(
|
||||||
|
val numerator: Long,
|
||||||
|
val denominator: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AdminAnalyticsChannelRow(
|
||||||
|
val channel: String,
|
||||||
|
val installations: Long,
|
||||||
|
val activated: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AdminAnalyticsCohortRow(
|
||||||
|
val cohortDate: LocalDate,
|
||||||
|
val size: Long,
|
||||||
|
val d1: Long,
|
||||||
|
val d7: Long,
|
||||||
|
val d30: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AdminAnalyticsFeatureRow(
|
||||||
|
val feature: String,
|
||||||
|
val executionMode: String,
|
||||||
|
val users: Long,
|
||||||
|
val successes: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AdminAnalyticsConsumptionRow(
|
||||||
|
val totalCredits: Long,
|
||||||
|
val managedRequests: Long,
|
||||||
|
val averageDailyCreditsPerActiveUser: Double?,
|
||||||
|
val medianUserDailyCredits: Double?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AdminAnalyticsMonetizationRow(
|
||||||
|
val payingUsers: Long,
|
||||||
|
val purchases: Long,
|
||||||
|
val creditsPurchased: Long,
|
||||||
|
val conversion7d: AdminAnalyticsCountRow,
|
||||||
|
val conversion30d: AdminAnalyticsCountRow,
|
||||||
|
val repeatPurchase: AdminAnalyticsCountRow,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AdminAnalyticsReferralRow(
|
||||||
|
val shared: Long,
|
||||||
|
val opened: Long,
|
||||||
|
val bound: Long,
|
||||||
|
val activated: Long,
|
||||||
|
val rewarded: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AdminAnalyticsGuardrailRow(
|
||||||
|
val clientSuccess: AdminAnalyticsCountRow,
|
||||||
|
val managedSuccess: AdminAnalyticsCountRow,
|
||||||
|
val creditBlockedUsers: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AdminAnalyticsGrowthFunnelRow(
|
||||||
|
val opened: Long,
|
||||||
|
val registered: Long,
|
||||||
|
val activated: Long,
|
||||||
|
val retainedD7: Long,
|
||||||
|
val purchased: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AdminProductAnalyticsSnapshot(
|
||||||
|
val currentWeeklyUsers: Long,
|
||||||
|
val previousWeeklyUsers: Long,
|
||||||
|
val newInstallations: Long,
|
||||||
|
val newAccounts: Long,
|
||||||
|
val activation24h: AdminAnalyticsCountRow,
|
||||||
|
val medianTimeToValueMinutes: Double?,
|
||||||
|
val channels: List<AdminAnalyticsChannelRow>,
|
||||||
|
val dau: Long,
|
||||||
|
val wau: Long,
|
||||||
|
val mau: Long,
|
||||||
|
val periodActiveUsers: Long,
|
||||||
|
val successfulAiRequests: Long,
|
||||||
|
val consumption: AdminAnalyticsConsumptionRow,
|
||||||
|
val monetization: AdminAnalyticsMonetizationRow,
|
||||||
|
val growthFunnel: AdminAnalyticsGrowthFunnelRow,
|
||||||
|
val retention: List<AdminAnalyticsCohortRow>,
|
||||||
|
val features: List<AdminAnalyticsFeatureRow>,
|
||||||
|
val referrals: AdminAnalyticsReferralRow,
|
||||||
|
val guardrails: AdminAnalyticsGuardrailRow,
|
||||||
|
)
|
||||||
|
|
||||||
|
interface AdminProductAnalyticsRepository {
|
||||||
|
suspend fun load(
|
||||||
|
range: AdminAnalyticsWindow,
|
||||||
|
currentWeek: AdminAnalyticsWindow,
|
||||||
|
previousWeek: AdminAnalyticsWindow,
|
||||||
|
): AdminProductAnalyticsSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExposedAdminProductAnalyticsRepository(
|
||||||
|
private val databaseFactory: DatabaseFactory,
|
||||||
|
) : AdminProductAnalyticsRepository {
|
||||||
|
override suspend fun load(
|
||||||
|
range: AdminAnalyticsWindow,
|
||||||
|
currentWeek: AdminAnalyticsWindow,
|
||||||
|
previousWeek: AdminAnalyticsWindow,
|
||||||
|
): AdminProductAnalyticsSnapshot = databaseFactory.query {
|
||||||
|
val activation = loadActivation(range)
|
||||||
|
AdminProductAnalyticsSnapshot(
|
||||||
|
currentWeeklyUsers = loadValueActiveUsers(currentWeek),
|
||||||
|
previousWeeklyUsers = loadValueActiveUsers(previousWeek),
|
||||||
|
newInstallations = activation.denominator,
|
||||||
|
newAccounts = loadNewAccounts(range),
|
||||||
|
activation24h = AdminAnalyticsCountRow(activation.activated, activation.denominator),
|
||||||
|
medianTimeToValueMinutes = activation.medianMinutes,
|
||||||
|
channels = loadChannels(range),
|
||||||
|
dau = loadValueActiveUsers(
|
||||||
|
AdminAnalyticsWindow(range.until.minusSeconds(DAY_SECONDS), range.until),
|
||||||
|
),
|
||||||
|
wau = loadValueActiveUsers(
|
||||||
|
AdminAnalyticsWindow(range.until.minusSeconds(7 * DAY_SECONDS), range.until),
|
||||||
|
),
|
||||||
|
mau = loadValueActiveUsers(
|
||||||
|
AdminAnalyticsWindow(range.until.minusSeconds(30 * DAY_SECONDS), range.until),
|
||||||
|
),
|
||||||
|
periodActiveUsers = loadValueActiveUsers(range),
|
||||||
|
successfulAiRequests = loadSuccessfulAiRequests(range),
|
||||||
|
consumption = loadConsumption(range),
|
||||||
|
monetization = loadMonetization(range),
|
||||||
|
growthFunnel = loadGrowthFunnel(range),
|
||||||
|
retention = loadRetention(range),
|
||||||
|
features = loadFeatures(range),
|
||||||
|
referrals = loadReferrals(range),
|
||||||
|
guardrails = loadGuardrails(range),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadValueActiveUsers(window: AdminAnalyticsWindow): Long =
|
||||||
|
querySingle(
|
||||||
|
valueEventsCte() +
|
||||||
|
"""
|
||||||
|
SELECT COUNT(DISTINCT identity_key) AS aggregate_value
|
||||||
|
FROM value_events
|
||||||
|
WHERE occurred_at >= ? AND occurred_at < ?
|
||||||
|
""",
|
||||||
|
window.arguments(),
|
||||||
|
) { it.exactLong("aggregate_value") }
|
||||||
|
|
||||||
|
private fun loadNewAccounts(range: AdminAnalyticsWindow): Long =
|
||||||
|
querySingle(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS aggregate_value
|
||||||
|
FROM accounts
|
||||||
|
WHERE created_at >= ? AND created_at < ?
|
||||||
|
""",
|
||||||
|
range.arguments(),
|
||||||
|
) { it.exactLong("aggregate_value") }
|
||||||
|
|
||||||
|
private fun loadActivation(range: AdminAnalyticsWindow): ActivationRow =
|
||||||
|
querySingle(
|
||||||
|
"""
|
||||||
|
WITH first_open AS (
|
||||||
|
SELECT installation_hash, MIN(occurred_at) AS opened_at
|
||||||
|
FROM product_analytics_events
|
||||||
|
WHERE event_name = 'FIRST_OPEN'
|
||||||
|
AND occurred_at >= ? AND occurred_at < ?
|
||||||
|
GROUP BY installation_hash
|
||||||
|
),
|
||||||
|
client_value AS (
|
||||||
|
SELECT installation_hash, MIN(occurred_at) AS value_at
|
||||||
|
FROM product_analytics_events
|
||||||
|
WHERE event_name = 'AI_FEATURE_SUCCEEDED'
|
||||||
|
AND execution_mode IN ('LOCAL', 'BYOK')
|
||||||
|
GROUP BY installation_hash
|
||||||
|
),
|
||||||
|
managed_value AS (
|
||||||
|
SELECT i.installation_hash, MIN(u.created_at) AS value_at
|
||||||
|
FROM product_analytics_installations i
|
||||||
|
JOIN credit_usage_records u ON u.user_id = i.account_id
|
||||||
|
GROUP BY i.installation_hash
|
||||||
|
),
|
||||||
|
first_value_by_install AS (
|
||||||
|
SELECT installation_hash, MIN(value_at) AS value_at
|
||||||
|
FROM (
|
||||||
|
SELECT * FROM client_value
|
||||||
|
UNION ALL
|
||||||
|
SELECT * FROM managed_value
|
||||||
|
) values_by_source
|
||||||
|
GROUP BY installation_hash
|
||||||
|
),
|
||||||
|
activated AS (
|
||||||
|
SELECT
|
||||||
|
o.installation_hash,
|
||||||
|
TIMESTAMPDIFF(SECOND, o.opened_at, v.value_at) AS seconds_to_value
|
||||||
|
FROM first_open o
|
||||||
|
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 (
|
||||||
|
SELECT
|
||||||
|
seconds_to_value,
|
||||||
|
ROW_NUMBER() OVER (ORDER BY seconds_to_value) AS row_number_value,
|
||||||
|
COUNT(*) OVER () AS total_rows
|
||||||
|
FROM activated
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM first_open) AS denominator_value,
|
||||||
|
(SELECT COUNT(*) FROM activated) AS activated_value,
|
||||||
|
(
|
||||||
|
SELECT AVG(seconds_to_value) / 60.0
|
||||||
|
FROM ranked
|
||||||
|
WHERE row_number_value IN (
|
||||||
|
FLOOR((total_rows + 1) / 2),
|
||||||
|
FLOOR((total_rows + 2) / 2)
|
||||||
|
)
|
||||||
|
) AS median_minutes
|
||||||
|
""",
|
||||||
|
range.arguments(),
|
||||||
|
) {
|
||||||
|
ActivationRow(
|
||||||
|
denominator = it.exactLong("denominator_value"),
|
||||||
|
activated = it.exactLong("activated_value"),
|
||||||
|
medianMinutes = it.nullableDouble("median_minutes"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadChannels(range: AdminAnalyticsWindow): List<AdminAnalyticsChannelRow> =
|
||||||
|
queryRows(
|
||||||
|
"""
|
||||||
|
WITH first_open AS (
|
||||||
|
SELECT
|
||||||
|
e.installation_hash,
|
||||||
|
MIN(e.occurred_at) AS opened_at,
|
||||||
|
COALESCE(
|
||||||
|
MAX(
|
||||||
|
CASE
|
||||||
|
WHEN e.acquisition_channel <> 'UNKNOWN'
|
||||||
|
THEN e.acquisition_channel
|
||||||
|
END
|
||||||
|
),
|
||||||
|
'UNKNOWN'
|
||||||
|
) AS channel
|
||||||
|
FROM product_analytics_events e
|
||||||
|
WHERE e.event_name = 'FIRST_OPEN'
|
||||||
|
AND e.occurred_at >= ? AND e.occurred_at < ?
|
||||||
|
GROUP BY e.installation_hash
|
||||||
|
),
|
||||||
|
client_value AS (
|
||||||
|
SELECT installation_hash, MIN(occurred_at) AS value_at
|
||||||
|
FROM product_analytics_events
|
||||||
|
WHERE event_name = 'AI_FEATURE_SUCCEEDED'
|
||||||
|
AND execution_mode IN ('LOCAL', 'BYOK')
|
||||||
|
GROUP BY installation_hash
|
||||||
|
),
|
||||||
|
managed_value AS (
|
||||||
|
SELECT i.installation_hash, MIN(u.created_at) AS value_at
|
||||||
|
FROM product_analytics_installations i
|
||||||
|
JOIN credit_usage_records u ON u.user_id = i.account_id
|
||||||
|
GROUP BY i.installation_hash
|
||||||
|
),
|
||||||
|
first_value_by_install AS (
|
||||||
|
SELECT installation_hash, MIN(value_at) AS value_at
|
||||||
|
FROM (
|
||||||
|
SELECT * FROM client_value
|
||||||
|
UNION ALL
|
||||||
|
SELECT * FROM managed_value
|
||||||
|
) values_by_source
|
||||||
|
GROUP BY installation_hash
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
o.channel,
|
||||||
|
COUNT(*) AS installations,
|
||||||
|
SUM(
|
||||||
|
CASE
|
||||||
|
WHEN v.value_at >= o.opened_at
|
||||||
|
AND v.value_at <= DATE_ADD(o.opened_at, INTERVAL 24 HOUR)
|
||||||
|
THEN 1 ELSE 0
|
||||||
|
END
|
||||||
|
) AS activated
|
||||||
|
FROM first_open o
|
||||||
|
LEFT JOIN first_value_by_install v ON v.installation_hash = o.installation_hash
|
||||||
|
GROUP BY o.channel
|
||||||
|
ORDER BY o.channel
|
||||||
|
""",
|
||||||
|
range.arguments(),
|
||||||
|
) {
|
||||||
|
AdminAnalyticsChannelRow(
|
||||||
|
channel = it.getString("channel"),
|
||||||
|
installations = it.exactLong("installations"),
|
||||||
|
activated = it.exactLong("activated"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadSuccessfulAiRequests(range: AdminAnalyticsWindow): Long =
|
||||||
|
querySingle(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM credit_usage_records
|
||||||
|
WHERE created_at >= ? AND created_at < ?
|
||||||
|
) + (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM product_analytics_events
|
||||||
|
WHERE occurred_at >= ? AND occurred_at < ?
|
||||||
|
AND event_name = 'AI_FEATURE_SUCCEEDED'
|
||||||
|
AND execution_mode IN ('LOCAL', 'BYOK')
|
||||||
|
) AS aggregate_value
|
||||||
|
""",
|
||||||
|
range.arguments(repetitions = 2),
|
||||||
|
) { it.exactLong("aggregate_value") }
|
||||||
|
|
||||||
|
private fun loadConsumption(range: AdminAnalyticsWindow): AdminAnalyticsConsumptionRow =
|
||||||
|
querySingle(
|
||||||
|
"""
|
||||||
|
WITH user_days AS (
|
||||||
|
SELECT
|
||||||
|
user_id,
|
||||||
|
DATE(created_at) AS usage_date,
|
||||||
|
SUM(charged_credits) AS daily_credits
|
||||||
|
FROM credit_usage_records
|
||||||
|
WHERE created_at >= ? AND created_at < ?
|
||||||
|
GROUP BY user_id, DATE(created_at)
|
||||||
|
),
|
||||||
|
day_totals AS (
|
||||||
|
SELECT
|
||||||
|
usage_date,
|
||||||
|
SUM(daily_credits) AS credits,
|
||||||
|
COUNT(*) AS users
|
||||||
|
FROM user_days
|
||||||
|
GROUP BY usage_date
|
||||||
|
),
|
||||||
|
ranked_user_days AS (
|
||||||
|
SELECT
|
||||||
|
daily_credits,
|
||||||
|
ROW_NUMBER() OVER (ORDER BY daily_credits) AS row_number_value,
|
||||||
|
COUNT(*) OVER () AS total_rows
|
||||||
|
FROM user_days
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
COALESCE((SELECT SUM(daily_credits) FROM user_days), 0) AS total_credits,
|
||||||
|
COALESCE((SELECT COUNT(*) FROM credit_usage_records
|
||||||
|
WHERE created_at >= ? AND created_at < ?), 0) AS managed_requests,
|
||||||
|
(SELECT AVG(credits / NULLIF(users, 0)) FROM day_totals)
|
||||||
|
AS average_daily_per_user,
|
||||||
|
(
|
||||||
|
SELECT AVG(daily_credits)
|
||||||
|
FROM ranked_user_days
|
||||||
|
WHERE row_number_value IN (
|
||||||
|
FLOOR((total_rows + 1) / 2),
|
||||||
|
FLOOR((total_rows + 2) / 2)
|
||||||
|
)
|
||||||
|
) AS median_user_day
|
||||||
|
""",
|
||||||
|
range.arguments(repetitions = 2),
|
||||||
|
) {
|
||||||
|
AdminAnalyticsConsumptionRow(
|
||||||
|
totalCredits = it.exactLong("total_credits"),
|
||||||
|
managedRequests = it.exactLong("managed_requests"),
|
||||||
|
averageDailyCreditsPerActiveUser = it.nullableDouble("average_daily_per_user"),
|
||||||
|
medianUserDailyCredits = it.nullableDouble("median_user_day"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadMonetization(range: AdminAnalyticsWindow): AdminAnalyticsMonetizationRow {
|
||||||
|
val sevenDayMaturity = range.until.minusSeconds(7 * DAY_SECONDS)
|
||||||
|
val thirtyDayMaturity = range.until.minusSeconds(30 * DAY_SECONDS)
|
||||||
|
return querySingle(
|
||||||
|
"""
|
||||||
|
WITH first_purchase AS (
|
||||||
|
SELECT user_id, MIN(purchased_at) AS first_purchased_at, COUNT(*) AS lifetime_purchases
|
||||||
|
FROM storekit_credit_purchases
|
||||||
|
GROUP BY user_id
|
||||||
|
),
|
||||||
|
period_payers AS (
|
||||||
|
SELECT user_id, COUNT(*) AS period_purchases
|
||||||
|
FROM storekit_credit_purchases
|
||||||
|
WHERE purchased_at >= ? AND purchased_at < ?
|
||||||
|
GROUP BY user_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM period_payers) AS paying_users,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM storekit_credit_purchases
|
||||||
|
WHERE purchased_at >= ? AND purchased_at < ?
|
||||||
|
) AS purchases,
|
||||||
|
(
|
||||||
|
SELECT COALESCE(SUM(credits_granted), 0)
|
||||||
|
FROM storekit_credit_purchases
|
||||||
|
WHERE purchased_at >= ? AND purchased_at < ?
|
||||||
|
) AS credits_purchased,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM accounts
|
||||||
|
WHERE created_at >= ? AND created_at < ?
|
||||||
|
) AS conversion_7_denominator,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM accounts a
|
||||||
|
JOIN first_purchase p ON p.user_id = a.id
|
||||||
|
WHERE a.created_at >= ? AND a.created_at < ?
|
||||||
|
AND p.first_purchased_at <= DATE_ADD(a.created_at, INTERVAL 7 DAY)
|
||||||
|
) AS conversion_7_numerator,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM accounts
|
||||||
|
WHERE created_at >= ? AND created_at < ?
|
||||||
|
) AS conversion_30_denominator,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM accounts a
|
||||||
|
JOIN first_purchase p ON p.user_id = a.id
|
||||||
|
WHERE a.created_at >= ? AND a.created_at < ?
|
||||||
|
AND p.first_purchased_at <= DATE_ADD(a.created_at, INTERVAL 30 DAY)
|
||||||
|
) AS conversion_30_numerator,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM period_payers pp
|
||||||
|
JOIN first_purchase fp ON fp.user_id = pp.user_id
|
||||||
|
WHERE fp.lifetime_purchases >= 2
|
||||||
|
) AS repeat_numerator,
|
||||||
|
(SELECT COUNT(*) FROM period_payers) AS repeat_denominator
|
||||||
|
""",
|
||||||
|
buildList {
|
||||||
|
addAll(range.arguments(repetitions = 3))
|
||||||
|
addAll(maturedWindowArguments(range.from, sevenDayMaturity))
|
||||||
|
addAll(maturedWindowArguments(range.from, sevenDayMaturity))
|
||||||
|
addAll(maturedWindowArguments(range.from, thirtyDayMaturity))
|
||||||
|
addAll(maturedWindowArguments(range.from, thirtyDayMaturity))
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
AdminAnalyticsMonetizationRow(
|
||||||
|
payingUsers = it.exactLong("paying_users"),
|
||||||
|
purchases = it.exactLong("purchases"),
|
||||||
|
creditsPurchased = it.exactLong("credits_purchased"),
|
||||||
|
conversion7d = AdminAnalyticsCountRow(
|
||||||
|
it.exactLong("conversion_7_numerator"),
|
||||||
|
it.exactLong("conversion_7_denominator"),
|
||||||
|
),
|
||||||
|
conversion30d = AdminAnalyticsCountRow(
|
||||||
|
it.exactLong("conversion_30_numerator"),
|
||||||
|
it.exactLong("conversion_30_denominator"),
|
||||||
|
),
|
||||||
|
repeatPurchase = AdminAnalyticsCountRow(
|
||||||
|
it.exactLong("repeat_numerator"),
|
||||||
|
it.exactLong("repeat_denominator"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadGrowthFunnel(range: AdminAnalyticsWindow): AdminAnalyticsGrowthFunnelRow =
|
||||||
|
querySingle(
|
||||||
|
"""
|
||||||
|
WITH first_open AS (
|
||||||
|
SELECT installation_hash, MIN(occurred_at) AS opened_at
|
||||||
|
FROM product_analytics_events
|
||||||
|
WHERE event_name = 'FIRST_OPEN'
|
||||||
|
AND occurred_at >= ? AND occurred_at < ?
|
||||||
|
GROUP BY installation_hash
|
||||||
|
),
|
||||||
|
client_values AS (
|
||||||
|
SELECT installation_hash, occurred_at
|
||||||
|
FROM product_analytics_events
|
||||||
|
WHERE event_name = 'AI_FEATURE_SUCCEEDED'
|
||||||
|
AND execution_mode IN ('LOCAL', 'BYOK')
|
||||||
|
),
|
||||||
|
managed_values AS (
|
||||||
|
SELECT i.installation_hash, u.created_at AS occurred_at
|
||||||
|
FROM product_analytics_installations i
|
||||||
|
JOIN credit_usage_records u ON u.user_id = i.account_id
|
||||||
|
),
|
||||||
|
values_by_install AS (
|
||||||
|
SELECT * FROM client_values
|
||||||
|
UNION ALL
|
||||||
|
SELECT * FROM managed_values
|
||||||
|
),
|
||||||
|
activated AS (
|
||||||
|
SELECT
|
||||||
|
o.installation_hash,
|
||||||
|
o.opened_at,
|
||||||
|
MIN(v.occurred_at) AS first_value_at
|
||||||
|
FROM first_open o
|
||||||
|
JOIN values_by_install v ON v.installation_hash = o.installation_hash
|
||||||
|
WHERE v.occurred_at >= o.opened_at
|
||||||
|
AND v.occurred_at <= DATE_ADD(o.opened_at, INTERVAL 24 HOUR)
|
||||||
|
GROUP BY o.installation_hash, o.opened_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM first_open) AS opened,
|
||||||
|
(
|
||||||
|
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 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),
|
||||||
|
) {
|
||||||
|
AdminAnalyticsGrowthFunnelRow(
|
||||||
|
opened = it.exactLong("opened"),
|
||||||
|
registered = it.exactLong("registered"),
|
||||||
|
activated = it.exactLong("activated"),
|
||||||
|
retainedD7 = it.exactLong("retained_d7"),
|
||||||
|
purchased = it.exactLong("purchased"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadRetention(range: AdminAnalyticsWindow): List<AdminAnalyticsCohortRow> =
|
||||||
|
queryRows(
|
||||||
|
valueEventsCte() +
|
||||||
|
"""
|
||||||
|
, first_value_by_identity AS (
|
||||||
|
SELECT identity_key, MIN(occurred_at) AS first_value_at
|
||||||
|
FROM value_events
|
||||||
|
GROUP BY identity_key
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
DATE(f.first_value_at) AS cohort_date,
|
||||||
|
COUNT(DISTINCT f.identity_key) AS cohort_size,
|
||||||
|
COUNT(
|
||||||
|
DISTINCT CASE
|
||||||
|
WHEN DATEDIFF(DATE(v.occurred_at), DATE(f.first_value_at)) = 1
|
||||||
|
THEN f.identity_key
|
||||||
|
END
|
||||||
|
) AS retained_d1,
|
||||||
|
COUNT(
|
||||||
|
DISTINCT CASE
|
||||||
|
WHEN DATEDIFF(DATE(v.occurred_at), DATE(f.first_value_at)) = 7
|
||||||
|
THEN f.identity_key
|
||||||
|
END
|
||||||
|
) AS retained_d7,
|
||||||
|
COUNT(
|
||||||
|
DISTINCT CASE
|
||||||
|
WHEN DATEDIFF(DATE(v.occurred_at), DATE(f.first_value_at)) = 30
|
||||||
|
THEN f.identity_key
|
||||||
|
END
|
||||||
|
) AS retained_d30
|
||||||
|
FROM first_value_by_identity f
|
||||||
|
LEFT JOIN value_events v ON v.identity_key = f.identity_key
|
||||||
|
WHERE f.first_value_at >= ? AND f.first_value_at < ?
|
||||||
|
GROUP BY DATE(f.first_value_at)
|
||||||
|
ORDER BY cohort_date DESC
|
||||||
|
""",
|
||||||
|
range.arguments(),
|
||||||
|
) {
|
||||||
|
AdminAnalyticsCohortRow(
|
||||||
|
cohortDate = it.getObject("cohort_date", LocalDate::class.java),
|
||||||
|
size = it.exactLong("cohort_size"),
|
||||||
|
d1 = it.exactLong("retained_d1"),
|
||||||
|
d7 = it.exactLong("retained_d7"),
|
||||||
|
d30 = it.exactLong("retained_d30"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadFeatures(range: AdminAnalyticsWindow): List<AdminAnalyticsFeatureRow> =
|
||||||
|
queryRows(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
e.feature,
|
||||||
|
e.execution_mode,
|
||||||
|
COUNT(
|
||||||
|
DISTINCT COALESCE(
|
||||||
|
CONCAT('a:', i.account_id),
|
||||||
|
CONCAT('i:', e.installation_hash)
|
||||||
|
)
|
||||||
|
) AS users,
|
||||||
|
COUNT(*) AS successes
|
||||||
|
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'
|
||||||
|
GROUP BY e.feature, e.execution_mode
|
||||||
|
ORDER BY successes DESC, e.feature, e.execution_mode
|
||||||
|
""",
|
||||||
|
range.arguments(),
|
||||||
|
) {
|
||||||
|
AdminAnalyticsFeatureRow(
|
||||||
|
feature = it.getString("feature"),
|
||||||
|
executionMode = it.getString("execution_mode"),
|
||||||
|
users = it.exactLong("users"),
|
||||||
|
successes = it.exactLong("successes"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadReferrals(range: AdminAnalyticsWindow): AdminAnalyticsReferralRow =
|
||||||
|
querySingle(
|
||||||
|
valueEventsCte() +
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
(
|
||||||
|
SELECT COUNT(DISTINCT installation_hash)
|
||||||
|
FROM product_analytics_events
|
||||||
|
WHERE event_name = 'REFERRAL_SHARED'
|
||||||
|
AND occurred_at >= ? AND occurred_at < ?
|
||||||
|
) AS shared,
|
||||||
|
(
|
||||||
|
SELECT COUNT(DISTINCT installation_hash)
|
||||||
|
FROM product_analytics_events
|
||||||
|
WHERE event_name = 'INVITE_OPENED'
|
||||||
|
AND occurred_at >= ? AND occurred_at < ?
|
||||||
|
) + (
|
||||||
|
SELECT COALESCE(SUM(counter_value), 0)
|
||||||
|
FROM product_analytics_daily_counters
|
||||||
|
WHERE counter_name = 'INVITE_PAGE_OPENED'
|
||||||
|
AND counter_date >= DATE(?) AND counter_date < DATE(?)
|
||||||
|
) AS opened,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM referral_bindings
|
||||||
|
WHERE bound_at >= ? AND bound_at < ?
|
||||||
|
) AS bound,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM referral_bindings r
|
||||||
|
WHERE r.bound_at >= ? AND r.bound_at < ?
|
||||||
|
AND 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,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM referral_bindings
|
||||||
|
WHERE bound_at >= ? AND bound_at < ?
|
||||||
|
AND reward_status = 'REWARDED'
|
||||||
|
) AS rewarded
|
||||||
|
""",
|
||||||
|
buildList {
|
||||||
|
addAll(range.arguments(repetitions = 5))
|
||||||
|
add(INSTANT_COLUMN_TYPE to range.until)
|
||||||
|
addAll(range.arguments())
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
AdminAnalyticsReferralRow(
|
||||||
|
shared = it.exactLong("shared"),
|
||||||
|
opened = it.exactLong("opened"),
|
||||||
|
bound = it.exactLong("bound"),
|
||||||
|
activated = it.exactLong("activated"),
|
||||||
|
rewarded = it.exactLong("rewarded"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadGuardrails(range: AdminAnalyticsWindow): AdminAnalyticsGuardrailRow =
|
||||||
|
querySingle(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM product_analytics_events
|
||||||
|
WHERE occurred_at >= ? AND occurred_at < ?
|
||||||
|
AND event_name = 'AI_FEATURE_SUCCEEDED'
|
||||||
|
) AS client_success,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM product_analytics_events
|
||||||
|
WHERE occurred_at >= ? AND occurred_at < ?
|
||||||
|
AND event_name IN ('AI_FEATURE_SUCCEEDED', 'AI_FEATURE_FAILED')
|
||||||
|
) AS client_terminal,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM provider_requests
|
||||||
|
WHERE completed_at >= ? AND completed_at < ?
|
||||||
|
AND status = 'SETTLED'
|
||||||
|
) AS managed_success,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM provider_requests
|
||||||
|
WHERE completed_at >= ? AND completed_at < ?
|
||||||
|
AND status IN ('SETTLED', 'RELEASED', 'MANUAL_REVIEW')
|
||||||
|
) AS managed_terminal,
|
||||||
|
(
|
||||||
|
SELECT COUNT(
|
||||||
|
DISTINCT COALESCE(
|
||||||
|
CONCAT('a:', i.account_id),
|
||||||
|
CONCAT('i:', e.installation_hash)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
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_FAILED'
|
||||||
|
AND e.failure_category = 'INSUFFICIENT_CREDITS'
|
||||||
|
) AS credit_blocked_users
|
||||||
|
""",
|
||||||
|
range.arguments(repetitions = 5),
|
||||||
|
) {
|
||||||
|
AdminAnalyticsGuardrailRow(
|
||||||
|
clientSuccess = AdminAnalyticsCountRow(
|
||||||
|
it.exactLong("client_success"),
|
||||||
|
it.exactLong("client_terminal"),
|
||||||
|
),
|
||||||
|
managedSuccess = AdminAnalyticsCountRow(
|
||||||
|
it.exactLong("managed_success"),
|
||||||
|
it.exactLong("managed_terminal"),
|
||||||
|
),
|
||||||
|
creditBlockedUsers = it.exactLong("credit_blocked_users"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class ActivationRow(
|
||||||
|
val denominator: Long,
|
||||||
|
val activated: Long,
|
||||||
|
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(
|
||||||
|
repetitions: Int = 1,
|
||||||
|
): List<Pair<IColumnType<*>, Any?>> = buildList {
|
||||||
|
repeat(repetitions) {
|
||||||
|
add(INSTANT_COLUMN_TYPE to from)
|
||||||
|
add(INSTANT_COLUMN_TYPE to until)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun maturedWindowArguments(
|
||||||
|
from: Instant,
|
||||||
|
maturityEnd: Instant,
|
||||||
|
): List<Pair<IColumnType<*>, Any?>> =
|
||||||
|
listOf(
|
||||||
|
INSTANT_COLUMN_TYPE to from,
|
||||||
|
INSTANT_COLUMN_TYPE to maxOf(from, maturityEnd),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun <T> querySingle(
|
||||||
|
sql: String,
|
||||||
|
arguments: List<Pair<IColumnType<*>, Any?>>,
|
||||||
|
transform: (ResultSet) -> T,
|
||||||
|
): T = queryRows(sql, arguments, transform).single()
|
||||||
|
|
||||||
|
private fun <T> queryRows(
|
||||||
|
sql: String,
|
||||||
|
arguments: List<Pair<IColumnType<*>, Any?>>,
|
||||||
|
transform: (ResultSet) -> T,
|
||||||
|
): List<T> {
|
||||||
|
val normalized = sql.trimIndent()
|
||||||
|
// Exposed classifies statements beginning with WITH as updates. Wrapping the
|
||||||
|
// CTE keeps prepared arguments and makes the statement unambiguously a query.
|
||||||
|
val executable = if (normalized.startsWith("WITH ", ignoreCase = true)) {
|
||||||
|
"SELECT * FROM (\n$normalized\n) AS analytics_result"
|
||||||
|
} else {
|
||||||
|
normalized
|
||||||
|
}
|
||||||
|
return TransactionManager.current().exec(executable, arguments) { result ->
|
||||||
|
buildList {
|
||||||
|
while (result.next()) add(transform(result))
|
||||||
|
}
|
||||||
|
} ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ResultSet.exactLong(column: String): Long =
|
||||||
|
requireNotNull(getBigDecimal(column)) { "Aggregate column $column must not be null" }
|
||||||
|
.longValueExact()
|
||||||
|
|
||||||
|
private fun ResultSet.nullableDouble(column: String): Double? =
|
||||||
|
getBigDecimal(column)?.toDouble()
|
||||||
|
|
||||||
|
private val INSTANT_COLUMN_TYPE = JavaInstantColumnType()
|
||||||
|
private const val DAY_SECONDS = 86_400L
|
||||||
+187
@@ -0,0 +1,187 @@
|
|||||||
|
package com.osglab.account.features.admin.stats.services
|
||||||
|
|
||||||
|
import com.osglab.account.features.admin.stats.models.AdminAnalyticsActivityDto
|
||||||
|
import com.osglab.account.features.admin.stats.models.AdminAnalyticsChannelDto
|
||||||
|
import com.osglab.account.features.admin.stats.models.AdminAnalyticsCohortDto
|
||||||
|
import com.osglab.account.features.admin.stats.models.AdminAnalyticsConsumptionDto
|
||||||
|
import com.osglab.account.features.admin.stats.models.AdminAnalyticsFeatureUsageDto
|
||||||
|
import com.osglab.account.features.admin.stats.models.AdminAnalyticsFunnelStepDto
|
||||||
|
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.AdminAnalyticsMonetizationDto
|
||||||
|
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.AdminAnalyticsRateDto
|
||||||
|
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.AdminAnalyticsWindow
|
||||||
|
import com.osglab.account.features.admin.stats.repositories.AdminProductAnalyticsRepository
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.math.RoundingMode
|
||||||
|
import java.time.DayOfWeek
|
||||||
|
import java.time.Duration
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.time.temporal.TemporalAdjusters
|
||||||
|
|
||||||
|
class AdminProductAnalyticsService(
|
||||||
|
private val repository: AdminProductAnalyticsRepository,
|
||||||
|
) {
|
||||||
|
suspend fun get(from: Instant, until: Instant): AdminProductAnalyticsDto {
|
||||||
|
require(from < until)
|
||||||
|
val currentWeek = currentWeekWindow(until)
|
||||||
|
val elapsed = Duration.between(currentWeek.from, currentWeek.until)
|
||||||
|
val previousWeek = AdminAnalyticsWindow(
|
||||||
|
from = currentWeek.from.minus(Duration.ofDays(7)),
|
||||||
|
until = currentWeek.from.minus(Duration.ofDays(7)).plus(elapsed),
|
||||||
|
)
|
||||||
|
val snapshot = repository.load(
|
||||||
|
range = AdminAnalyticsWindow(from, until),
|
||||||
|
currentWeek = currentWeek,
|
||||||
|
previousWeek = previousWeek,
|
||||||
|
)
|
||||||
|
val referrals = snapshot.referrals
|
||||||
|
val growth = snapshot.growthFunnel
|
||||||
|
return AdminProductAnalyticsDto(
|
||||||
|
period = AdminAnalyticsPeriodDto(from.toString(), until.toString()),
|
||||||
|
northStar = AdminAnalyticsNorthStarDto(
|
||||||
|
weeklyAiActiveUsers = snapshot.currentWeeklyUsers,
|
||||||
|
previousWeeklyAiActiveUsers = snapshot.previousWeeklyUsers,
|
||||||
|
weekOverWeekPercent = growthPercent(
|
||||||
|
snapshot.currentWeeklyUsers,
|
||||||
|
snapshot.previousWeeklyUsers,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
growth = AdminAnalyticsGrowthDto(
|
||||||
|
newInstallations = snapshot.newInstallations,
|
||||||
|
newAccounts = snapshot.newAccounts,
|
||||||
|
activation24h = snapshot.activation24h.toRate(),
|
||||||
|
medianTimeToValueMinutes = snapshot.medianTimeToValueMinutes?.rounded(),
|
||||||
|
channels = snapshot.channels.map {
|
||||||
|
AdminAnalyticsChannelDto(
|
||||||
|
channel = it.channel,
|
||||||
|
installations = it.installations,
|
||||||
|
activated = it.activated,
|
||||||
|
activationRate = AdminAnalyticsCountRow(
|
||||||
|
it.activated,
|
||||||
|
it.installations,
|
||||||
|
).toRate(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
activity = AdminAnalyticsActivityDto(
|
||||||
|
dau = snapshot.dau,
|
||||||
|
wau = snapshot.wau,
|
||||||
|
mau = snapshot.mau,
|
||||||
|
stickinessPercent = percentage(snapshot.dau, snapshot.mau),
|
||||||
|
successfulAiRequests = snapshot.successfulAiRequests,
|
||||||
|
successfulRequestsPerActiveUser = ratio(
|
||||||
|
snapshot.successfulAiRequests,
|
||||||
|
snapshot.periodActiveUsers,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
consumption = AdminAnalyticsConsumptionDto(
|
||||||
|
totalCredits = snapshot.consumption.totalCredits,
|
||||||
|
averageDailyCreditsPerActiveUser =
|
||||||
|
snapshot.consumption.averageDailyCreditsPerActiveUser?.rounded(),
|
||||||
|
medianUserDailyCredits = snapshot.consumption.medianUserDailyCredits?.rounded(),
|
||||||
|
averageCreditsPerManagedRequest = ratio(
|
||||||
|
snapshot.consumption.totalCredits,
|
||||||
|
snapshot.consumption.managedRequests,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
monetization = AdminAnalyticsMonetizationDto(
|
||||||
|
payingUsers = snapshot.monetization.payingUsers,
|
||||||
|
purchases = snapshot.monetization.purchases,
|
||||||
|
creditsPurchased = snapshot.monetization.creditsPurchased,
|
||||||
|
conversion7d = snapshot.monetization.conversion7d.toRate(),
|
||||||
|
conversion30d = snapshot.monetization.conversion30d.toRate(),
|
||||||
|
repeatPurchaseRate = snapshot.monetization.repeatPurchase.toRate(),
|
||||||
|
),
|
||||||
|
growthFunnel = listOf(
|
||||||
|
AdminAnalyticsFunnelStepDto("首次启动", growth.opened),
|
||||||
|
AdminAnalyticsFunnelStepDto("完成注册", growth.registered),
|
||||||
|
AdminAnalyticsFunnelStepDto("24 小时内首次 AI 成功", growth.activated),
|
||||||
|
AdminAnalyticsFunnelStepDto("D7 再次使用 AI", growth.retainedD7),
|
||||||
|
AdminAnalyticsFunnelStepDto("首次购买", growth.purchased),
|
||||||
|
),
|
||||||
|
retention = snapshot.retention.map { cohort ->
|
||||||
|
AdminAnalyticsCohortDto(
|
||||||
|
cohortDate = cohort.cohortDate.toString(),
|
||||||
|
size = cohort.size,
|
||||||
|
d1 = cohort.takeIf { isMature(it.cohortDate.atStartOfDay().toInstant(ZoneOffset.UTC), 1, until) }
|
||||||
|
?.let { AdminAnalyticsCountRow(it.d1, it.size).toRate() },
|
||||||
|
d7 = cohort.takeIf { isMature(it.cohortDate.atStartOfDay().toInstant(ZoneOffset.UTC), 7, until) }
|
||||||
|
?.let { AdminAnalyticsCountRow(it.d7, it.size).toRate() },
|
||||||
|
d30 = cohort.takeIf {
|
||||||
|
isMature(it.cohortDate.atStartOfDay().toInstant(ZoneOffset.UTC), 30, until)
|
||||||
|
}?.let { AdminAnalyticsCountRow(it.d30, it.size).toRate() },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
aiFeatures = snapshot.features.map {
|
||||||
|
AdminAnalyticsFeatureUsageDto(
|
||||||
|
feature = it.feature,
|
||||||
|
executionMode = it.executionMode,
|
||||||
|
users = it.users,
|
||||||
|
successes = it.successes,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
referralFunnel = listOf(
|
||||||
|
AdminAnalyticsFunnelStepDto("发起分享", referrals.shared),
|
||||||
|
AdminAnalyticsFunnelStepDto("打开邀请", referrals.opened),
|
||||||
|
AdminAnalyticsFunnelStepDto("完成绑定", referrals.bound),
|
||||||
|
AdminAnalyticsFunnelStepDto("首次 AI 成功", referrals.activated),
|
||||||
|
AdminAnalyticsFunnelStepDto("完成奖励", referrals.rewarded),
|
||||||
|
),
|
||||||
|
guardrails = AdminAnalyticsGuardrailsDto(
|
||||||
|
clientAiSuccessRate = snapshot.guardrails.clientSuccess.toRate(),
|
||||||
|
managedSuccessRate = snapshot.guardrails.managedSuccess.toRate(),
|
||||||
|
creditBlockedUsers = snapshot.guardrails.creditBlockedUsers,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun currentWeekWindow(until: Instant): AdminAnalyticsWindow {
|
||||||
|
val referenceDate = until.minusNanos(1).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
|
val weekStart = referenceDate
|
||||||
|
.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
|
||||||
|
.atStartOfDay(ZoneOffset.UTC)
|
||||||
|
.toInstant()
|
||||||
|
return AdminAnalyticsWindow(weekStart, until)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AdminAnalyticsCountRow.toRate(): AdminAnalyticsRateDto =
|
||||||
|
AdminAnalyticsRateDto(
|
||||||
|
numerator = numerator,
|
||||||
|
denominator = denominator,
|
||||||
|
percent = percentage(numerator, denominator),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun percentage(numerator: Long, denominator: Long): Double? =
|
||||||
|
if (denominator == 0L) null else
|
||||||
|
numerator.toBigDecimal()
|
||||||
|
.multiply(HUNDRED)
|
||||||
|
.divide(denominator.toBigDecimal(), 1, RoundingMode.HALF_UP)
|
||||||
|
.toDouble()
|
||||||
|
|
||||||
|
private fun ratio(numerator: Long, denominator: Long): Double? =
|
||||||
|
if (denominator == 0L) null else
|
||||||
|
numerator.toBigDecimal()
|
||||||
|
.divide(denominator.toBigDecimal(), 2, RoundingMode.HALF_UP)
|
||||||
|
.toDouble()
|
||||||
|
|
||||||
|
private fun growthPercent(current: Long, previous: Long): Double? =
|
||||||
|
if (previous == 0L) null else
|
||||||
|
(current - previous).toBigDecimal()
|
||||||
|
.multiply(HUNDRED)
|
||||||
|
.divide(previous.toBigDecimal(), 1, RoundingMode.HALF_UP)
|
||||||
|
.toDouble()
|
||||||
|
|
||||||
|
private fun Double.rounded(scale: Int = 1): Double =
|
||||||
|
toBigDecimal().setScale(scale, RoundingMode.HALF_UP).toDouble()
|
||||||
|
|
||||||
|
private fun isMature(cohortStart: Instant, offsetDays: Long, until: Instant): Boolean =
|
||||||
|
!until.isBefore(cohortStart.plus(Duration.ofDays(offsetDays + 1)))
|
||||||
|
|
||||||
|
private val HUNDRED = BigDecimal.valueOf(100)
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package com.osglab.account.features.analytics.domain
|
||||||
|
|
||||||
|
import com.osglab.account.common.errors.ApiException
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class AnalyticsEventType {
|
||||||
|
FIRST_OPEN,
|
||||||
|
SESSION_STARTED,
|
||||||
|
KEYBOARD_ACTIVATED,
|
||||||
|
AI_FEATURE_STARTED,
|
||||||
|
AI_FEATURE_SUCCEEDED,
|
||||||
|
AI_FEATURE_FAILED,
|
||||||
|
PURCHASE_VIEWED,
|
||||||
|
PURCHASE_STARTED,
|
||||||
|
PURCHASE_CANCELLED,
|
||||||
|
REFERRAL_SHARED,
|
||||||
|
INVITE_OPENED,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class AnalyticsSurface {
|
||||||
|
APP,
|
||||||
|
KEYBOARD,
|
||||||
|
INVITE_WEB,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class AnalyticsAcquisitionChannel {
|
||||||
|
APP_STORE_ORGANIC,
|
||||||
|
REFERRAL,
|
||||||
|
SOCIAL_CONTENT,
|
||||||
|
UNKNOWN,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class AnalyticsFeature {
|
||||||
|
TRANSCRIPTION,
|
||||||
|
POLISH,
|
||||||
|
AI_ASSISTANT,
|
||||||
|
AGENT,
|
||||||
|
HOTWORD,
|
||||||
|
OTHER,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class AnalyticsExecutionMode {
|
||||||
|
MANAGED,
|
||||||
|
LOCAL,
|
||||||
|
BYOK,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class AnalyticsFailureCategory {
|
||||||
|
NETWORK,
|
||||||
|
PROVIDER,
|
||||||
|
TIMEOUT,
|
||||||
|
CANCELLED,
|
||||||
|
INSUFFICIENT_CREDITS,
|
||||||
|
VALIDATION,
|
||||||
|
UNKNOWN,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class AnalyticsDurationBucket {
|
||||||
|
LT_1S,
|
||||||
|
S1_TO_3,
|
||||||
|
S3_TO_10,
|
||||||
|
S10_TO_30,
|
||||||
|
GTE_30S,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AnalyticsEvent(
|
||||||
|
val clientEventId: UUID,
|
||||||
|
val eventType: AnalyticsEventType,
|
||||||
|
val occurredAt: Instant,
|
||||||
|
val surface: AnalyticsSurface,
|
||||||
|
val acquisitionChannel: AnalyticsAcquisitionChannel?,
|
||||||
|
val feature: AnalyticsFeature?,
|
||||||
|
val executionMode: AnalyticsExecutionMode?,
|
||||||
|
val failureCategory: AnalyticsFailureCategory?,
|
||||||
|
val durationBucket: AnalyticsDurationBucket?,
|
||||||
|
val appVersion: String?,
|
||||||
|
val osVersion: String?,
|
||||||
|
val payloadHash: String,
|
||||||
|
) {
|
||||||
|
override fun toString(): String = "AnalyticsEvent([REDACTED])"
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AnalyticsBatch(
|
||||||
|
val installationHash: String,
|
||||||
|
val accountId: UUID?,
|
||||||
|
val events: List<AnalyticsEvent>,
|
||||||
|
val receivedAt: Instant,
|
||||||
|
) {
|
||||||
|
override fun toString(): String =
|
||||||
|
"AnalyticsBatch(installationHash=[REDACTED], accountId=[REDACTED], events=${events.size})"
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AnalyticsIngestResult(
|
||||||
|
val accepted: Int,
|
||||||
|
val replayed: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
class AnalyticsEventTimeException :
|
||||||
|
ApiException(
|
||||||
|
status = HttpStatusCode.UnprocessableEntity,
|
||||||
|
code = "event_time_invalid",
|
||||||
|
message = "An event timestamp is outside the accepted range",
|
||||||
|
)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.osglab.account.features.analytics.models
|
||||||
|
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsAcquisitionChannel
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsDurationBucket
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsEventType
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsExecutionMode
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsFailureCategory
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsFeature
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsIngestResult
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsSurface
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AnalyticsBatchRequest(
|
||||||
|
val installationId: String,
|
||||||
|
val events: List<AnalyticsEventRequest>,
|
||||||
|
) {
|
||||||
|
override fun toString(): String =
|
||||||
|
"AnalyticsBatchRequest(installationId=[REDACTED], events=[REDACTED size=${events.size}])"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AnalyticsEventRequest(
|
||||||
|
val clientEventId: String,
|
||||||
|
val eventType: AnalyticsEventType,
|
||||||
|
val occurredAt: String,
|
||||||
|
val surface: AnalyticsSurface,
|
||||||
|
val acquisitionChannel: AnalyticsAcquisitionChannel? = null,
|
||||||
|
val feature: AnalyticsFeature? = null,
|
||||||
|
val executionMode: AnalyticsExecutionMode? = null,
|
||||||
|
val failureCategory: AnalyticsFailureCategory? = null,
|
||||||
|
val durationBucket: AnalyticsDurationBucket? = null,
|
||||||
|
val appVersion: String? = null,
|
||||||
|
val osVersion: String? = null,
|
||||||
|
) {
|
||||||
|
override fun toString(): String = "AnalyticsEventRequest([REDACTED])"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AnalyticsIngestResponse(
|
||||||
|
val accepted: Int,
|
||||||
|
val replayed: Int,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun fromDomain(result: AnalyticsIngestResult): AnalyticsIngestResponse =
|
||||||
|
AnalyticsIngestResponse(result.accepted, result.replayed)
|
||||||
|
}
|
||||||
|
}
|
||||||
+197
@@ -0,0 +1,197 @@
|
|||||||
|
package com.osglab.account.features.analytics.repositories
|
||||||
|
|
||||||
|
import com.osglab.account.common.errors.ConflictException
|
||||||
|
import com.osglab.account.config.DatabaseFactory
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsAcquisitionChannel
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsBatch
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsDurationBucket
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsEvent
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsEventType
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsExecutionMode
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsFailureCategory
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsFeature
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsIngestResult
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsSurface
|
||||||
|
import org.jetbrains.exposed.v1.core.Table
|
||||||
|
import org.jetbrains.exposed.v1.core.and
|
||||||
|
import org.jetbrains.exposed.v1.core.eq
|
||||||
|
import org.jetbrains.exposed.v1.core.inList
|
||||||
|
import org.jetbrains.exposed.v1.core.isNull
|
||||||
|
import org.jetbrains.exposed.v1.core.less
|
||||||
|
import org.jetbrains.exposed.v1.core.plus
|
||||||
|
import org.jetbrains.exposed.v1.javatime.date
|
||||||
|
import org.jetbrains.exposed.v1.javatime.timestamp
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.insert
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.insertIgnore
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.deleteWhere
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.update
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
|
||||||
|
interface AnalyticsRepository {
|
||||||
|
suspend fun ingest(batch: AnalyticsBatch): AnalyticsIngestResult
|
||||||
|
suspend fun recordInvitePageOpen(occurredAt: Instant)
|
||||||
|
suspend fun purgeAnonymousInstallations(before: Instant, limit: Int): Int
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExposedAnalyticsRepository(
|
||||||
|
private val databaseFactory: DatabaseFactory,
|
||||||
|
) : AnalyticsRepository {
|
||||||
|
override suspend fun purgeAnonymousInstallations(before: Instant, limit: Int): Int {
|
||||||
|
require(limit in 1..10_000)
|
||||||
|
return databaseFactory.query {
|
||||||
|
val hashes = AnalyticsInstallations
|
||||||
|
.selectAll()
|
||||||
|
.where {
|
||||||
|
AnalyticsInstallations.accountId.isNull() and
|
||||||
|
(AnalyticsInstallations.updatedAt less before)
|
||||||
|
}
|
||||||
|
.limit(limit)
|
||||||
|
.map { it[AnalyticsInstallations.installationHash] }
|
||||||
|
if (hashes.isEmpty()) {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
AnalyticsInstallations.deleteWhere {
|
||||||
|
AnalyticsInstallations.installationHash inList hashes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun recordInvitePageOpen(occurredAt: Instant) {
|
||||||
|
databaseFactory.query {
|
||||||
|
val date = occurredAt.atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
|
AnalyticsDailyCounters.insertIgnore {
|
||||||
|
it[counterDate] = date
|
||||||
|
it[counterName] = INVITE_PAGE_OPENED
|
||||||
|
it[counterValue] = 0
|
||||||
|
it[updatedAt] = occurredAt
|
||||||
|
}
|
||||||
|
AnalyticsDailyCounters.update({
|
||||||
|
(AnalyticsDailyCounters.counterDate eq date) and
|
||||||
|
(AnalyticsDailyCounters.counterName eq INVITE_PAGE_OPENED)
|
||||||
|
}) {
|
||||||
|
it[counterValue] = AnalyticsDailyCounters.counterValue + 1
|
||||||
|
it[updatedAt] = occurredAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun ingest(batch: AnalyticsBatch): AnalyticsIngestResult =
|
||||||
|
databaseFactory.query {
|
||||||
|
AnalyticsInstallations.insertIgnore {
|
||||||
|
it[installationHash] = batch.installationHash
|
||||||
|
it[accountId] = batch.accountId?.toString()
|
||||||
|
it[createdAt] = batch.receivedAt
|
||||||
|
it[updatedAt] = batch.receivedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
val installation = AnalyticsInstallations
|
||||||
|
.selectAll()
|
||||||
|
.where { AnalyticsInstallations.installationHash eq batch.installationHash }
|
||||||
|
.forUpdate()
|
||||||
|
.single()
|
||||||
|
val linkedAccount = installation[AnalyticsInstallations.accountId]
|
||||||
|
when {
|
||||||
|
batch.accountId == null -> Unit
|
||||||
|
linkedAccount == null -> AnalyticsInstallations.update({
|
||||||
|
AnalyticsInstallations.installationHash eq batch.installationHash
|
||||||
|
}) {
|
||||||
|
it[accountId] = batch.accountId.toString()
|
||||||
|
it[updatedAt] = batch.receivedAt
|
||||||
|
}
|
||||||
|
linkedAccount != batch.accountId.toString() ->
|
||||||
|
throw ConflictException("Installation is linked to another account")
|
||||||
|
}
|
||||||
|
AnalyticsInstallations.update({
|
||||||
|
AnalyticsInstallations.installationHash eq batch.installationHash
|
||||||
|
}) {
|
||||||
|
it[updatedAt] = batch.receivedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
var accepted = 0
|
||||||
|
var replayed = 0
|
||||||
|
batch.events.forEach { event ->
|
||||||
|
val existingPayloadHash = AnalyticsEvents
|
||||||
|
.selectAll()
|
||||||
|
.where {
|
||||||
|
(AnalyticsEvents.installationHash eq batch.installationHash) and
|
||||||
|
(AnalyticsEvents.clientEventId eq event.clientEventId.toString())
|
||||||
|
}
|
||||||
|
.singleOrNull()
|
||||||
|
?.get(AnalyticsEvents.payloadHash)
|
||||||
|
when {
|
||||||
|
existingPayloadHash == null -> {
|
||||||
|
insertEvent(batch, event)
|
||||||
|
accepted += 1
|
||||||
|
}
|
||||||
|
existingPayloadHash == event.payloadHash -> replayed += 1
|
||||||
|
else -> throw ConflictException("Client event ID was reused with another payload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AnalyticsIngestResult(accepted = accepted, replayed = replayed)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun insertEvent(batch: AnalyticsBatch, event: AnalyticsEvent) {
|
||||||
|
AnalyticsEvents.insert {
|
||||||
|
it[installationHash] = batch.installationHash
|
||||||
|
it[clientEventId] = event.clientEventId.toString()
|
||||||
|
it[eventType] = event.eventType
|
||||||
|
it[occurredAt] = event.occurredAt
|
||||||
|
it[surface] = event.surface
|
||||||
|
it[acquisitionChannel] = event.acquisitionChannel
|
||||||
|
it[feature] = event.feature
|
||||||
|
it[executionMode] = event.executionMode
|
||||||
|
it[failureCategory] = event.failureCategory
|
||||||
|
it[durationBucket] = event.durationBucket
|
||||||
|
it[appVersion] = event.appVersion
|
||||||
|
it[osVersion] = event.osVersion
|
||||||
|
it[payloadHash] = event.payloadHash
|
||||||
|
it[receivedAt] = batch.receivedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private object AnalyticsInstallations : Table("product_analytics_installations") {
|
||||||
|
val installationHash = char("installation_hash", 64)
|
||||||
|
val accountId = varchar("account_id", 36).nullable()
|
||||||
|
val createdAt = timestamp("created_at")
|
||||||
|
val updatedAt = timestamp("updated_at")
|
||||||
|
|
||||||
|
override val primaryKey = PrimaryKey(installationHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
private object AnalyticsEvents : Table("product_analytics_events") {
|
||||||
|
val installationHash = char("installation_hash", 64)
|
||||||
|
val clientEventId = char("client_event_id", 36)
|
||||||
|
val eventType = enumerationByName<AnalyticsEventType>("event_name", 32)
|
||||||
|
val occurredAt = timestamp("occurred_at")
|
||||||
|
val surface = enumerationByName<AnalyticsSurface>("surface", 16)
|
||||||
|
val acquisitionChannel =
|
||||||
|
enumerationByName<AnalyticsAcquisitionChannel>("acquisition_channel", 32).nullable()
|
||||||
|
val feature = enumerationByName<AnalyticsFeature>("feature", 32).nullable()
|
||||||
|
val executionMode =
|
||||||
|
enumerationByName<AnalyticsExecutionMode>("execution_mode", 16).nullable()
|
||||||
|
val failureCategory =
|
||||||
|
enumerationByName<AnalyticsFailureCategory>("failure_category", 32).nullable()
|
||||||
|
val durationBucket =
|
||||||
|
enumerationByName<AnalyticsDurationBucket>("duration_bucket", 16).nullable()
|
||||||
|
val appVersion = varchar("app_version", 32).nullable()
|
||||||
|
val osVersion = varchar("os_version", 32).nullable()
|
||||||
|
val payloadHash = char("payload_hash", 64)
|
||||||
|
val receivedAt = timestamp("received_at")
|
||||||
|
|
||||||
|
override val primaryKey = PrimaryKey(installationHash, clientEventId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private object AnalyticsDailyCounters : Table("product_analytics_daily_counters") {
|
||||||
|
val counterDate = date("counter_date")
|
||||||
|
val counterName = varchar("counter_name", 32)
|
||||||
|
val counterValue = long("counter_value")
|
||||||
|
val updatedAt = timestamp("updated_at")
|
||||||
|
|
||||||
|
override val primaryKey = PrimaryKey(counterDate, counterName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val INVITE_PAGE_OPENED = "INVITE_PAGE_OPENED"
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.osglab.account.features.analytics.routes
|
||||||
|
|
||||||
|
import com.osglab.account.common.security.AccountPrincipal
|
||||||
|
import com.osglab.account.common.security.SESSION_AUTH_NAME
|
||||||
|
import com.osglab.account.common.errors.InvalidRequestException
|
||||||
|
import com.osglab.account.features.analytics.models.AnalyticsBatchRequest
|
||||||
|
import com.osglab.account.features.analytics.models.AnalyticsIngestResponse
|
||||||
|
import com.osglab.account.features.analytics.services.AnalyticsService
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.http.HttpHeaders
|
||||||
|
import io.ktor.server.auth.authenticate
|
||||||
|
import io.ktor.server.auth.principal
|
||||||
|
import io.ktor.server.request.header
|
||||||
|
import io.ktor.server.request.receiveText
|
||||||
|
import io.ktor.server.response.respond
|
||||||
|
import io.ktor.server.routing.Route
|
||||||
|
import io.ktor.server.routing.post
|
||||||
|
import kotlinx.serialization.SerializationException
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
fun Route.analyticsRoutes(service: AnalyticsService) {
|
||||||
|
authenticate(SESSION_AUTH_NAME, optional = true) {
|
||||||
|
post("/v1/analytics/events") {
|
||||||
|
val declaredLength = call.request.header(HttpHeaders.ContentLength)?.toLongOrNull()
|
||||||
|
if (declaredLength != null && declaredLength > MAX_ANALYTICS_BODY_BYTES) {
|
||||||
|
throw InvalidRequestException("Analytics request body is too large")
|
||||||
|
}
|
||||||
|
val body = call.receiveText()
|
||||||
|
if (body.toByteArray(Charsets.UTF_8).size > MAX_ANALYTICS_BODY_BYTES) {
|
||||||
|
throw InvalidRequestException("Analytics request body is too large")
|
||||||
|
}
|
||||||
|
val request = try {
|
||||||
|
ANALYTICS_JSON.decodeFromString<AnalyticsBatchRequest>(body)
|
||||||
|
} catch (_: SerializationException) {
|
||||||
|
throw InvalidRequestException("Analytics request body is invalid")
|
||||||
|
}
|
||||||
|
val result = service.ingest(
|
||||||
|
accountId = call.principal<AccountPrincipal>()?.userId,
|
||||||
|
request = request,
|
||||||
|
)
|
||||||
|
call.respond(HttpStatusCode.OK, AnalyticsIngestResponse.fromDomain(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val ANALYTICS_JSON = Json {
|
||||||
|
ignoreUnknownKeys = false
|
||||||
|
explicitNulls = false
|
||||||
|
}
|
||||||
|
private const val MAX_ANALYTICS_BODY_BYTES = 64 * 1024
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
package com.osglab.account.features.analytics.services
|
||||||
|
|
||||||
|
import com.osglab.account.common.errors.InvalidRequestException
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsAcquisitionChannel
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsBatch
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsEvent
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsEventTimeException
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsEventType
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsFailureCategory
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsIngestResult
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsSurface
|
||||||
|
import com.osglab.account.features.analytics.models.AnalyticsBatchRequest
|
||||||
|
import com.osglab.account.features.analytics.models.AnalyticsEventRequest
|
||||||
|
import com.osglab.account.features.analytics.repositories.AnalyticsRepository
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.time.Clock
|
||||||
|
import java.time.Duration
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.format.DateTimeParseException
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
interface AnalyticsService {
|
||||||
|
suspend fun ingest(
|
||||||
|
accountId: UUID?,
|
||||||
|
request: AnalyticsBatchRequest,
|
||||||
|
): AnalyticsIngestResult
|
||||||
|
}
|
||||||
|
|
||||||
|
class DefaultAnalyticsService(
|
||||||
|
private val repository: AnalyticsRepository,
|
||||||
|
private val clock: Clock = Clock.systemUTC(),
|
||||||
|
) : AnalyticsService {
|
||||||
|
override suspend fun ingest(
|
||||||
|
accountId: UUID?,
|
||||||
|
request: AnalyticsBatchRequest,
|
||||||
|
): AnalyticsIngestResult {
|
||||||
|
if (request.events.size !in MIN_BATCH_SIZE..MAX_BATCH_SIZE) {
|
||||||
|
throw InvalidRequestException("events must contain between 1 and 50 items")
|
||||||
|
}
|
||||||
|
val installationId = parseUuid(request.installationId, "installationId")
|
||||||
|
val now = clock.instant()
|
||||||
|
val events = request.events.map { validateAndMap(it, now) }
|
||||||
|
return repository.ingest(
|
||||||
|
AnalyticsBatch(
|
||||||
|
installationHash = installationId.toString().sha256Hex(),
|
||||||
|
accountId = accountId,
|
||||||
|
events = events,
|
||||||
|
receivedAt = now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateAndMap(request: AnalyticsEventRequest, now: Instant): AnalyticsEvent {
|
||||||
|
val clientEventId = parseUuid(request.clientEventId, "clientEventId")
|
||||||
|
val occurredAt = parseOccurredAt(request.occurredAt)
|
||||||
|
if (
|
||||||
|
occurredAt.isBefore(now.minus(MAX_EVENT_AGE)) ||
|
||||||
|
occurredAt.isAfter(now.plus(MAX_FUTURE_SKEW))
|
||||||
|
) {
|
||||||
|
throw AnalyticsEventTimeException()
|
||||||
|
}
|
||||||
|
validateReleaseIdentifier(request.appVersion, "appVersion")
|
||||||
|
validateReleaseIdentifier(request.osVersion, "osVersion")
|
||||||
|
validateEventShape(request)
|
||||||
|
|
||||||
|
return AnalyticsEvent(
|
||||||
|
clientEventId = clientEventId,
|
||||||
|
eventType = request.eventType,
|
||||||
|
occurredAt = occurredAt,
|
||||||
|
surface = request.surface,
|
||||||
|
acquisitionChannel = request.acquisitionChannel,
|
||||||
|
feature = request.feature,
|
||||||
|
executionMode = request.executionMode,
|
||||||
|
failureCategory = request.failureCategory,
|
||||||
|
durationBucket = request.durationBucket,
|
||||||
|
appVersion = request.appVersion,
|
||||||
|
osVersion = request.osVersion,
|
||||||
|
payloadHash = payloadHash(request, clientEventId, occurredAt),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateEventShape(event: AnalyticsEventRequest) {
|
||||||
|
val valid = when (event.eventType) {
|
||||||
|
AnalyticsEventType.FIRST_OPEN ->
|
||||||
|
event.surface == AnalyticsSurface.APP &&
|
||||||
|
event.acquisitionChannel != null &&
|
||||||
|
event.feature == null &&
|
||||||
|
event.executionMode == null &&
|
||||||
|
event.failureCategory == null &&
|
||||||
|
event.durationBucket == null
|
||||||
|
|
||||||
|
AnalyticsEventType.SESSION_STARTED ->
|
||||||
|
event.surface in setOf(AnalyticsSurface.APP, AnalyticsSurface.KEYBOARD) &&
|
||||||
|
event.acquisitionChannel == null &&
|
||||||
|
event.feature == null &&
|
||||||
|
event.executionMode == null &&
|
||||||
|
event.failureCategory == null &&
|
||||||
|
event.durationBucket == null
|
||||||
|
|
||||||
|
AnalyticsEventType.KEYBOARD_ACTIVATED ->
|
||||||
|
event.surface == AnalyticsSurface.KEYBOARD &&
|
||||||
|
event.acquisitionChannel == null &&
|
||||||
|
event.feature == null &&
|
||||||
|
event.executionMode == null &&
|
||||||
|
event.failureCategory == null &&
|
||||||
|
event.durationBucket == null
|
||||||
|
|
||||||
|
AnalyticsEventType.AI_FEATURE_STARTED ->
|
||||||
|
event.feature != null &&
|
||||||
|
event.executionMode != null &&
|
||||||
|
event.acquisitionChannel == null &&
|
||||||
|
event.failureCategory == null &&
|
||||||
|
event.durationBucket == null
|
||||||
|
|
||||||
|
AnalyticsEventType.AI_FEATURE_SUCCEEDED ->
|
||||||
|
event.feature != null &&
|
||||||
|
event.executionMode != null &&
|
||||||
|
event.durationBucket != null &&
|
||||||
|
event.acquisitionChannel == null &&
|
||||||
|
event.failureCategory == null
|
||||||
|
|
||||||
|
AnalyticsEventType.AI_FEATURE_FAILED ->
|
||||||
|
event.feature != null &&
|
||||||
|
event.executionMode != null &&
|
||||||
|
event.failureCategory != null &&
|
||||||
|
event.acquisitionChannel == null
|
||||||
|
|
||||||
|
AnalyticsEventType.PURCHASE_VIEWED,
|
||||||
|
AnalyticsEventType.PURCHASE_STARTED,
|
||||||
|
->
|
||||||
|
event.surface == AnalyticsSurface.APP &&
|
||||||
|
event.acquisitionChannel == null &&
|
||||||
|
event.feature == null &&
|
||||||
|
event.executionMode == null &&
|
||||||
|
event.failureCategory == null &&
|
||||||
|
event.durationBucket == null
|
||||||
|
|
||||||
|
AnalyticsEventType.PURCHASE_CANCELLED ->
|
||||||
|
event.surface == AnalyticsSurface.APP &&
|
||||||
|
event.failureCategory == AnalyticsFailureCategory.CANCELLED &&
|
||||||
|
event.acquisitionChannel == null &&
|
||||||
|
event.feature == null &&
|
||||||
|
event.executionMode == null &&
|
||||||
|
event.durationBucket == null
|
||||||
|
|
||||||
|
AnalyticsEventType.REFERRAL_SHARED ->
|
||||||
|
event.surface == AnalyticsSurface.APP &&
|
||||||
|
event.acquisitionChannel == null &&
|
||||||
|
event.feature == null &&
|
||||||
|
event.executionMode == null &&
|
||||||
|
event.failureCategory == null &&
|
||||||
|
event.durationBucket == null
|
||||||
|
|
||||||
|
AnalyticsEventType.INVITE_OPENED ->
|
||||||
|
event.surface == AnalyticsSurface.INVITE_WEB &&
|
||||||
|
event.acquisitionChannel == AnalyticsAcquisitionChannel.REFERRAL &&
|
||||||
|
event.feature == null &&
|
||||||
|
event.executionMode == null &&
|
||||||
|
event.failureCategory == null &&
|
||||||
|
event.durationBucket == null
|
||||||
|
}
|
||||||
|
if (!valid) {
|
||||||
|
throw InvalidRequestException("Event fields do not match the event type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseOccurredAt(value: String): Instant {
|
||||||
|
if (!value.endsWith('Z')) {
|
||||||
|
throw InvalidRequestException("occurredAt must be a UTC ISO-8601 timestamp")
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
Instant.parse(value)
|
||||||
|
} catch (_: DateTimeParseException) {
|
||||||
|
throw InvalidRequestException("occurredAt must be a UTC ISO-8601 timestamp")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateReleaseIdentifier(value: String?, field: String) {
|
||||||
|
if (value != null && !RELEASE_IDENTIFIER.matches(value)) {
|
||||||
|
throw InvalidRequestException("$field must be a 1 to 32 character release identifier")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseUuid(value: String, field: String): UUID {
|
||||||
|
if (!UUID_PATTERN.matches(value)) {
|
||||||
|
throw InvalidRequestException("$field must be a UUID")
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
UUID.fromString(value)
|
||||||
|
} catch (_: IllegalArgumentException) {
|
||||||
|
throw InvalidRequestException("$field must be a UUID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun payloadHash(
|
||||||
|
event: AnalyticsEventRequest,
|
||||||
|
clientEventId: UUID,
|
||||||
|
occurredAt: Instant,
|
||||||
|
): String = listOf(
|
||||||
|
clientEventId.toString(),
|
||||||
|
event.eventType.name,
|
||||||
|
occurredAt.toString(),
|
||||||
|
event.surface.name,
|
||||||
|
event.acquisitionChannel?.name,
|
||||||
|
event.feature?.name,
|
||||||
|
event.executionMode?.name,
|
||||||
|
event.failureCategory?.name,
|
||||||
|
event.durationBucket?.name,
|
||||||
|
event.appVersion,
|
||||||
|
event.osVersion,
|
||||||
|
).joinToString(separator = "\u0000") { it ?: "" }.sha256Hex()
|
||||||
|
|
||||||
|
private fun String.sha256Hex(): String =
|
||||||
|
MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(toByteArray(Charsets.UTF_8))
|
||||||
|
.joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) }
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val MIN_BATCH_SIZE = 1
|
||||||
|
const val MAX_BATCH_SIZE = 50
|
||||||
|
val MAX_EVENT_AGE: Duration = Duration.ofDays(35)
|
||||||
|
val MAX_FUTURE_SKEW: Duration = Duration.ofMinutes(5)
|
||||||
|
val UUID_PATTERN =
|
||||||
|
Regex("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
|
||||||
|
val RELEASE_IDENTIFIER = Regex("[A-Za-z0-9._+\\-]{1,32}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AnalyticsMaintenanceService(
|
||||||
|
private val repository: AnalyticsRepository,
|
||||||
|
private val clock: Clock = Clock.systemUTC(),
|
||||||
|
private val anonymousRetention: Duration = Duration.ofDays(90),
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(!anonymousRetention.isNegative && !anonymousRetention.isZero)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun purgeStaleAnonymousInstallations(): Int =
|
||||||
|
repository.purgeAnonymousInstallations(
|
||||||
|
before = clock.instant().minus(anonymousRetention),
|
||||||
|
limit = PURGE_BATCH_SIZE,
|
||||||
|
)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val PURGE_BATCH_SIZE = 1_000
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,10 @@ fun interface ReferralLookupPort {
|
|||||||
suspend fun isValid(code: String): Boolean
|
suspend fun isValid(code: String): Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun interface InviteOpenRecorder {
|
||||||
|
suspend fun record()
|
||||||
|
}
|
||||||
|
|
||||||
data class InviteWebConfig(
|
data class InviteWebConfig(
|
||||||
val appStoreUrl: String,
|
val appStoreUrl: String,
|
||||||
val appleAppId: String,
|
val appleAppId: String,
|
||||||
@@ -75,6 +79,7 @@ sealed interface InvitePageResult {
|
|||||||
class InvitePageService(
|
class InvitePageService(
|
||||||
private val referralLookup: ReferralLookupPort,
|
private val referralLookup: ReferralLookupPort,
|
||||||
private val config: InviteWebConfig,
|
private val config: InviteWebConfig,
|
||||||
|
private val inviteOpenRecorder: InviteOpenRecorder = InviteOpenRecorder {},
|
||||||
) {
|
) {
|
||||||
private val appStoreUrl = validateHttpsUrl(config.appStoreUrl, "APP_STORE_URL", allowQuery = true)
|
private val appStoreUrl = validateHttpsUrl(config.appStoreUrl, "APP_STORE_URL", allowQuery = true)
|
||||||
.toASCIIString()
|
.toASCIIString()
|
||||||
@@ -101,6 +106,13 @@ class InvitePageService(
|
|||||||
return InvitePageResult.TemporarilyUnavailable
|
return InvitePageResult.TemporarilyUnavailable
|
||||||
}
|
}
|
||||||
if (!valid) return InvitePageResult.Invalid
|
if (!valid) return InvitePageResult.Invalid
|
||||||
|
try {
|
||||||
|
inviteOpenRecorder.record()
|
||||||
|
} catch (exception: CancellationException) {
|
||||||
|
throw exception
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Analytics must never make a valid invitation unavailable.
|
||||||
|
}
|
||||||
|
|
||||||
val nonce = createNonce()
|
val nonce = createNonce()
|
||||||
val universalLink = "$universalLinkBaseUrl/$validCode".escapeHtml()
|
val universalLink = "$universalLinkBaseUrl/$validCode".escapeHtml()
|
||||||
@@ -129,6 +141,7 @@ fun Route.configureInviteWebRoutes() {
|
|||||||
}
|
}
|
||||||
configureInviteWebRoutes(
|
configureInviteWebRoutes(
|
||||||
referralLookup = koin.get<ReferralLookupPort>(),
|
referralLookup = koin.get<ReferralLookupPort>(),
|
||||||
|
inviteOpenRecorder = koin.get<InviteOpenRecorder>(),
|
||||||
config = InviteWebConfig(
|
config = InviteWebConfig(
|
||||||
appStoreUrl = appConfig.appStoreUrl,
|
appStoreUrl = appConfig.appStoreUrl,
|
||||||
appleAppId = "$teamId.${appConfig.apple.clientId}",
|
appleAppId = "$teamId.${appConfig.apple.clientId}",
|
||||||
@@ -140,8 +153,9 @@ fun Route.configureInviteWebRoutes() {
|
|||||||
fun Route.configureInviteWebRoutes(
|
fun Route.configureInviteWebRoutes(
|
||||||
referralLookup: ReferralLookupPort,
|
referralLookup: ReferralLookupPort,
|
||||||
config: InviteWebConfig,
|
config: InviteWebConfig,
|
||||||
|
inviteOpenRecorder: InviteOpenRecorder = InviteOpenRecorder {},
|
||||||
) {
|
) {
|
||||||
val service = InvitePageService(referralLookup, config)
|
val service = InvitePageService(referralLookup, config, inviteOpenRecorder)
|
||||||
|
|
||||||
get("/i/{code}") {
|
get("/i/{code}") {
|
||||||
when (val result = service.render(call.parameters["code"])) {
|
when (val result = service.render(call.parameters["code"])) {
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
CREATE TABLE product_analytics_installations (
|
||||||
|
installation_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||||
|
account_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
|
||||||
|
created_at DATETIME(6) NOT NULL,
|
||||||
|
updated_at DATETIME(6) NOT NULL,
|
||||||
|
PRIMARY KEY (installation_hash),
|
||||||
|
INDEX ix_product_analytics_installations_account (account_id),
|
||||||
|
CONSTRAINT fk_product_analytics_installations_account
|
||||||
|
FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE CASCADE
|
||||||
|
) ENGINE = InnoDB;
|
||||||
|
|
||||||
|
CREATE TABLE product_analytics_events (
|
||||||
|
installation_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||||
|
client_event_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||||
|
event_name VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||||
|
occurred_at DATETIME(6) NOT NULL,
|
||||||
|
surface VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||||
|
acquisition_channel VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NULL,
|
||||||
|
feature VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NULL,
|
||||||
|
execution_mode VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NULL,
|
||||||
|
failure_category VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NULL,
|
||||||
|
duration_bucket VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NULL,
|
||||||
|
app_version VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NULL,
|
||||||
|
os_version VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NULL,
|
||||||
|
payload_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||||
|
received_at DATETIME(6) NOT NULL,
|
||||||
|
PRIMARY KEY (installation_hash, client_event_id),
|
||||||
|
INDEX ix_product_analytics_events_type_occurred (event_name, occurred_at),
|
||||||
|
INDEX ix_product_analytics_events_occurred (occurred_at),
|
||||||
|
CONSTRAINT fk_product_analytics_events_installation
|
||||||
|
FOREIGN KEY (installation_hash)
|
||||||
|
REFERENCES product_analytics_installations (installation_hash)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT chk_product_analytics_events_type CHECK (
|
||||||
|
event_name IN (
|
||||||
|
'FIRST_OPEN',
|
||||||
|
'SESSION_STARTED',
|
||||||
|
'KEYBOARD_ACTIVATED',
|
||||||
|
'AI_FEATURE_STARTED',
|
||||||
|
'AI_FEATURE_SUCCEEDED',
|
||||||
|
'AI_FEATURE_FAILED',
|
||||||
|
'PURCHASE_VIEWED',
|
||||||
|
'PURCHASE_STARTED',
|
||||||
|
'PURCHASE_CANCELLED',
|
||||||
|
'REFERRAL_SHARED',
|
||||||
|
'INVITE_OPENED'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
CONSTRAINT chk_product_analytics_events_surface CHECK (
|
||||||
|
surface IN ('APP', 'KEYBOARD', 'INVITE_WEB')
|
||||||
|
),
|
||||||
|
CONSTRAINT chk_product_analytics_events_acquisition CHECK (
|
||||||
|
acquisition_channel IS NULL
|
||||||
|
OR acquisition_channel IN (
|
||||||
|
'APP_STORE_ORGANIC',
|
||||||
|
'REFERRAL',
|
||||||
|
'SOCIAL_CONTENT',
|
||||||
|
'UNKNOWN'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
CONSTRAINT chk_product_analytics_events_feature CHECK (
|
||||||
|
feature IS NULL
|
||||||
|
OR feature IN (
|
||||||
|
'TRANSCRIPTION',
|
||||||
|
'POLISH',
|
||||||
|
'AI_ASSISTANT',
|
||||||
|
'AGENT',
|
||||||
|
'HOTWORD',
|
||||||
|
'OTHER'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
CONSTRAINT chk_product_analytics_events_execution CHECK (
|
||||||
|
execution_mode IS NULL OR execution_mode IN ('MANAGED', 'LOCAL', 'BYOK')
|
||||||
|
),
|
||||||
|
CONSTRAINT chk_product_analytics_events_failure CHECK (
|
||||||
|
failure_category IS NULL
|
||||||
|
OR failure_category IN (
|
||||||
|
'NETWORK',
|
||||||
|
'PROVIDER',
|
||||||
|
'TIMEOUT',
|
||||||
|
'CANCELLED',
|
||||||
|
'INSUFFICIENT_CREDITS',
|
||||||
|
'VALIDATION',
|
||||||
|
'UNKNOWN'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
CONSTRAINT chk_product_analytics_events_duration CHECK (
|
||||||
|
duration_bucket IS NULL
|
||||||
|
OR duration_bucket IN ('LT_1S', 'S1_TO_3', 'S3_TO_10', 'S10_TO_30', 'GTE_30S')
|
||||||
|
)
|
||||||
|
) ENGINE = InnoDB;
|
||||||
|
|
||||||
|
-- First-party invitation page views are counted without cookies, IP addresses,
|
||||||
|
-- user agents, installation IDs, or referral codes.
|
||||||
|
CREATE TABLE product_analytics_daily_counters (
|
||||||
|
counter_date DATE NOT NULL,
|
||||||
|
counter_name VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||||
|
counter_value BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
updated_at DATETIME(6) NOT NULL,
|
||||||
|
PRIMARY KEY (counter_date, counter_name),
|
||||||
|
CONSTRAINT chk_product_analytics_daily_counter_name
|
||||||
|
CHECK (counter_name IN ('INVITE_PAGE_OPENED'))
|
||||||
|
) ENGINE = InnoDB;
|
||||||
@@ -123,6 +123,29 @@ class DeploymentConsistencyTest : FunSpec({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("product analytics contract stays allowlisted and account deletions cascade") {
|
||||||
|
val migration = root.read(
|
||||||
|
"src/main/resources/db/migration/V16__product_analytics_events.sql",
|
||||||
|
)
|
||||||
|
migration shouldContain "product_analytics_installations"
|
||||||
|
migration shouldContain "product_analytics_events"
|
||||||
|
migration shouldContain "REFERENCES accounts (id) ON DELETE CASCADE"
|
||||||
|
migration shouldContain "PRIMARY KEY (installation_hash, client_event_id)"
|
||||||
|
|
||||||
|
val openApi = root.read("docs/openapi.yaml")
|
||||||
|
val eventSchema = openApi
|
||||||
|
.substringAfter(" ProductAnalyticsEvent:")
|
||||||
|
.substringBefore(" AdminSessionState:")
|
||||||
|
eventSchema shouldContain "additionalProperties: false"
|
||||||
|
eventSchema shouldContain "AI_FEATURE_SUCCEEDED"
|
||||||
|
eventSchema shouldContain "INSUFFICIENT_CREDITS"
|
||||||
|
eventSchema shouldNotContain "prompt"
|
||||||
|
eventSchema shouldNotContain "transcript"
|
||||||
|
eventSchema shouldNotContain "audio"
|
||||||
|
eventSchema shouldNotContain "modelOutput"
|
||||||
|
openApi shouldContain "schema: { \$ref: \"#/components/schemas/AdminProductAnalytics\" }"
|
||||||
|
}
|
||||||
|
|
||||||
test("production Compose reuses private MySQL and hardens the application container") {
|
test("production Compose reuses private MySQL and hardens the application container") {
|
||||||
val compose = root.read("compose.yaml")
|
val compose = root.read("compose.yaml")
|
||||||
|
|
||||||
@@ -228,6 +251,7 @@ private val EXPECTED_PUBLIC_PATHS = setOf(
|
|||||||
"/v1/auth/logout",
|
"/v1/auth/logout",
|
||||||
"/v1/account",
|
"/v1/account",
|
||||||
"/v1/apple/events",
|
"/v1/apple/events",
|
||||||
|
"/v1/analytics/events",
|
||||||
"/v1/credits/balance",
|
"/v1/credits/balance",
|
||||||
"/v1/credits/ledger",
|
"/v1/credits/ledger",
|
||||||
"/v1/credits/rates",
|
"/v1/credits/rates",
|
||||||
@@ -255,6 +279,7 @@ private val EXPECTED_PUBLIC_PATHS = setOf(
|
|||||||
"/v1/admin/auth/logout",
|
"/v1/admin/auth/logout",
|
||||||
"/v1/admin/overview",
|
"/v1/admin/overview",
|
||||||
"/v1/admin/referrals",
|
"/v1/admin/referrals",
|
||||||
|
"/v1/admin/analytics",
|
||||||
"/v1/admin/users",
|
"/v1/admin/users",
|
||||||
"/v1/admin/users/{userId}",
|
"/v1/admin/users/{userId}",
|
||||||
"/v1/admin/users/{userId}/ledger",
|
"/v1/admin/users/{userId}/ledger",
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class SmokeDeploymentTest : FunSpec({
|
|||||||
|
|
||||||
test("runtime grants cover every migrated table without mutable history privileges") {
|
test("runtime grants cover every migrated table without mutable history privileges") {
|
||||||
val grants = root.read("deploy/smoke/runtime-grants.sql")
|
val grants = root.read("deploy/smoke/runtime-grants.sql")
|
||||||
val migrationTables = (1..13)
|
val migrationTables = (1..16)
|
||||||
.flatMap { version ->
|
.flatMap { version ->
|
||||||
val migration = Files.list(root.resolve("src/main/resources/db/migration")).use { paths ->
|
val migration = Files.list(root.resolve("src/main/resources/db/migration")).use { paths ->
|
||||||
paths.filter { it.fileName.toString().startsWith("V${version}__") }
|
paths.filter { it.fileName.toString().startsWith("V${version}__") }
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import com.osglab.account.features.admin.services.AdminOperatorService
|
|||||||
import com.osglab.account.features.admin.services.AdminOperatorErrorCode
|
import com.osglab.account.features.admin.services.AdminOperatorErrorCode
|
||||||
import com.osglab.account.features.admin.services.AdminOperatorException
|
import com.osglab.account.features.admin.services.AdminOperatorException
|
||||||
import com.osglab.account.features.admin.services.AdminSessionService
|
import com.osglab.account.features.admin.services.AdminSessionService
|
||||||
|
import com.osglab.account.features.admin.stats.services.AdminProductAnalyticsService
|
||||||
import com.osglab.account.features.admin.stats.services.AdminStatsService
|
import com.osglab.account.features.admin.stats.services.AdminStatsService
|
||||||
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
|
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
|
||||||
import com.osglab.account.features.admin.users.models.AdminUserLedgerPageDto
|
import com.osglab.account.features.admin.users.models.AdminUserLedgerPageDto
|
||||||
@@ -315,6 +316,7 @@ private fun io.ktor.server.application.Application.installAdminTestRoutes(
|
|||||||
authService = authService,
|
authService = authService,
|
||||||
sessionService = sessionService,
|
sessionService = sessionService,
|
||||||
statsService = mockk<AdminStatsService>(relaxed = true),
|
statsService = mockk<AdminStatsService>(relaxed = true),
|
||||||
|
productAnalyticsService = mockk<AdminProductAnalyticsService>(relaxed = true),
|
||||||
usersService = usersService,
|
usersService = usersService,
|
||||||
grantService = grantService,
|
grantService = grantService,
|
||||||
operatorService = operatorService,
|
operatorService = operatorService,
|
||||||
|
|||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
package com.osglab.account.features.admin.stats
|
||||||
|
|
||||||
|
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsChannelRow
|
||||||
|
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsCohortRow
|
||||||
|
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsConsumptionRow
|
||||||
|
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsCountRow
|
||||||
|
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsFeatureRow
|
||||||
|
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsGrowthFunnelRow
|
||||||
|
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsGuardrailRow
|
||||||
|
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsMonetizationRow
|
||||||
|
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.AdminProductAnalyticsRepository
|
||||||
|
import com.osglab.account.features.admin.stats.repositories.AdminProductAnalyticsSnapshot
|
||||||
|
import com.osglab.account.features.admin.stats.services.AdminProductAnalyticsService
|
||||||
|
import io.kotest.core.spec.style.FunSpec
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
class AdminProductAnalyticsServiceTest : FunSpec({
|
||||||
|
test("maps product metrics with explicit rates and mature retention windows") {
|
||||||
|
val captured = mutableListOf<Triple<AdminAnalyticsWindow, AdminAnalyticsWindow, AdminAnalyticsWindow>>()
|
||||||
|
val repository = object : AdminProductAnalyticsRepository {
|
||||||
|
override suspend fun load(
|
||||||
|
range: AdminAnalyticsWindow,
|
||||||
|
currentWeek: AdminAnalyticsWindow,
|
||||||
|
previousWeek: AdminAnalyticsWindow,
|
||||||
|
): AdminProductAnalyticsSnapshot {
|
||||||
|
captured += Triple(range, currentWeek, previousWeek)
|
||||||
|
return snapshot()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val service = AdminProductAnalyticsService(repository)
|
||||||
|
val until = Instant.parse("2026-08-20T09:00:00Z")
|
||||||
|
|
||||||
|
val result = service.get(Instant.parse("2026-07-21T09:00:00Z"), until)
|
||||||
|
|
||||||
|
result.northStar.weeklyAiActiveUsers shouldBe 120
|
||||||
|
result.northStar.weekOverWeekPercent shouldBe 20.0
|
||||||
|
result.growth.activation24h.percent shouldBe 60.0
|
||||||
|
result.activity.stickinessPercent shouldBe 20.0
|
||||||
|
result.activity.successfulRequestsPerActiveUser shouldBe 5.0
|
||||||
|
result.consumption.averageCreditsPerManagedRequest shouldBe 2.5
|
||||||
|
result.retention.first().d1?.percent shouldBe 50.0
|
||||||
|
result.retention.first().d7?.percent shouldBe 30.0
|
||||||
|
result.retention.first().d30 shouldBe null
|
||||||
|
result.growthFunnel.map { it.label } shouldBe listOf(
|
||||||
|
"首次启动",
|
||||||
|
"完成注册",
|
||||||
|
"24 小时内首次 AI 成功",
|
||||||
|
"D7 再次使用 AI",
|
||||||
|
"首次购买",
|
||||||
|
)
|
||||||
|
captured.single().second.from shouldBe Instant.parse("2026-08-17T00:00:00Z")
|
||||||
|
captured.single().third.from shouldBe Instant.parse("2026-08-10T00:00:00Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
test("zero denominators remain unavailable instead of reporting false zero rates") {
|
||||||
|
val empty = snapshot().copy(
|
||||||
|
previousWeeklyUsers = 0,
|
||||||
|
mau = 0,
|
||||||
|
periodActiveUsers = 0,
|
||||||
|
activation24h = AdminAnalyticsCountRow(0, 0),
|
||||||
|
consumption = AdminAnalyticsConsumptionRow(0, 0, null, null),
|
||||||
|
)
|
||||||
|
val service = AdminProductAnalyticsService(
|
||||||
|
object : AdminProductAnalyticsRepository {
|
||||||
|
override suspend fun load(
|
||||||
|
range: AdminAnalyticsWindow,
|
||||||
|
currentWeek: AdminAnalyticsWindow,
|
||||||
|
previousWeek: AdminAnalyticsWindow,
|
||||||
|
) = empty
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = service.get(
|
||||||
|
Instant.parse("2026-08-19T00:00:00Z"),
|
||||||
|
Instant.parse("2026-08-20T00:00:00Z"),
|
||||||
|
)
|
||||||
|
|
||||||
|
result.northStar.weekOverWeekPercent shouldBe null
|
||||||
|
result.growth.activation24h.percent shouldBe null
|
||||||
|
result.activity.stickinessPercent shouldBe null
|
||||||
|
result.activity.successfulRequestsPerActiveUser shouldBe null
|
||||||
|
result.consumption.averageCreditsPerManagedRequest shouldBe null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
private fun snapshot(): AdminProductAnalyticsSnapshot =
|
||||||
|
AdminProductAnalyticsSnapshot(
|
||||||
|
currentWeeklyUsers = 120,
|
||||||
|
previousWeeklyUsers = 100,
|
||||||
|
newInstallations = 100,
|
||||||
|
newAccounts = 80,
|
||||||
|
activation24h = AdminAnalyticsCountRow(60, 100),
|
||||||
|
medianTimeToValueMinutes = 7.6,
|
||||||
|
channels = listOf(
|
||||||
|
AdminAnalyticsChannelRow("APP_STORE_ORGANIC", 100, 60),
|
||||||
|
),
|
||||||
|
dau = 30,
|
||||||
|
wau = 120,
|
||||||
|
mau = 150,
|
||||||
|
periodActiveUsers = 100,
|
||||||
|
successfulAiRequests = 500,
|
||||||
|
consumption = AdminAnalyticsConsumptionRow(
|
||||||
|
totalCredits = 1_000,
|
||||||
|
managedRequests = 400,
|
||||||
|
averageDailyCreditsPerActiveUser = 12.25,
|
||||||
|
medianUserDailyCredits = 8.0,
|
||||||
|
),
|
||||||
|
monetization = AdminAnalyticsMonetizationRow(
|
||||||
|
payingUsers = 10,
|
||||||
|
purchases = 12,
|
||||||
|
creditsPurchased = 8_000,
|
||||||
|
conversion7d = AdminAnalyticsCountRow(8, 70),
|
||||||
|
conversion30d = AdminAnalyticsCountRow(10, 50),
|
||||||
|
repeatPurchase = AdminAnalyticsCountRow(2, 10),
|
||||||
|
),
|
||||||
|
growthFunnel = AdminAnalyticsGrowthFunnelRow(100, 80, 60, 20, 10),
|
||||||
|
retention = listOf(
|
||||||
|
AdminAnalyticsCohortRow(
|
||||||
|
cohortDate = LocalDate.parse("2026-08-01"),
|
||||||
|
size = 20,
|
||||||
|
d1 = 10,
|
||||||
|
d7 = 6,
|
||||||
|
d30 = 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
features = listOf(
|
||||||
|
AdminAnalyticsFeatureRow("POLISH", "MANAGED", 30, 100),
|
||||||
|
),
|
||||||
|
referrals = AdminAnalyticsReferralRow(20, 15, 10, 8, 5),
|
||||||
|
guardrails = AdminAnalyticsGuardrailRow(
|
||||||
|
clientSuccess = AdminAnalyticsCountRow(90, 100),
|
||||||
|
managedSuccess = AdminAnalyticsCountRow(95, 100),
|
||||||
|
creditBlockedUsers = 3,
|
||||||
|
),
|
||||||
|
)
|
||||||
+96
@@ -3,6 +3,8 @@ package com.osglab.account.features.admin.stats
|
|||||||
import com.osglab.account.config.DatabaseConfig
|
import com.osglab.account.config.DatabaseConfig
|
||||||
import com.osglab.account.config.DatabaseFactory
|
import com.osglab.account.config.DatabaseFactory
|
||||||
import com.osglab.account.features.admin.stats.repositories.AdminStatsRange
|
import com.osglab.account.features.admin.stats.repositories.AdminStatsRange
|
||||||
|
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsWindow
|
||||||
|
import com.osglab.account.features.admin.stats.repositories.ExposedAdminProductAnalyticsRepository
|
||||||
import com.osglab.account.features.admin.stats.repositories.ExposedAdminStatsRepository
|
import com.osglab.account.features.admin.stats.repositories.ExposedAdminStatsRepository
|
||||||
import io.kotest.core.spec.style.FunSpec
|
import io.kotest.core.spec.style.FunSpec
|
||||||
import io.kotest.matchers.collections.shouldBeEmpty
|
import io.kotest.matchers.collections.shouldBeEmpty
|
||||||
@@ -11,6 +13,7 @@ import io.kotest.matchers.shouldBe
|
|||||||
import org.opentest4j.TestAbortedException
|
import org.opentest4j.TestAbortedException
|
||||||
import org.testcontainers.DockerClientFactory
|
import org.testcontainers.DockerClientFactory
|
||||||
import org.testcontainers.containers.MySQLContainer
|
import org.testcontainers.containers.MySQLContainer
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
class AdminStatsRepositoryIntegrationTest : FunSpec({
|
class AdminStatsRepositoryIntegrationTest : FunSpec({
|
||||||
@@ -48,6 +51,20 @@ class AdminStatsRepositoryIntegrationTest : FunSpec({
|
|||||||
until = Instant.parse("2026-08-17T12:00:00Z"),
|
until = Instant.parse("2026-08-17T12:00:00Z"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
val analytics = ExposedAdminProductAnalyticsRepository(factory).load(
|
||||||
|
range = AdminAnalyticsWindow(
|
||||||
|
from = Instant.parse("2026-08-10T12:00:00Z"),
|
||||||
|
until = Instant.parse("2026-08-17T12:00:00Z"),
|
||||||
|
),
|
||||||
|
currentWeek = AdminAnalyticsWindow(
|
||||||
|
from = Instant.parse("2026-08-17T00:00:00Z"),
|
||||||
|
until = Instant.parse("2026-08-17T12:00:00Z"),
|
||||||
|
),
|
||||||
|
previousWeek = AdminAnalyticsWindow(
|
||||||
|
from = Instant.parse("2026-08-10T00:00:00Z"),
|
||||||
|
until = Instant.parse("2026-08-10T12:00:00Z"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
// A dedicated container starts empty, so every scalar and grouped aggregate is explicit.
|
// A dedicated container starts empty, so every scalar and grouped aggregate is explicit.
|
||||||
if (mysql != null) {
|
if (mysql != null) {
|
||||||
@@ -59,6 +76,36 @@ class AdminStatsRepositoryIntegrationTest : FunSpec({
|
|||||||
snapshot.registrationsByDate shouldBe emptyMap()
|
snapshot.registrationsByDate shouldBe emptyMap()
|
||||||
snapshot.referralRanking.shouldBeEmpty()
|
snapshot.referralRanking.shouldBeEmpty()
|
||||||
snapshot.usage.shouldBeEmpty()
|
snapshot.usage.shouldBeEmpty()
|
||||||
|
analytics.currentWeeklyUsers shouldBeExactly 0
|
||||||
|
analytics.newInstallations shouldBeExactly 0
|
||||||
|
analytics.retention.shouldBeEmpty()
|
||||||
|
analytics.features.shouldBeEmpty()
|
||||||
|
analytics.consumption.totalCredits shouldBeExactly 0
|
||||||
|
|
||||||
|
factory.query { seedProductAnalytics() }
|
||||||
|
val populated = ExposedAdminProductAnalyticsRepository(factory).load(
|
||||||
|
range = AdminAnalyticsWindow(
|
||||||
|
from = Instant.parse("2026-08-10T00:00:00Z"),
|
||||||
|
until = Instant.parse("2026-08-17T00:00:00Z"),
|
||||||
|
),
|
||||||
|
currentWeek = AdminAnalyticsWindow(
|
||||||
|
from = Instant.parse("2026-08-10T00:00:00Z"),
|
||||||
|
until = Instant.parse("2026-08-17T00:00:00Z"),
|
||||||
|
),
|
||||||
|
previousWeek = AdminAnalyticsWindow(
|
||||||
|
from = Instant.parse("2026-08-03T00:00:00Z"),
|
||||||
|
until = Instant.parse("2026-08-10T00:00:00Z"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
populated.currentWeeklyUsers shouldBeExactly 1
|
||||||
|
populated.newInstallations shouldBeExactly 1
|
||||||
|
populated.activation24h shouldBe
|
||||||
|
com.osglab.account.features.admin.stats.repositories.AdminAnalyticsCountRow(1, 1)
|
||||||
|
populated.periodActiveUsers shouldBeExactly 1
|
||||||
|
populated.successfulAiRequests shouldBeExactly 2
|
||||||
|
populated.features.single().successes shouldBeExactly 2
|
||||||
|
populated.retention.single().d1 shouldBeExactly 1
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
factory.close()
|
factory.close()
|
||||||
@@ -69,3 +116,52 @@ class AdminStatsRepositoryIntegrationTest : FunSpec({
|
|||||||
|
|
||||||
private class StatsMySqlContainer(image: String) :
|
private class StatsMySqlContainer(image: String) :
|
||||||
MySQLContainer<StatsMySqlContainer>(image)
|
MySQLContainer<StatsMySqlContainer>(image)
|
||||||
|
|
||||||
|
private fun seedProductAnalytics() {
|
||||||
|
TransactionManager.current().exec(
|
||||||
|
"""
|
||||||
|
INSERT INTO product_analytics_installations (
|
||||||
|
installation_hash, account_id, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
'${"a".repeat(64)}', NULL,
|
||||||
|
'2026-08-11 00:00:00.000000', '2026-08-12 00:10:00.000000'
|
||||||
|
)
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
listOf(
|
||||||
|
"""
|
||||||
|
(
|
||||||
|
'${"a".repeat(64)}', '40000000-0000-0000-0000-000000000001',
|
||||||
|
'FIRST_OPEN', '2026-08-11 00:00:00.000000', 'APP',
|
||||||
|
'APP_STORE_ORGANIC', NULL, NULL, NULL, NULL,
|
||||||
|
'1.0', '18.6', '${"1".repeat(64)}', '2026-08-11 00:00:01.000000'
|
||||||
|
)
|
||||||
|
""".trimIndent(),
|
||||||
|
"""
|
||||||
|
(
|
||||||
|
'${"a".repeat(64)}', '40000000-0000-0000-0000-000000000002',
|
||||||
|
'AI_FEATURE_SUCCEEDED', '2026-08-11 00:10:00.000000', 'KEYBOARD',
|
||||||
|
NULL, 'POLISH', 'LOCAL', NULL, 'S1_TO_3',
|
||||||
|
'1.0', '18.6', '${"2".repeat(64)}', '2026-08-11 00:10:01.000000'
|
||||||
|
)
|
||||||
|
""".trimIndent(),
|
||||||
|
"""
|
||||||
|
(
|
||||||
|
'${"a".repeat(64)}', '40000000-0000-0000-0000-000000000003',
|
||||||
|
'AI_FEATURE_SUCCEEDED', '2026-08-12 00:10:00.000000', 'KEYBOARD',
|
||||||
|
NULL, 'POLISH', 'LOCAL', NULL, 'S1_TO_3',
|
||||||
|
'1.0', '18.6', '${"3".repeat(64)}', '2026-08-12 00:10:01.000000'
|
||||||
|
)
|
||||||
|
""".trimIndent(),
|
||||||
|
).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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+255
@@ -0,0 +1,255 @@
|
|||||||
|
package com.osglab.account.features.analytics
|
||||||
|
|
||||||
|
import com.osglab.account.common.errors.ConflictException
|
||||||
|
import com.osglab.account.config.DatabaseConfig
|
||||||
|
import com.osglab.account.config.DatabaseFactory
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsEventType
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsIngestResult
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsSurface
|
||||||
|
import com.osglab.account.features.analytics.models.AnalyticsBatchRequest
|
||||||
|
import com.osglab.account.features.analytics.models.AnalyticsEventRequest
|
||||||
|
import com.osglab.account.features.analytics.repositories.ExposedAnalyticsRepository
|
||||||
|
import com.osglab.account.features.analytics.services.DefaultAnalyticsService
|
||||||
|
import io.kotest.assertions.throwables.shouldThrow
|
||||||
|
import io.kotest.core.spec.style.FunSpec
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.sql.DriverManager
|
||||||
|
import java.time.Clock
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.util.UUID
|
||||||
|
import org.opentest4j.TestAbortedException
|
||||||
|
import org.testcontainers.DockerClientFactory
|
||||||
|
import org.testcontainers.containers.MySQLContainer
|
||||||
|
|
||||||
|
class AnalyticsRepositoryIntegrationTest : FunSpec({
|
||||||
|
test("V16 repository links accounts replays atomically and cascades account deletion") {
|
||||||
|
withAnalyticsDatabase { config, databaseFactory ->
|
||||||
|
val now = Instant.parse("2026-08-20T01:00:00Z")
|
||||||
|
val accountId = UUID.fromString("20000000-0000-0000-0000-000000000001")
|
||||||
|
val otherAccountId = UUID.fromString("20000000-0000-0000-0000-000000000002")
|
||||||
|
val installationId = "10000000-0000-0000-0000-000000000001"
|
||||||
|
insertAccounts(config, listOf(accountId, otherAccountId), now)
|
||||||
|
val repository = ExposedAnalyticsRepository(databaseFactory)
|
||||||
|
val service = DefaultAnalyticsService(
|
||||||
|
repository = repository,
|
||||||
|
clock = Clock.fixed(now, ZoneOffset.UTC),
|
||||||
|
)
|
||||||
|
val original = event(
|
||||||
|
id = "30000000-0000-0000-0000-000000000001",
|
||||||
|
now = now,
|
||||||
|
surface = AnalyticsSurface.APP,
|
||||||
|
)
|
||||||
|
val request = AnalyticsBatchRequest(installationId, listOf(original))
|
||||||
|
|
||||||
|
service.ingest(null, request) shouldBe AnalyticsIngestResult(1, 0)
|
||||||
|
service.ingest(accountId, request) shouldBe AnalyticsIngestResult(0, 1)
|
||||||
|
service.ingest(accountId, request) shouldBe AnalyticsIngestResult(0, 1)
|
||||||
|
shouldThrow<ConflictException> {
|
||||||
|
service.ingest(otherAccountId, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
installationCount(config, installationId) shouldBe 0
|
||||||
|
installationCount(config, installationId.sha256Hex()) shouldBe 1
|
||||||
|
linkedAccount(config, installationId.sha256Hex()) shouldBe accountId.toString()
|
||||||
|
eventCount(config) shouldBe 1
|
||||||
|
repository.recordInvitePageOpen(now)
|
||||||
|
repository.recordInvitePageOpen(now.plusSeconds(30))
|
||||||
|
scalarInt(
|
||||||
|
config,
|
||||||
|
"SELECT counter_value FROM product_analytics_daily_counters " +
|
||||||
|
"WHERE counter_name = 'INVITE_PAGE_OPENED'",
|
||||||
|
) shouldBe 2
|
||||||
|
|
||||||
|
val concurrentRequest = AnalyticsBatchRequest(
|
||||||
|
installationId = "10000000-0000-0000-0000-000000000099",
|
||||||
|
events = listOf(
|
||||||
|
event(
|
||||||
|
id = "30000000-0000-0000-0000-000000000099",
|
||||||
|
now = now,
|
||||||
|
surface = AnalyticsSurface.APP,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val concurrentResults = coroutineScope {
|
||||||
|
List(8) {
|
||||||
|
async { service.ingest(null, concurrentRequest) }
|
||||||
|
}.awaitAll()
|
||||||
|
}
|
||||||
|
concurrentResults.sumOf(AnalyticsIngestResult::accepted) shouldBe 1
|
||||||
|
concurrentResults.sumOf(AnalyticsIngestResult::replayed) shouldBe 7
|
||||||
|
markInstallationUpdatedAt(
|
||||||
|
config,
|
||||||
|
concurrentRequest.installationId.sha256Hex(),
|
||||||
|
now.minusSeconds(91L * 24 * 60 * 60),
|
||||||
|
)
|
||||||
|
repository.purgeAnonymousInstallations(
|
||||||
|
before = now.minusSeconds(90L * 24 * 60 * 60),
|
||||||
|
limit = 100,
|
||||||
|
) shouldBe 1
|
||||||
|
installationCount(config, concurrentRequest.installationId.sha256Hex()) shouldBe 0
|
||||||
|
|
||||||
|
shouldThrow<ConflictException> {
|
||||||
|
service.ingest(
|
||||||
|
accountId,
|
||||||
|
AnalyticsBatchRequest(
|
||||||
|
installationId = installationId,
|
||||||
|
events = listOf(
|
||||||
|
event(
|
||||||
|
id = "30000000-0000-0000-0000-000000000002",
|
||||||
|
now = now,
|
||||||
|
surface = AnalyticsSurface.APP,
|
||||||
|
),
|
||||||
|
original.copy(surface = AnalyticsSurface.KEYBOARD),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
eventCount(config) shouldBe 1
|
||||||
|
|
||||||
|
deleteAccount(config, accountId)
|
||||||
|
installationCount(config, installationId.sha256Hex()) shouldBe 0
|
||||||
|
eventCount(config) shouldBe 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
private fun event(
|
||||||
|
id: String,
|
||||||
|
now: Instant,
|
||||||
|
surface: AnalyticsSurface,
|
||||||
|
) = AnalyticsEventRequest(
|
||||||
|
clientEventId = id,
|
||||||
|
eventType = AnalyticsEventType.SESSION_STARTED,
|
||||||
|
occurredAt = now.toString(),
|
||||||
|
surface = surface,
|
||||||
|
)
|
||||||
|
|
||||||
|
private suspend fun withAnalyticsDatabase(
|
||||||
|
block: suspend (DatabaseConfig, DatabaseFactory) -> Unit,
|
||||||
|
) {
|
||||||
|
val externalJdbcUrl = System.getenv("TEST_MYSQL_JDBC_URL")?.takeIf(String::isNotBlank)
|
||||||
|
if (externalJdbcUrl == null && !DockerClientFactory.instance().isDockerAvailable) {
|
||||||
|
throw TestAbortedException("Docker is unavailable; MySQL integration test skipped")
|
||||||
|
}
|
||||||
|
val mysql = if (externalJdbcUrl == null) {
|
||||||
|
AnalyticsMySqlContainer("mysql:8.4")
|
||||||
|
.withDatabaseName("osg_analytics_test")
|
||||||
|
.withUsername("test")
|
||||||
|
.withPassword("test")
|
||||||
|
.also(AnalyticsMySqlContainer::start)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val config = DatabaseConfig(
|
||||||
|
jdbcUrl = externalJdbcUrl ?: requireNotNull(mysql).jdbcUrl,
|
||||||
|
username = System.getenv("TEST_MYSQL_USER")?.takeIf(String::isNotBlank)
|
||||||
|
?: mysql?.username
|
||||||
|
?: "root",
|
||||||
|
password = System.getenv("TEST_MYSQL_PASSWORD") ?: mysql?.password ?: "",
|
||||||
|
maximumPoolSize = 4,
|
||||||
|
)
|
||||||
|
val databaseFactory = DatabaseFactory(config)
|
||||||
|
try {
|
||||||
|
databaseFactory.database
|
||||||
|
block(config, databaseFactory)
|
||||||
|
} finally {
|
||||||
|
databaseFactory.close()
|
||||||
|
mysql?.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun insertAccounts(
|
||||||
|
config: DatabaseConfig,
|
||||||
|
accountIds: List<UUID>,
|
||||||
|
now: Instant,
|
||||||
|
) {
|
||||||
|
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
|
||||||
|
connection.prepareStatement(
|
||||||
|
"""
|
||||||
|
INSERT INTO accounts (id, apple_sub, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""".trimIndent()
|
||||||
|
).use { statement ->
|
||||||
|
accountIds.forEach { accountId ->
|
||||||
|
statement.setString(1, accountId.toString())
|
||||||
|
statement.setString(2, "analytics-test-$accountId")
|
||||||
|
statement.setTimestamp(3, java.sql.Timestamp.from(now))
|
||||||
|
statement.setTimestamp(4, java.sql.Timestamp.from(now))
|
||||||
|
statement.addBatch()
|
||||||
|
}
|
||||||
|
statement.executeBatch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun installationCount(config: DatabaseConfig, hash: String): Int =
|
||||||
|
scalarInt(
|
||||||
|
config,
|
||||||
|
"SELECT COUNT(*) FROM product_analytics_installations WHERE installation_hash = ?",
|
||||||
|
hash,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun eventCount(config: DatabaseConfig): Int =
|
||||||
|
scalarInt(config, "SELECT COUNT(*) FROM product_analytics_events")
|
||||||
|
|
||||||
|
private fun linkedAccount(config: DatabaseConfig, hash: String): String? =
|
||||||
|
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
|
||||||
|
connection.prepareStatement(
|
||||||
|
"SELECT account_id FROM product_analytics_installations WHERE installation_hash = ?"
|
||||||
|
).use { statement ->
|
||||||
|
statement.setString(1, hash)
|
||||||
|
statement.executeQuery().use { result ->
|
||||||
|
result.next()
|
||||||
|
result.getString(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scalarInt(config: DatabaseConfig, sql: String, argument: String? = null): Int =
|
||||||
|
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
|
||||||
|
connection.prepareStatement(sql).use { statement ->
|
||||||
|
argument?.let { statement.setString(1, it) }
|
||||||
|
statement.executeQuery().use { result ->
|
||||||
|
result.next()
|
||||||
|
result.getInt(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteAccount(config: DatabaseConfig, accountId: UUID) {
|
||||||
|
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
|
||||||
|
connection.prepareStatement("DELETE FROM accounts WHERE id = ?").use { statement ->
|
||||||
|
statement.setString(1, accountId.toString())
|
||||||
|
statement.executeUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun markInstallationUpdatedAt(
|
||||||
|
config: DatabaseConfig,
|
||||||
|
installationHash: String,
|
||||||
|
updatedAt: Instant,
|
||||||
|
) {
|
||||||
|
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
|
||||||
|
connection.prepareStatement(
|
||||||
|
"UPDATE product_analytics_installations SET updated_at = ? WHERE installation_hash = ?"
|
||||||
|
).use { statement ->
|
||||||
|
statement.setTimestamp(1, java.sql.Timestamp.from(updatedAt))
|
||||||
|
statement.setString(2, installationHash)
|
||||||
|
statement.executeUpdate() shouldBe 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.sha256Hex(): String =
|
||||||
|
MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(toByteArray(Charsets.UTF_8))
|
||||||
|
.joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) }
|
||||||
|
|
||||||
|
private class AnalyticsMySqlContainer(image: String) :
|
||||||
|
MySQLContainer<AnalyticsMySqlContainer>(image)
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package com.osglab.account.features.analytics
|
||||||
|
|
||||||
|
import com.osglab.account.common.api.installApiStatusPages
|
||||||
|
import com.osglab.account.common.errors.ConflictException
|
||||||
|
import com.osglab.account.common.security.AccountPrincipal
|
||||||
|
import com.osglab.account.common.security.installSessionAuthentication
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsEventTimeException
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsIngestResult
|
||||||
|
import com.osglab.account.features.analytics.models.AnalyticsBatchRequest
|
||||||
|
import com.osglab.account.features.analytics.routes.analyticsRoutes
|
||||||
|
import com.osglab.account.features.analytics.services.AnalyticsService
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
import io.kotest.matchers.string.shouldContain
|
||||||
|
import io.kotest.matchers.string.shouldNotContain
|
||||||
|
import io.ktor.client.request.bearerAuth
|
||||||
|
import io.ktor.client.request.post
|
||||||
|
import io.ktor.client.request.setBody
|
||||||
|
import io.ktor.client.statement.bodyAsText
|
||||||
|
import io.ktor.http.ContentType
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.http.contentType
|
||||||
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
import io.ktor.server.application.install
|
||||||
|
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
|
||||||
|
import io.ktor.server.routing.routing
|
||||||
|
import io.ktor.server.testing.testApplication
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlin.test.Test
|
||||||
|
|
||||||
|
class AnalyticsRoutesTest {
|
||||||
|
private val accountId = UUID.fromString("20000000-0000-0000-0000-000000000001")
|
||||||
|
private val sessionId = UUID.fromString("30000000-0000-0000-0000-000000000001")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `route accepts anonymous and authenticated batches without returning identity`() =
|
||||||
|
testApplication {
|
||||||
|
val service = RecordingAnalyticsService()
|
||||||
|
application {
|
||||||
|
install(ContentNegotiation) { json(Json { explicitNulls = false }) }
|
||||||
|
installApiStatusPages()
|
||||||
|
installSessionAuthentication { token ->
|
||||||
|
token.takeIf { it == "valid-token" }?.let {
|
||||||
|
AccountPrincipal(accountId, sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
routing { analyticsRoutes(service) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val anonymous = client.post("/v1/analytics/events") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(validBody())
|
||||||
|
}
|
||||||
|
val authenticated = client.post("/v1/analytics/events") {
|
||||||
|
bearerAuth("valid-token")
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(validBody())
|
||||||
|
}
|
||||||
|
val invalidBearer = client.post("/v1/analytics/events") {
|
||||||
|
bearerAuth("invalid-token")
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(validBody())
|
||||||
|
}
|
||||||
|
|
||||||
|
anonymous.status shouldBe HttpStatusCode.OK
|
||||||
|
anonymous.bodyAsText() shouldBe """{"accepted":1,"replayed":0}"""
|
||||||
|
authenticated.status shouldBe HttpStatusCode.OK
|
||||||
|
authenticated.bodyAsText() shouldNotContain accountId.toString()
|
||||||
|
authenticated.bodyAsText() shouldNotContain "installationId"
|
||||||
|
invalidBearer.status shouldBe HttpStatusCode.Unauthorized
|
||||||
|
service.accountIds shouldBe listOf(null, accountId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `route returns stable validation conflict and event-time errors`() = testApplication {
|
||||||
|
val service = ErrorAnalyticsService()
|
||||||
|
application {
|
||||||
|
install(ContentNegotiation) { json(Json { explicitNulls = false }) }
|
||||||
|
installApiStatusPages()
|
||||||
|
installSessionAuthentication { null }
|
||||||
|
routing { analyticsRoutes(service) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val invalidEnum = client.post("/v1/analytics/events") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(validBody().replace("SESSION_STARTED", "ARBITRARY_EVENT"))
|
||||||
|
}
|
||||||
|
val unknownField = client.post("/v1/analytics/events") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(validBody().replace("\"appVersion\"", "\"userText\":\"forbidden\",\"appVersion\""))
|
||||||
|
}
|
||||||
|
val conflict = client.post("/v1/analytics/events") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(validBody().replace(INSTALLATION_ID, CONFLICT_INSTALLATION_ID))
|
||||||
|
}
|
||||||
|
val invalidTime = client.post("/v1/analytics/events") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(validBody().replace(INSTALLATION_ID, INVALID_TIME_INSTALLATION_ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidEnum.status shouldBe HttpStatusCode.BadRequest
|
||||||
|
invalidEnum.bodyAsText() shouldContain """"code":"invalid_request""""
|
||||||
|
unknownField.status shouldBe HttpStatusCode.BadRequest
|
||||||
|
unknownField.bodyAsText() shouldContain """"code":"invalid_request""""
|
||||||
|
conflict.status shouldBe HttpStatusCode.Conflict
|
||||||
|
conflict.bodyAsText() shouldContain """"code":"conflict""""
|
||||||
|
invalidTime.status shouldBe HttpStatusCode.UnprocessableEntity
|
||||||
|
invalidTime.bodyAsText() shouldContain """"code":"event_time_invalid""""
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validBody(): String =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"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 companion object {
|
||||||
|
const val INSTALLATION_ID = "10000000-0000-0000-0000-000000000001"
|
||||||
|
const val CONFLICT_INSTALLATION_ID = "10000000-0000-0000-0000-000000000002"
|
||||||
|
const val INVALID_TIME_INSTALLATION_ID = "10000000-0000-0000-0000-000000000003"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class RecordingAnalyticsService : AnalyticsService {
|
||||||
|
val accountIds = mutableListOf<UUID?>()
|
||||||
|
|
||||||
|
override suspend fun ingest(
|
||||||
|
accountId: UUID?,
|
||||||
|
request: AnalyticsBatchRequest,
|
||||||
|
): AnalyticsIngestResult {
|
||||||
|
accountIds += accountId
|
||||||
|
return AnalyticsIngestResult(accepted = request.events.size, replayed = 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ErrorAnalyticsService : AnalyticsService {
|
||||||
|
override suspend fun ingest(
|
||||||
|
accountId: UUID?,
|
||||||
|
request: AnalyticsBatchRequest,
|
||||||
|
): AnalyticsIngestResult = when (request.installationId) {
|
||||||
|
"10000000-0000-0000-0000-000000000002" -> throw ConflictException("Conflict")
|
||||||
|
"10000000-0000-0000-0000-000000000003" -> throw AnalyticsEventTimeException()
|
||||||
|
else -> AnalyticsIngestResult(request.events.size, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
package com.osglab.account.features.analytics
|
||||||
|
|
||||||
|
import com.osglab.account.common.errors.ConflictException
|
||||||
|
import com.osglab.account.common.errors.InvalidRequestException
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsAcquisitionChannel
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsBatch
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsDurationBucket
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsEventType
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsEventTimeException
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsExecutionMode
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsFailureCategory
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsFeature
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsIngestResult
|
||||||
|
import com.osglab.account.features.analytics.domain.AnalyticsSurface
|
||||||
|
import com.osglab.account.features.analytics.models.AnalyticsBatchRequest
|
||||||
|
import com.osglab.account.features.analytics.models.AnalyticsEventRequest
|
||||||
|
import com.osglab.account.features.analytics.repositories.AnalyticsRepository
|
||||||
|
import com.osglab.account.features.analytics.services.AnalyticsMaintenanceService
|
||||||
|
import com.osglab.account.features.analytics.services.DefaultAnalyticsService
|
||||||
|
import io.kotest.assertions.throwables.shouldThrow
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
import io.kotest.matchers.string.shouldNotContain
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.time.Clock
|
||||||
|
import java.time.Duration
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlin.test.Test
|
||||||
|
|
||||||
|
class AnalyticsServiceTest {
|
||||||
|
private val now = Instant.parse("2026-08-20T01:00:00Z")
|
||||||
|
private val installationId = "10000000-0000-0000-0000-000000000001"
|
||||||
|
private val accountId = UUID.fromString("20000000-0000-0000-0000-000000000001")
|
||||||
|
private val otherAccountId = UUID.fromString("20000000-0000-0000-0000-000000000002")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `anonymous events are accepted and only the installation digest reaches persistence`(): Unit =
|
||||||
|
kotlinx.coroutines.runBlocking {
|
||||||
|
val repository = InMemoryAnalyticsRepository()
|
||||||
|
val request = batch(firstOpen())
|
||||||
|
val result = service(repository).ingest(null, request)
|
||||||
|
|
||||||
|
result shouldBe AnalyticsIngestResult(accepted = 1, replayed = 0)
|
||||||
|
repository.lastBatch?.accountId shouldBe null
|
||||||
|
repository.lastBatch?.installationHash shouldBe installationId.sha256Hex()
|
||||||
|
repository.lastBatch.toString() shouldNotContain installationId
|
||||||
|
request.toString() shouldNotContain installationId
|
||||||
|
request.toString() shouldNotContain firstOpen().clientEventId
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `authenticated ingestion links an anonymous installation and rejects another account`(): Unit =
|
||||||
|
kotlinx.coroutines.runBlocking {
|
||||||
|
val repository = InMemoryAnalyticsRepository()
|
||||||
|
val service = service(repository)
|
||||||
|
val request = batch(sessionStarted())
|
||||||
|
|
||||||
|
service.ingest(null, request) shouldBe AnalyticsIngestResult(1, 0)
|
||||||
|
service.ingest(accountId, request) shouldBe AnalyticsIngestResult(0, 1)
|
||||||
|
service.ingest(accountId, request) shouldBe AnalyticsIngestResult(0, 1)
|
||||||
|
shouldThrow<ConflictException> {
|
||||||
|
service.ingest(otherAccountId, request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `complete event catalog accepts only its declared dimensions`(): Unit =
|
||||||
|
kotlinx.coroutines.runBlocking {
|
||||||
|
val repository = InMemoryAnalyticsRepository()
|
||||||
|
val validEvents = listOf(
|
||||||
|
firstOpen(),
|
||||||
|
sessionStarted(id = uuid(2)),
|
||||||
|
event(
|
||||||
|
id = uuid(3),
|
||||||
|
type = AnalyticsEventType.KEYBOARD_ACTIVATED,
|
||||||
|
surface = AnalyticsSurface.KEYBOARD,
|
||||||
|
),
|
||||||
|
event(
|
||||||
|
id = uuid(4),
|
||||||
|
type = AnalyticsEventType.AI_FEATURE_STARTED,
|
||||||
|
feature = AnalyticsFeature.POLISH,
|
||||||
|
executionMode = AnalyticsExecutionMode.LOCAL,
|
||||||
|
),
|
||||||
|
event(
|
||||||
|
id = uuid(5),
|
||||||
|
type = AnalyticsEventType.AI_FEATURE_SUCCEEDED,
|
||||||
|
feature = AnalyticsFeature.AI_ASSISTANT,
|
||||||
|
executionMode = AnalyticsExecutionMode.BYOK,
|
||||||
|
durationBucket = AnalyticsDurationBucket.S1_TO_3,
|
||||||
|
),
|
||||||
|
event(
|
||||||
|
id = uuid(6),
|
||||||
|
type = AnalyticsEventType.AI_FEATURE_FAILED,
|
||||||
|
feature = AnalyticsFeature.TRANSCRIPTION,
|
||||||
|
executionMode = AnalyticsExecutionMode.MANAGED,
|
||||||
|
failureCategory = AnalyticsFailureCategory.TIMEOUT,
|
||||||
|
durationBucket = AnalyticsDurationBucket.S10_TO_30,
|
||||||
|
),
|
||||||
|
event(id = uuid(7), type = AnalyticsEventType.PURCHASE_VIEWED),
|
||||||
|
event(id = uuid(8), type = AnalyticsEventType.PURCHASE_STARTED),
|
||||||
|
event(
|
||||||
|
id = uuid(9),
|
||||||
|
type = AnalyticsEventType.PURCHASE_CANCELLED,
|
||||||
|
failureCategory = AnalyticsFailureCategory.CANCELLED,
|
||||||
|
),
|
||||||
|
event(id = uuid(10), type = AnalyticsEventType.REFERRAL_SHARED),
|
||||||
|
event(
|
||||||
|
id = uuid(11),
|
||||||
|
type = AnalyticsEventType.INVITE_OPENED,
|
||||||
|
surface = AnalyticsSurface.INVITE_WEB,
|
||||||
|
acquisitionChannel = AnalyticsAcquisitionChannel.REFERRAL,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
service(repository).ingest(null, batch(events = validEvents)) shouldBe
|
||||||
|
AnalyticsIngestResult(validEvents.size, 0)
|
||||||
|
|
||||||
|
listOf(
|
||||||
|
firstOpen().copy(acquisitionChannel = null),
|
||||||
|
sessionStarted().copy(surface = AnalyticsSurface.INVITE_WEB),
|
||||||
|
event(
|
||||||
|
type = AnalyticsEventType.KEYBOARD_ACTIVATED,
|
||||||
|
surface = AnalyticsSurface.APP,
|
||||||
|
),
|
||||||
|
event(
|
||||||
|
type = AnalyticsEventType.AI_FEATURE_STARTED,
|
||||||
|
feature = AnalyticsFeature.POLISH,
|
||||||
|
executionMode = AnalyticsExecutionMode.LOCAL,
|
||||||
|
durationBucket = AnalyticsDurationBucket.LT_1S,
|
||||||
|
),
|
||||||
|
event(
|
||||||
|
type = AnalyticsEventType.AI_FEATURE_SUCCEEDED,
|
||||||
|
feature = AnalyticsFeature.POLISH,
|
||||||
|
executionMode = AnalyticsExecutionMode.LOCAL,
|
||||||
|
),
|
||||||
|
event(
|
||||||
|
type = AnalyticsEventType.AI_FEATURE_FAILED,
|
||||||
|
feature = AnalyticsFeature.POLISH,
|
||||||
|
executionMode = AnalyticsExecutionMode.LOCAL,
|
||||||
|
),
|
||||||
|
event(
|
||||||
|
type = AnalyticsEventType.PURCHASE_CANCELLED,
|
||||||
|
failureCategory = AnalyticsFailureCategory.NETWORK,
|
||||||
|
),
|
||||||
|
event(
|
||||||
|
type = AnalyticsEventType.INVITE_OPENED,
|
||||||
|
surface = AnalyticsSurface.INVITE_WEB,
|
||||||
|
acquisitionChannel = AnalyticsAcquisitionChannel.UNKNOWN,
|
||||||
|
),
|
||||||
|
).forEach { invalidEvent ->
|
||||||
|
shouldThrow<InvalidRequestException> {
|
||||||
|
service(repository).ingest(null, batch(invalidEvent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `batch UUID release identifier and timestamp validation use stable errors`(): Unit =
|
||||||
|
kotlinx.coroutines.runBlocking {
|
||||||
|
val repository = InMemoryAnalyticsRepository()
|
||||||
|
val service = service(repository)
|
||||||
|
|
||||||
|
listOf(
|
||||||
|
AnalyticsBatchRequest("not-a-uuid", listOf(sessionStarted())),
|
||||||
|
batch(sessionStarted().copy(clientEventId = "not-a-uuid")),
|
||||||
|
batch(sessionStarted().copy(appVersion = "")),
|
||||||
|
batch(sessionStarted().copy(appVersion = "1.0 beta")),
|
||||||
|
batch(sessionStarted().copy(osVersion = "版本")),
|
||||||
|
batch(sessionStarted().copy(osVersion = "x".repeat(33))),
|
||||||
|
batch(sessionStarted().copy(occurredAt = "2026-08-20T01:00:00+01:00")),
|
||||||
|
AnalyticsBatchRequest(installationId, emptyList()),
|
||||||
|
AnalyticsBatchRequest(installationId, List(51) { sessionStarted() }),
|
||||||
|
).forEach { invalid ->
|
||||||
|
shouldThrow<InvalidRequestException> {
|
||||||
|
service.ingest(null, invalid)
|
||||||
|
}.code shouldBe "invalid_request"
|
||||||
|
}
|
||||||
|
repository.eventCount shouldBe 0
|
||||||
|
|
||||||
|
listOf(
|
||||||
|
now.minusSeconds(35L * 24 * 60 * 60 + 1),
|
||||||
|
now.plusSeconds(5L * 60 + 1),
|
||||||
|
).forEach { outside ->
|
||||||
|
shouldThrow<AnalyticsEventTimeException> {
|
||||||
|
service.ingest(null, batch(sessionStarted().copy(occurredAt = outside.toString())))
|
||||||
|
}.code shouldBe "event_time_invalid"
|
||||||
|
}
|
||||||
|
|
||||||
|
val maximumBatch = List(50) { index -> sessionStarted(uuid(100 + index)) }
|
||||||
|
service.ingest(null, batch(maximumBatch)) shouldBe AnalyticsIngestResult(50, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `time boundaries are inclusive and batch persistence is atomic on conflict`(): Unit =
|
||||||
|
kotlinx.coroutines.runBlocking {
|
||||||
|
val repository = InMemoryAnalyticsRepository()
|
||||||
|
val service = service(repository)
|
||||||
|
val oldest = sessionStarted(uuid(20)).copy(
|
||||||
|
occurredAt = now.minusSeconds(35L * 24 * 60 * 60).toString()
|
||||||
|
)
|
||||||
|
val newest = sessionStarted(uuid(21)).copy(occurredAt = now.plusSeconds(5 * 60).toString())
|
||||||
|
|
||||||
|
service.ingest(null, batch(events = listOf(oldest, newest))) shouldBe
|
||||||
|
AnalyticsIngestResult(2, 0)
|
||||||
|
|
||||||
|
val original = sessionStarted(uuid(30))
|
||||||
|
service.ingest(null, batch(original)) shouldBe AnalyticsIngestResult(1, 0)
|
||||||
|
service.ingest(null, batch(original)) shouldBe AnalyticsIngestResult(0, 1)
|
||||||
|
val eventCountBeforeConflict = repository.eventCount
|
||||||
|
shouldThrow<ConflictException> {
|
||||||
|
service.ingest(
|
||||||
|
null,
|
||||||
|
batch(
|
||||||
|
events = listOf(
|
||||||
|
sessionStarted(uuid(31)),
|
||||||
|
original.copy(surface = AnalyticsSurface.KEYBOARD),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
repository.eventCount shouldBe eventCountBeforeConflict
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `maintenance purges only anonymous installations older than ninety days`(): Unit =
|
||||||
|
kotlinx.coroutines.runBlocking {
|
||||||
|
val repository = InMemoryAnalyticsRepository()
|
||||||
|
val maintenance = AnalyticsMaintenanceService(
|
||||||
|
repository = repository,
|
||||||
|
clock = Clock.fixed(now, ZoneOffset.UTC),
|
||||||
|
anonymousRetention = Duration.ofDays(90),
|
||||||
|
)
|
||||||
|
|
||||||
|
maintenance.purgeStaleAnonymousInstallations() shouldBe 0
|
||||||
|
repository.lastPurgeBefore shouldBe now.minus(Duration.ofDays(90))
|
||||||
|
repository.lastPurgeLimit shouldBe 1_000
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun service(repository: AnalyticsRepository) =
|
||||||
|
DefaultAnalyticsService(repository, Clock.fixed(now, ZoneOffset.UTC))
|
||||||
|
|
||||||
|
private fun batch(
|
||||||
|
event: AnalyticsEventRequest,
|
||||||
|
) = batch(events = listOf(event))
|
||||||
|
|
||||||
|
private fun batch(
|
||||||
|
events: List<AnalyticsEventRequest>,
|
||||||
|
) = AnalyticsBatchRequest(installationId = installationId, events = events)
|
||||||
|
|
||||||
|
private fun firstOpen() = event(
|
||||||
|
id = uuid(1),
|
||||||
|
type = AnalyticsEventType.FIRST_OPEN,
|
||||||
|
acquisitionChannel = AnalyticsAcquisitionChannel.APP_STORE_ORGANIC,
|
||||||
|
appVersion = "1.2.3",
|
||||||
|
osVersion = "18.6",
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun sessionStarted(id: String = uuid(12)) = event(
|
||||||
|
id = id,
|
||||||
|
type = AnalyticsEventType.SESSION_STARTED,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun event(
|
||||||
|
id: String = uuid(40),
|
||||||
|
type: AnalyticsEventType,
|
||||||
|
surface: AnalyticsSurface = AnalyticsSurface.APP,
|
||||||
|
acquisitionChannel: AnalyticsAcquisitionChannel? = null,
|
||||||
|
feature: AnalyticsFeature? = null,
|
||||||
|
executionMode: AnalyticsExecutionMode? = null,
|
||||||
|
failureCategory: AnalyticsFailureCategory? = null,
|
||||||
|
durationBucket: AnalyticsDurationBucket? = null,
|
||||||
|
appVersion: String? = null,
|
||||||
|
osVersion: String? = null,
|
||||||
|
) = AnalyticsEventRequest(
|
||||||
|
clientEventId = id,
|
||||||
|
eventType = type,
|
||||||
|
occurredAt = now.toString(),
|
||||||
|
surface = surface,
|
||||||
|
acquisitionChannel = acquisitionChannel,
|
||||||
|
feature = feature,
|
||||||
|
executionMode = executionMode,
|
||||||
|
failureCategory = failureCategory,
|
||||||
|
durationBucket = durationBucket,
|
||||||
|
appVersion = appVersion,
|
||||||
|
osVersion = osVersion,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun uuid(number: Int): String =
|
||||||
|
"30000000-0000-0000-0000-${number.toString().padStart(12, '0')}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private class InMemoryAnalyticsRepository : AnalyticsRepository {
|
||||||
|
private val linkedAccounts = mutableMapOf<String, UUID?>()
|
||||||
|
private val payloads = mutableMapOf<Pair<String, UUID>, String>()
|
||||||
|
var lastBatch: AnalyticsBatch? = null
|
||||||
|
private set
|
||||||
|
val eventCount: Int get() = payloads.size
|
||||||
|
var lastPurgeBefore: Instant? = null
|
||||||
|
private set
|
||||||
|
var lastPurgeLimit: Int? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
override suspend fun recordInvitePageOpen(occurredAt: Instant) = Unit
|
||||||
|
override suspend fun purgeAnonymousInstallations(before: Instant, limit: Int): Int {
|
||||||
|
lastPurgeBefore = before
|
||||||
|
lastPurgeLimit = limit
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun ingest(batch: AnalyticsBatch): AnalyticsIngestResult {
|
||||||
|
val accountsCopy = linkedAccounts.toMutableMap()
|
||||||
|
val payloadsCopy = payloads.toMutableMap()
|
||||||
|
val existingAccount = accountsCopy[batch.installationHash]
|
||||||
|
if (batch.installationHash !in accountsCopy) {
|
||||||
|
accountsCopy[batch.installationHash] = batch.accountId
|
||||||
|
} else if (batch.accountId != null) {
|
||||||
|
when {
|
||||||
|
existingAccount == null -> accountsCopy[batch.installationHash] = batch.accountId
|
||||||
|
existingAccount != batch.accountId ->
|
||||||
|
throw ConflictException("Installation is linked to another account")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var accepted = 0
|
||||||
|
var replayed = 0
|
||||||
|
batch.events.forEach { event ->
|
||||||
|
val key = batch.installationHash to event.clientEventId
|
||||||
|
val existingHash = payloadsCopy[key]
|
||||||
|
when {
|
||||||
|
existingHash == null -> {
|
||||||
|
payloadsCopy[key] = event.payloadHash
|
||||||
|
accepted += 1
|
||||||
|
}
|
||||||
|
existingHash == event.payloadHash -> replayed += 1
|
||||||
|
else -> throw ConflictException("Client event ID was reused with another payload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
linkedAccounts.clear()
|
||||||
|
linkedAccounts.putAll(accountsCopy)
|
||||||
|
payloads.clear()
|
||||||
|
payloads.putAll(payloadsCopy)
|
||||||
|
lastBatch = batch
|
||||||
|
return AnalyticsIngestResult(accepted, replayed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.sha256Hex(): String =
|
||||||
|
MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(toByteArray(Charsets.UTF_8))
|
||||||
|
.joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) }
|
||||||
@@ -22,9 +22,14 @@ class InviteWebRoutesTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `valid referral renders a bilingual first-party page with hardened headers`() = testApplication {
|
fun `valid referral renders a bilingual first-party page with hardened headers`() = testApplication {
|
||||||
|
var recordedOpens = 0
|
||||||
application {
|
application {
|
||||||
routing {
|
routing {
|
||||||
configureInviteWebRoutes(ReferralLookupPort { true }, config)
|
configureInviteWebRoutes(
|
||||||
|
ReferralLookupPort { true },
|
||||||
|
config,
|
||||||
|
InviteOpenRecorder { recordedOpens += 1 },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +54,7 @@ class InviteWebRoutesTest {
|
|||||||
body shouldNotContain "branch.io"
|
body shouldNotContain "branch.io"
|
||||||
body shouldNotContain "appsflyer"
|
body shouldNotContain "appsflyer"
|
||||||
body shouldNotContain "adjust.com"
|
body shouldNotContain "adjust.com"
|
||||||
|
recordedOpens shouldBe 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -108,6 +114,21 @@ class InviteWebRoutesTest {
|
|||||||
body shouldContain "temporarily unavailable"
|
body shouldContain "temporarily unavailable"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `analytics counter failure never makes a valid invitation unavailable`() = testApplication {
|
||||||
|
application {
|
||||||
|
routing {
|
||||||
|
configureInviteWebRoutes(
|
||||||
|
ReferralLookupPort { true },
|
||||||
|
config,
|
||||||
|
InviteOpenRecorder { error("analytics unavailable") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client.get("/i/$VALID_CODE").status shouldBe HttpStatusCode.OK
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `lookup timeout fails closed with retry guidance`() = testApplication {
|
fun `lookup timeout fails closed with retry guidance`() = testApplication {
|
||||||
application {
|
application {
|
||||||
|
|||||||
Reference in New Issue
Block a user