diff --git a/admin-web/src/api/types.ts b/admin-web/src/api/types.ts index 2576bb5..2cd4c2d 100644 --- a/admin-web/src/api/types.ts +++ b/admin-web/src/api/types.ts @@ -187,6 +187,10 @@ export interface TrendPoint { } export interface Overview { + period: { + from: string; + until: string; + }; totalUsers: number; activeUsers: number; newUsers: number; @@ -210,6 +214,10 @@ export interface ReferralRankingItem { } export interface ReferralOverview { + period: { + from: string; + until: string; + }; pendingBindings: number; ineligibleBindings: number; funnel: FunnelStep[]; @@ -244,6 +252,12 @@ export interface AnalyticsFeatureUsage { successes: number; } +export interface AnalyticsLatencyBucket { + bucket: string; + successful: number; + failed: number; +} + export interface ProductAnalyticsOverview { period: { from: string; @@ -282,6 +296,8 @@ export interface ProductAnalyticsOverview { conversion7d: AnalyticsRate; conversion30d: AnalyticsRate; repeatPurchaseRate: AnalyticsRate; + purchaseFunnel: FunnelStep[]; + cancelledUsers: number; }; growthFunnel: FunnelStep[]; retention: AnalyticsCohort[]; @@ -305,11 +321,16 @@ export interface ProductAnalyticsOverview { mixedLanguageSessions: number; otherOnlySessions: number; }; + referralSignals: { + shared: number; + opened: number; + }; referralFunnel: FunnelStep[]; guardrails: { clientAiSuccessRate: AnalyticsRate; managedSuccessRate: AnalyticsRate; creditBlockedUsers: number; + latencyBuckets: AnalyticsLatencyBucket[]; }; } diff --git a/admin-web/src/components/charts/comparison-bar-chart.tsx b/admin-web/src/components/charts/comparison-bar-chart.tsx index 56b447c..e351fbd 100644 --- a/admin-web/src/components/charts/comparison-bar-chart.tsx +++ b/admin-web/src/components/charts/comparison-bar-chart.tsx @@ -6,8 +6,8 @@ type ChartTone = "primary" | "violet" | "success" | "warning"; export interface ComparisonBarItem { id?: string; label: string; - value: number; - secondaryValue?: number; + value: number | null; + secondaryValue?: number | null; hint?: string; } @@ -33,7 +33,7 @@ export function ComparisonBarChart({ } const maximum = Math.max( - ...items.flatMap((item) => [item.value, item.secondaryValue ?? 0]), + ...items.flatMap((item) => [item.value ?? 0, item.secondaryValue ?? 0]), 1, ); const legend = [ @@ -56,15 +56,17 @@ export function ComparisonBarChart({ maximum={maximum} tone={primaryTone} value={item.value} - valueLabel={valueFormatter(item.value)} + valueLabel={item.value == null ? "—" : valueFormatter(item.value)} /> - {secondaryLabel != null && item.secondaryValue != null ? ( + {secondaryLabel != null && item.secondaryValue !== undefined ? ( ) : null} @@ -84,7 +86,7 @@ function Bar({ label: string; maximum: number; tone: ChartTone; - value: number; + value: number | null; valueLabel: string; }) { return ( @@ -92,8 +94,8 @@ function Bar({ {valueLabel} diff --git a/admin-web/src/components/charts/funnel-chart.tsx b/admin-web/src/components/charts/funnel-chart.tsx index 147a694..2f86510 100644 --- a/admin-web/src/components/charts/funnel-chart.tsx +++ b/admin-web/src/components/charts/funnel-chart.tsx @@ -12,7 +12,7 @@ export function FunnelChart({ return

{emptyText}

; } - const maximum = Math.max(steps[0]?.count ?? 0, ...steps.map((step) => step.count), 1); + const maximum = Math.max(steps[0]?.count ?? 0, 1); return (
diff --git a/admin-web/src/components/charts/radial-metric.tsx b/admin-web/src/components/charts/radial-metric.tsx index 03352c4..094bc71 100644 --- a/admin-web/src/components/charts/radial-metric.tsx +++ b/admin-web/src/components/charts/radial-metric.tsx @@ -7,11 +7,11 @@ export function RadialMetric({ tone = "primary", }: { label: string; - percent: number; + percent: number | null; detail?: string; tone?: RadialTone; }) { - const value = Math.min(Math.max(percent, 0), 100); + const value = percent == null ? null : Math.min(Math.max(percent, 0), 100); return (
@@ -19,7 +19,7 @@ export function RadialMetric({ className="size-28 shrink-0" viewBox="0 0 120 120" role="img" - aria-label={`${label}:${value.toFixed(1)}%`} + aria-label={value == null ? `${label}:暂无数据` : `${label}:${value.toFixed(1)}%`} > - {Math.round(value)}% + {value == null ? "—" : `${Math.round(value)}%`}
diff --git a/admin-web/src/components/period-caption.tsx b/admin-web/src/components/period-caption.tsx new file mode 100644 index 0000000..a974cf7 --- /dev/null +++ b/admin-web/src/components/period-caption.tsx @@ -0,0 +1,20 @@ +export function PeriodCaption({ + from, + until, +}: { + from: string; + until: string; +}) { + return ( +

+ 统计周期:{utcLabel(from)} 至 {utcLabel(until)}(UTC,包含今日未完整数据) +

+ ); +} + +function utcLabel(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "—"; + return date.toISOString().replace("T", " ").slice(0, 16); +} + diff --git a/admin-web/src/features/analytics/analytics-page.tsx b/admin-web/src/features/analytics/analytics-page.tsx index 026fc6f..241ddc8 100644 --- a/admin-web/src/features/analytics/analytics-page.tsx +++ b/admin-web/src/features/analytics/analytics-page.tsx @@ -23,6 +23,7 @@ import { ComparisonBarChart } from "../../components/charts/comparison-bar-chart import { FunnelChart } from "../../components/charts/funnel-chart"; import { FilterControl, ToggleFilter } from "../../components/filter-control"; import { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives"; +import { PeriodCaption } from "../../components/period-caption"; import { RangeControl } from "../../components/range-control"; import { SortControl } from "../../components/sort-control"; import { formatNumber } from "../../lib/format"; @@ -110,7 +111,12 @@ export function AnalyticsPage() { eyebrow="CEO Dashboard" title="产品增长与留存" description="围绕成功使用 AI 的核心价值事件,观察增长质量、留存、消耗与付费。" - actions={} + actions={ +
+ + +
+ } />
@@ -151,7 +157,7 @@ export function AnalyticsPage() { @@ -241,7 +247,7 @@ export function AnalyticsPage() { - + + @@ -379,11 +395,11 @@ export function AnalyticsPage() { items={[ { label: "客户端 AI", - value: data.guardrails.clientAiSuccessRate.percent ?? 0, + value: data.guardrails.clientAiSuccessRate.percent ?? null, }, { label: "托管请求", - value: data.guardrails.managedSuccessRate.percent ?? 0, + value: data.guardrails.managedSuccessRate.percent ?? null, }, ]} primaryLabel="成功率" @@ -399,6 +415,44 @@ export function AnalyticsPage() {
+
+ + + + + + + + + ({ + id: item.bucket, + label: latencyBucketLabel(item.bucket), + value: item.successful, + secondaryValue: item.failed, + }))} + primaryLabel="成功" + secondaryLabel="失败" + primaryTone="success" + secondaryTone="warning" + emptyText="客户端 AI 终态事件暂无样本" + /> + +
+ @@ -406,7 +460,7 @@ export function AnalyticsPage() { value={channelSort} order={channelOrder} options={[ - { value: "installs", label: "新增安装" }, + { value: "installs", label: "已完成观察安装" }, { value: "activated", label: "激活人数" }, { value: "rate", label: "激活率" }, ]} @@ -425,7 +479,7 @@ export function AnalyticsPage() { secondaryValue: channel.activated, hint: `激活率 ${rateLabel(channel.activationRate)}`, }))} - primaryLabel="新增安装" + primaryLabel="已完成 24h 观察安装" secondaryLabel="24 小时激活" emptyText="当前筛选条件下暂无渠道归因数据" /> @@ -535,3 +589,13 @@ function executionModeLabel(mode: string): string { BYOK: "BYOK", }[mode] ?? mode; } + +function latencyBucketLabel(bucket: string): string { + return { + LT_1S: "< 1 秒", + S1_TO_3: "1–3 秒", + S3_TO_10: "3–10 秒", + S10_TO_30: "10–30 秒", + GTE_30S: "≥ 30 秒", + }[bucket] ?? bucket; +} diff --git a/admin-web/src/features/overview/overview-page.tsx b/admin-web/src/features/overview/overview-page.tsx index 7981a4c..4c1c4b8 100644 --- a/admin-web/src/features/overview/overview-page.tsx +++ b/admin-web/src/features/overview/overview-page.tsx @@ -14,6 +14,7 @@ import { RadialMetric } from "../../components/charts/radial-metric"; import { TrendChart } from "../../components/charts/trend-chart"; import { ToggleFilter } from "../../components/filter-control"; import { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives"; +import { PeriodCaption } from "../../components/period-caption"; import { RangeControl } from "../../components/range-control"; import { SortControl } from "../../components/sort-control"; import { formatNumber, usageTypeLabel } from "../../lib/format"; @@ -58,7 +59,7 @@ export function OverviewPage() { if (!data) return ; const activeRate = - data.totalUsers > 0 ? Math.round((data.activeUsers / data.totalUsers) * 100) : 0; + data.totalUsers > 0 ? Math.round((data.activeUsers / data.totalUsers) * 100) : null; const usageRequests = data.usage.reduce((sum, item) => sum + item.requests, 0); return ( @@ -66,8 +67,13 @@ export function OverviewPage() { } + description="活跃用户指周期内成功使用 AI 或产生手动键盘输入的注册用户。" + actions={ +
+ + +
+ } />
@@ -80,7 +86,7 @@ export function OverviewPage() { @@ -105,7 +111,9 @@ export function OverviewPage() {

增长与消耗趋势

-

按 UTC 日期统计,双指标独立缩放

+

+ 按 UTC 日期统计,恰好覆盖所选日期数;今日数据尚未完整 +

0 ? Math.round((last / first) * 100) : 0; + const conversion = first > 0 ? Math.round((last / first) * 100) : null; return (
@@ -123,7 +124,12 @@ export function ReferralsPage() { eyebrow="增长分析" title="裂变与排行" description="奖励以有效使用为前提,关注真实转化而不是单纯注册量。" - actions={} + actions={ +
+ + +
+ } />
@@ -143,7 +149,7 @@ export function ReferralsPage() { />

裂变漏斗

-

从分享触达到有效使用,逐层观察流失

+

同一批绑定用户从首次 AI 成功到完成奖励

@@ -194,7 +200,7 @@ export function ReferralsPage() {
@@ -205,7 +211,9 @@ export function ReferralsPage() {

头部邀请贡献

-

比较邀请总数与达到奖励条件的有效邀请

+

+ 当前请求结果 Top 8;完整 {limit} 名见下方明细 +

diff --git a/admin-web/src/test/pages.test.tsx b/admin-web/src/test/pages.test.tsx index 4f30502..4c0645f 100644 --- a/admin-web/src/test/pages.test.tsx +++ b/admin-web/src/test/pages.test.tsx @@ -233,6 +233,10 @@ describe("React 管理页面", () => { expect(screen.getByText("中文活跃用户")).toBeTruthy(); expect(screen.getByText("中英混合")).toBeTruthy(); expect(screen.getByText("7 天免费转付费")).toBeTruthy(); + expect(screen.getByText("购买意向漏斗")).toBeTruthy(); + expect(screen.getByText("AI 终态延迟分布")).toBeTruthy(); + expect(screen.getByText("客户端分享信号")).toBeTruthy(); + expect(screen.getByLabelText("实际统计周期").textContent).toContain("包含今日未完整数据"); }); it("产品图表可按执行模式筛选并按用户数稳定排序", async () => { @@ -318,6 +322,12 @@ function analyticsOverview(): ProductAnalyticsOverview { conversion7d: rate(8, 70, 11.4), conversion30d: rate(10, 50, 20), repeatPurchaseRate: rate(2, 10, 20), + purchaseFunnel: [ + { label: "浏览购买页", count: 30 }, + { label: "发起购买", count: 15 }, + { label: "StoreKit 验证完成", count: 10 }, + ], + cancelledUsers: 4, }, growthFunnel: [ { label: "首次启动", count: 100 }, @@ -353,14 +363,17 @@ function analyticsOverview(): ProductAnalyticsOverview { mixedLanguageSessions: 30, otherOnlySessions: 10, }, + referralSignals: { shared: 20, opened: 15 }, referralFunnel: [ - { label: "发起分享", count: 20 }, + { label: "完成绑定", count: 10 }, + { label: "绑定后首次 AI 成功", count: 8 }, { label: "完成奖励", count: 5 }, ], guardrails: { clientAiSuccessRate: rate(90, 100, 90), managedSuccessRate: rate(95, 100, 95), creditBlockedUsers: 3, + latencyBuckets: [{ bucket: "S1_TO_3", successful: 80, failed: 5 }], }, }; } diff --git a/admin-web/src/test/risk-flows.test.tsx b/admin-web/src/test/risk-flows.test.tsx index 39d362a..4512a67 100644 --- a/admin-web/src/test/risk-flows.test.tsx +++ b/admin-web/src/test/risk-flows.test.tsx @@ -36,7 +36,10 @@ describe("高风险交互与 CSP", () => { const { container } = render( <> @@ -51,6 +54,7 @@ describe("高风险交互与 CSP", () => { ]} /> + , ); @@ -58,6 +62,8 @@ describe("高风险交互与 CSP", () => { expect(screen.getByRole("progressbar", { name: /首次启动:100/ })).toBeTruthy(); expect(screen.getByText("D1、D7、D30 价值留存 cohort 热力图")).toBeTruthy(); expect(screen.getByRole("img", { name: "用户活跃率:50.0%" })).toBeTruthy(); + expect(screen.getByRole("progressbar", { name: "样本不足 新增安装:暂无数据" })).toBeTruthy(); + expect(screen.getByRole("img", { name: "无样本活跃率:暂无数据" })).toBeTruthy(); expect(container.querySelector("[style]")).toBeNull(); }); diff --git a/docs/ANALYTICS_METRICS_DICTIONARY.md b/docs/ANALYTICS_METRICS_DICTIONARY.md index d221262..603a598 100644 --- a/docs/ANALYTICS_METRICS_DICTIONARY.md +++ b/docs/ANALYTICS_METRICS_DICTIONARY.md @@ -4,6 +4,10 @@ This document is the canonical definition of product metrics. All dates and cohorts use UTC calendar boundaries. Counts are based on distinct accounts when an installation is linked, otherwise on the pseudonymous installation. +Admin presets cover exactly 7, 30, or 90 UTC calendar dates, starting at 00:00 +on the first date and ending at the current instant. The current UTC date is +therefore explicitly partial. + ## North-star metric ### Weekly AI active users (WAIU) @@ -39,9 +43,10 @@ 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. +The percentage of new installations that have completed their full 24-hour +observation window and successfully complete any AI feature within 24 hours of +their first open. Unmatured installations are excluded from both numerator and +denominator. Managed usage before that installation's first open is ignored. ### Time to first value @@ -49,6 +54,14 @@ Elapsed time from `FIRST_OPEN` to the first successful AI feature. The dashboard reports the median in minutes. Users without a successful AI feature are not included in the median and remain visible in the activation denominator. +### 24-hour growth funnel + +A strict cohort of installations with a completed 24-hour observation window: +first open, account registration after first open, first AI value event after +registration, and first server-verified purchase after that value event. Every +downstream step must occur within 24 hours of first open. D7 belongs only to the +retention report and is not mixed into this funnel. + ## Activity ### AI DAU, WAU and MAU @@ -69,6 +82,13 @@ Managed client success events are excluded from this total. `successful AI requests / distinct value-active users` for the selected period. +### Registered product-active users + +The operations overview counts distinct registered accounts with either a +successful AI value event (managed, local, or BYOK) or a finalized manual +keyboard-input summary in the selected period. The displayed rate divides this +population by all registered accounts. + ## Keyboard input usage Keyboard input metrics use finalized UTC-day summaries produced on-device. @@ -175,8 +195,11 @@ server credits and are excluded. ### 7-day and 30-day free-to-paid conversion 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. +purchase no later than 7 or 30 days after registration. The selected report +period filters when each observation window matures: a 7-day report cohort uses +registrations shifted exactly 7 days earlier, and the 30-day cohort is shifted +30 days earlier. This keeps every denominator fully observed and makes the rate +available even when the selected preset is no longer than the conversion window. ### Paying users @@ -187,21 +210,29 @@ Distinct accounts with at least one credited StoreKit purchase in the period. The percentage of paying accounts with at least two credited StoreKit purchases across their lifetime. +### Purchase intent funnel + +A strict installation cohort: `PURCHASE_VIEWED`, followed by +`PURCHASE_STARTED`, followed by a StoreKit purchase verified by the server for +the linked account. Each event must occur after the previous step and before the +report's `until`. `PURCHASE_CANCELLED` is a separate signal, not a funnel step. + StoreKit transaction count and granted credits are operational proxies. Net 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: +The ordered cohort contains bindings created in the selected period: -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. +1. Referral binding created. +2. The same invitee reaches an AI value event after binding. +3. The same binding is rewarded before the report's `until`. + +`REFERRAL_SHARED` distinct installations and invitation opens are independent +directional signals. Invitation opens combine accepted `INVITE_OPENED` events +with anonymous first-party page-view counters, so they are not people and must +never be placed in the ordered conversion funnel. Pending and ineligible bindings are parallel status counts, not sequential funnel steps. @@ -212,8 +243,8 @@ funnel steps. 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. +- Client latency: successful and failed terminal events grouped by declared + duration bucket. Exact P50/P95 values are not inferred from buckets. - Credit-blocked users: distinct installations reporting `INSUFFICIENT_CREDITS` during the period. diff --git a/docs/PRODUCT_ANALYTICS.md b/docs/PRODUCT_ANALYTICS.md index 9e65a58..cab1c21 100644 --- a/docs/PRODUCT_ANALYTICS.md +++ b/docs/PRODUCT_ANALYTICS.md @@ -28,7 +28,10 @@ regardless of account linkage. - 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. + and shared with the keyboard extension through the App Group. New clients send + it once at the batch root. During the migration window, the server also accepts + released clients that repeat one identical `installationId` on every event; + missing, incomplete, or conflicting identities reject the entire batch. - When a valid account session is present, the installation is linked to that account. An installation cannot later be linked to a different account. - Every `clientEventId` is a client-generated UUID. The pair diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 167f2ba..d516903 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -85,6 +85,9 @@ paths: 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. + New clients must send installationId once at the batch level. During + migration, legacy clients that send the same installationId on every + event remain accepted; conflicting or incomplete identities are rejected. requestBody: required: true content: @@ -1314,6 +1317,7 @@ components: AdminRange: name: range in: query + description: Covers exactly 7, 30, or 90 UTC calendar dates, from 00:00 on the first date through the current instant. The current UTC date is partial. schema: { type: string, enum: [7d, 30d, 90d], default: 30d } AdminFrom: name: from @@ -1438,12 +1442,20 @@ components: minLength: 1 maxLength: 32 pattern: "^[A-Za-z0-9._+-]+$" + installationId: + type: string + format: uuid + deprecated: true + description: Transitional legacy field; new clients must use the batch-level installationId. ProductAnalyticsBatchRequest: type: object additionalProperties: false - required: [installationId, events] + required: [events] properties: - installationId: { type: string, format: uuid } + installationId: + type: string + format: uuid + description: Required for new clients; legacy batches may instead repeat one identical ID on every event. events: type: array minItems: 1 @@ -1793,10 +1805,18 @@ components: date: { type: string, format: date } registrations: { type: integer, format: int64, minimum: 0 } creditsUsed: { type: integer, format: int64, minimum: 0 } + AdminStatsPeriod: + type: object + additionalProperties: false + required: [from, until] + properties: + from: { type: string, format: date-time, description: Inclusive UTC lower bound. } + until: { type: string, format: date-time, description: Exclusive current-instant upper bound. } AdminOverview: type: object additionalProperties: false required: + - period - totalUsers - activeUsers - newUsers @@ -1806,8 +1826,13 @@ components: - trend - usage properties: + period: { $ref: "#/components/schemas/AdminStatsPeriod" } totalUsers: { type: integer, format: int64, minimum: 0 } - activeUsers: { type: integer, format: int64, minimum: 0 } + activeUsers: + type: integer + format: int64 + minimum: 0 + description: Registered accounts with successful AI use or manually committed keyboard input in the period. newUsers: { type: integer, format: int64, minimum: 0 } totalCreditBalance: { type: integer, format: int64, minimum: 0 } creditsGranted: { type: integer, format: int64, minimum: 0 } @@ -1825,7 +1850,7 @@ components: properties: label: type: string - enum: [邀请码创建, 成功绑定, 有效使用并奖励] + enum: [成功绑定, 绑定后首次 AI 成功, 完成奖励] count: { type: integer, format: int64, minimum: 0 } AdminReferralRank: type: object @@ -1839,8 +1864,9 @@ components: AdminReferralOverview: type: object additionalProperties: false - required: [pendingBindings, ineligibleBindings, funnel, ranking] + required: [period, pendingBindings, ineligibleBindings, funnel, ranking] properties: + period: { $ref: "#/components/schemas/AdminStatsPeriod" } pendingBindings: { type: integer, format: int64, minimum: 0 } ineligibleBindings: { type: integer, format: int64, minimum: 0 } funnel: @@ -1856,7 +1882,11 @@ components: properties: numerator: { type: integer, format: int64, minimum: 0 } denominator: { type: integer, format: int64, minimum: 0 } - percent: { type: ["number", "null"], minimum: 0, maximum: 100 } + percent: + type: ["number", "null"] + minimum: 0 + maximum: 100 + description: Null means unavailable, usually because the denominator is zero; clients must not render it as 0%. AdminAnalyticsFunnelStep: type: object additionalProperties: false @@ -1872,7 +1902,11 @@ components: channel: type: string enum: [APP_STORE_ORGANIC, REFERRAL, SOCIAL_CONTENT, UNKNOWN] - installations: { type: integer, format: int64, minimum: 0 } + installations: + type: integer + format: int64 + minimum: 0 + description: Installations in this channel with a completed 24-hour observation window. activated: { type: integer, format: int64, minimum: 0 } activationRate: { $ref: "#/components/schemas/AdminAnalyticsRate" } AdminAnalyticsCohort: @@ -1905,6 +1939,16 @@ components: executionMode: { type: string, enum: [MANAGED, LOCAL, BYOK] } users: { type: integer, format: int64, minimum: 0 } successes: { type: integer, format: int64, minimum: 0 } + AdminAnalyticsLatencyBucket: + type: object + additionalProperties: false + required: [bucket, successful, failed] + properties: + bucket: + type: string + enum: [LT_1S, S1_TO_3, S3_TO_10, S10_TO_30, GTE_30S] + successful: { type: integer, format: int64, minimum: 0 } + failed: { type: integer, format: int64, minimum: 0 } AdminAnalyticsKeyboardUsage: type: object additionalProperties: false @@ -1955,16 +1999,11 @@ components: - retention - aiFeatures - keyboardUsage + - referralSignals - referralFunnel - guardrails properties: - period: - type: object - additionalProperties: false - required: [from, until] - properties: - from: { type: string, format: date-time } - until: { type: string, format: date-time } + period: { $ref: "#/components/schemas/AdminStatsPeriod" } northStar: type: object additionalProperties: false @@ -1990,9 +2029,21 @@ components: 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 } + dau: + type: integer + format: int64 + minimum: 0 + description: Distinct AI value-active identities in the rolling 1-day window ending at period.until. + wau: + type: integer + format: int64 + minimum: 0 + description: Distinct AI value-active identities in the rolling 7-day window ending at period.until. + mau: + type: integer + format: int64 + minimum: 0 + description: Distinct AI value-active identities in the rolling 30-day window ending at period.until. stickinessPercent: { type: ["number", "null"], minimum: 0, maximum: 100 } successfulAiRequests: { type: integer, format: int64, minimum: 0 } successfulRequestsPerActiveUser: { type: ["number", "null"], minimum: 0 } @@ -2009,14 +2060,23 @@ components: type: object additionalProperties: false required: - [payingUsers, purchases, creditsPurchased, conversion7d, conversion30d, repeatPurchaseRate] + [payingUsers, purchases, creditsPurchased, conversion7d, conversion30d, repeatPurchaseRate, purchaseFunnel, cancelledUsers] 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" } + conversion7d: + $ref: "#/components/schemas/AdminAnalyticsRate" + description: Conversion for account cohorts whose full 7-day observation window matures inside the selected report period. + conversion30d: + $ref: "#/components/schemas/AdminAnalyticsRate" + description: Conversion for account cohorts whose full 30-day observation window matures inside the selected report period. repeatPurchaseRate: { $ref: "#/components/schemas/AdminAnalyticsRate" } + purchaseFunnel: + type: array + description: Strict installation cohort from purchase view through server-verified StoreKit purchase. + items: { $ref: "#/components/schemas/AdminAnalyticsFunnelStep" } + cancelledUsers: { type: integer, format: int64, minimum: 0 } growthFunnel: type: array items: { $ref: "#/components/schemas/AdminAnalyticsFunnelStep" } @@ -2028,17 +2088,29 @@ components: items: { $ref: "#/components/schemas/AdminAnalyticsFeatureUsage" } keyboardUsage: $ref: "#/components/schemas/AdminAnalyticsKeyboardUsage" + referralSignals: + type: object + additionalProperties: false + required: [shared, opened] + description: Directional signals only; these counts are not funnel stages. + properties: + shared: { type: integer, format: int64, minimum: 0 } + opened: { type: integer, format: int64, minimum: 0 } referralFunnel: type: array items: { $ref: "#/components/schemas/AdminAnalyticsFunnelStep" } guardrails: type: object additionalProperties: false - required: [clientAiSuccessRate, managedSuccessRate, creditBlockedUsers] + required: [clientAiSuccessRate, managedSuccessRate, creditBlockedUsers, latencyBuckets] properties: clientAiSuccessRate: { $ref: "#/components/schemas/AdminAnalyticsRate" } managedSuccessRate: { $ref: "#/components/schemas/AdminAnalyticsRate" } creditBlockedUsers: { type: integer, format: int64, minimum: 0 } + latencyBuckets: + type: array + description: Client AI terminal events grouped into declared duration buckets. + items: { $ref: "#/components/schemas/AdminAnalyticsLatencyBucket" } AdminUserSummary: type: object additionalProperties: false diff --git a/src/main/kotlin/com/osglab/account/features/admin/routes/AdminRoutes.kt b/src/main/kotlin/com/osglab/account/features/admin/routes/AdminRoutes.kt index b07b419..a88c5a9 100644 --- a/src/main/kotlin/com/osglab/account/features/admin/routes/AdminRoutes.kt +++ b/src/main/kotlin/com/osglab/account/features/admin/routes/AdminRoutes.kt @@ -604,7 +604,7 @@ private suspend fun AdminStatsService.getRange( ) } -private fun parseAdminStatsRange(range: String?, clock: Clock): Pair? { +internal fun parseAdminStatsRange(range: String?, clock: Clock): Pair? { val days = when (range) { null, "30d" -> 30L "7d" -> 7L @@ -612,7 +612,9 @@ private fun parseAdminStatsRange(range: String?, clock: Clock): Pair return null } val until = clock.instant() - return until.minus(Duration.ofDays(days)) to until + val firstIncludedDate = until.atZone(ZoneOffset.UTC).toLocalDate().minusDays(days - 1) + val from = firstIncludedDate.atStartOfDay(ZoneOffset.UTC).toInstant() + return from to until } private data class AdminReferralQueryOptions( @@ -953,6 +955,7 @@ private fun adminCookie( private fun AdminStatsDto.toOverviewResponse(): AdminOverviewResponse { val consumedByDate = creditFlow.associateBy { it.date } return AdminOverviewResponse( + period = period, totalUsers = overview.totalUsers, activeUsers = overview.activeUsers, newUsers = overview.registrations, @@ -972,12 +975,13 @@ private fun AdminStatsDto.toOverviewResponse(): AdminOverviewResponse { private fun AdminStatsDto.toReferralResponse(): AdminReferralResponse = AdminReferralResponse( + period = period, pendingBindings = referralFunnel.pendingBindings, ineligibleBindings = referralFunnel.ineligibleBindings, funnel = listOf( - AdminFunnelResponse("邀请码创建", referralFunnel.codesCreated), AdminFunnelResponse("成功绑定", referralFunnel.bindings), - AdminFunnelResponse("有效使用并奖励", referralFunnel.rewardedBindings), + AdminFunnelResponse("绑定后首次 AI 成功", referralFunnel.activatedBindings), + AdminFunnelResponse("完成奖励", referralFunnel.rewardedBindings), ), ranking = referralRanking.map { AdminReferralRankResponse( @@ -1137,6 +1141,7 @@ private data class PageResponse(val items: List, val nextCursor: String? = @Serializable private data class AdminOverviewResponse( + val period: com.osglab.account.features.admin.stats.models.AdminStatsPeriodDto, val totalUsers: Long, val activeUsers: Long, val newUsers: Long, @@ -1156,6 +1161,7 @@ private data class AdminTrendResponse( @Serializable private data class AdminReferralResponse( + val period: com.osglab.account.features.admin.stats.models.AdminStatsPeriodDto, val pendingBindings: Long, val ineligibleBindings: Long, val funnel: List, diff --git a/src/main/kotlin/com/osglab/account/features/admin/stats/models/AdminProductAnalyticsDtos.kt b/src/main/kotlin/com/osglab/account/features/admin/stats/models/AdminProductAnalyticsDtos.kt index 110fa30..8d9f2ab 100644 --- a/src/main/kotlin/com/osglab/account/features/admin/stats/models/AdminProductAnalyticsDtos.kt +++ b/src/main/kotlin/com/osglab/account/features/admin/stats/models/AdminProductAnalyticsDtos.kt @@ -34,6 +34,19 @@ data class AdminAnalyticsFeatureUsageDto( val successes: Long, ) +@Serializable +data class AdminAnalyticsReferralSignalsDto( + val shared: Long, + val opened: Long, +) + +@Serializable +data class AdminAnalyticsLatencyBucketDto( + val bucket: String, + val successful: Long, + val failed: Long, +) + @Serializable data class AdminAnalyticsFunnelStepDto( val label: String, @@ -88,6 +101,8 @@ data class AdminAnalyticsMonetizationDto( val conversion7d: AdminAnalyticsRateDto, val conversion30d: AdminAnalyticsRateDto, val repeatPurchaseRate: AdminAnalyticsRateDto, + val purchaseFunnel: List, + val cancelledUsers: Long, ) @Serializable @@ -95,6 +110,7 @@ data class AdminAnalyticsGuardrailsDto( val clientAiSuccessRate: AdminAnalyticsRateDto, val managedSuccessRate: AdminAnalyticsRateDto, val creditBlockedUsers: Long, + val latencyBuckets: List, ) @Serializable @@ -130,6 +146,7 @@ data class AdminProductAnalyticsDto( val retention: List, val aiFeatures: List, val keyboardUsage: AdminAnalyticsKeyboardUsageDto, + val referralSignals: AdminAnalyticsReferralSignalsDto, val referralFunnel: List, val guardrails: AdminAnalyticsGuardrailsDto, ) diff --git a/src/main/kotlin/com/osglab/account/features/admin/stats/models/AdminStatsDtos.kt b/src/main/kotlin/com/osglab/account/features/admin/stats/models/AdminStatsDtos.kt index 149333b..c3217f4 100644 --- a/src/main/kotlin/com/osglab/account/features/admin/stats/models/AdminStatsDtos.kt +++ b/src/main/kotlin/com/osglab/account/features/admin/stats/models/AdminStatsDtos.kt @@ -35,6 +35,7 @@ data class AdminCreditFlowPointDto( data class AdminReferralFunnelDto( val codesCreated: Long, val bindings: Long, + val activatedBindings: Long, val rewardedBindings: Long, val pendingBindings: Long, val ineligibleBindings: Long, diff --git a/src/main/kotlin/com/osglab/account/features/admin/stats/repositories/AdminAnalyticsSql.kt b/src/main/kotlin/com/osglab/account/features/admin/stats/repositories/AdminAnalyticsSql.kt new file mode 100644 index 0000000..c66d71f --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/admin/stats/repositories/AdminAnalyticsSql.kt @@ -0,0 +1,29 @@ +package com.osglab.account.features.admin.stats.repositories + +/** + * Canonical AI value events used by product analytics. Managed usage is sourced + * from immutable billing records; LOCAL and BYOK usage comes from terminal + * client events. No user content is selected. + */ +internal fun identityValueEventsCte(): String = + """ + WITH value_events AS ( + SELECT + CONCAT('a:', user_id) AS identity_key, + created_at AS occurred_at + FROM credit_usage_records + UNION ALL + SELECT + COALESCE( + CONCAT('a:', i.account_id), + CONCAT('i:', e.installation_hash) + ) AS identity_key, + e.occurred_at + FROM product_analytics_events e + JOIN product_analytics_installations i + ON i.installation_hash = e.installation_hash + WHERE e.event_name = 'AI_FEATURE_SUCCEEDED' + AND e.execution_mode IN ('LOCAL', 'BYOK') + ) + """.trimIndent() + diff --git a/src/main/kotlin/com/osglab/account/features/admin/stats/repositories/AdminProductAnalyticsRepository.kt b/src/main/kotlin/com/osglab/account/features/admin/stats/repositories/AdminProductAnalyticsRepository.kt index 949ccf3..5cc4b35 100644 --- a/src/main/kotlin/com/osglab/account/features/admin/stats/repositories/AdminProductAnalyticsRepository.kt +++ b/src/main/kotlin/com/osglab/account/features/admin/stats/repositories/AdminProductAnalyticsRepository.kt @@ -74,6 +74,19 @@ data class AdminAnalyticsGuardrailRow( val creditBlockedUsers: Long, ) +data class AdminAnalyticsLatencyRow( + val bucket: String, + val successful: Long, + val failed: Long, +) + +data class AdminAnalyticsPurchaseFunnelRow( + val viewed: Long, + val started: Long, + val verified: Long, + val cancelled: Long, +) + data class AdminAnalyticsKeyboardUsageRow( val activeUsers: Long, val keyboardUsers: Long, @@ -95,7 +108,6 @@ data class AdminAnalyticsGrowthFunnelRow( val opened: Long, val registered: Long, val activated: Long, - val retainedD7: Long, val purchased: Long, ) @@ -120,6 +132,8 @@ data class AdminProductAnalyticsSnapshot( val keyboardUsage: AdminAnalyticsKeyboardUsageRow, val referrals: AdminAnalyticsReferralRow, val guardrails: AdminAnalyticsGuardrailRow, + val latencyDistribution: List, + val purchaseFunnel: AdminAnalyticsPurchaseFunnelRow, ) interface AdminProductAnalyticsRepository { @@ -142,7 +156,7 @@ class ExposedAdminProductAnalyticsRepository( AdminProductAnalyticsSnapshot( currentWeeklyUsers = loadValueActiveUsers(currentWeek), previousWeeklyUsers = loadValueActiveUsers(previousWeek), - newInstallations = activation.denominator, + newInstallations = loadNewInstallations(range), newAccounts = loadNewAccounts(range), activation24h = AdminAnalyticsCountRow(activation.activated, activation.denominator), medianTimeToValueMinutes = activation.medianMinutes, @@ -166,12 +180,14 @@ class ExposedAdminProductAnalyticsRepository( keyboardUsage = loadKeyboardUsage(range), referrals = loadReferrals(range), guardrails = loadGuardrails(range), + latencyDistribution = loadLatencyDistribution(range), + purchaseFunnel = loadPurchaseFunnel(range), ) } private fun loadValueActiveUsers(window: AdminAnalyticsWindow): Long = querySingle( - valueEventsCte() + + identityValueEventsCte() + """ SELECT COUNT(DISTINCT identity_key) AS aggregate_value FROM value_events @@ -190,6 +206,21 @@ class ExposedAdminProductAnalyticsRepository( range.arguments(), ) { it.exactLong("aggregate_value") } + private fun loadNewInstallations(range: AdminAnalyticsWindow): Long = + querySingle( + """ + SELECT COUNT(*) AS aggregate_value + FROM ( + SELECT installation_hash, MIN(occurred_at) AS opened_at + FROM product_analytics_events + WHERE event_name = 'FIRST_OPEN' + GROUP BY installation_hash + ) first_open + WHERE opened_at >= ? AND opened_at < ? + """, + range.arguments(), + ) { it.exactLong("aggregate_value") } + private fun loadActivation(range: AdminAnalyticsWindow): ActivationRow = querySingle( """ @@ -197,21 +228,30 @@ class ExposedAdminProductAnalyticsRepository( 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 + HAVING opened_at >= ? + AND opened_at <= DATE_SUB(?, INTERVAL 24 HOUR) ), 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 + SELECT e.installation_hash, MIN(e.occurred_at) AS value_at + FROM first_open o + JOIN product_analytics_events e + ON e.installation_hash = o.installation_hash + WHERE e.event_name = 'AI_FEATURE_SUCCEEDED' + AND e.execution_mode IN ('LOCAL', 'BYOK') + AND e.occurred_at >= o.opened_at + AND e.occurred_at <= DATE_ADD(o.opened_at, INTERVAL 24 HOUR) + GROUP BY e.installation_hash ), managed_value AS ( - SELECT i.installation_hash, MIN(u.created_at) AS value_at - FROM product_analytics_installations i + SELECT o.installation_hash, MIN(u.created_at) AS value_at + FROM first_open o + JOIN product_analytics_installations i + ON i.installation_hash = o.installation_hash JOIN credit_usage_records u ON u.user_id = i.account_id - GROUP BY i.installation_hash + WHERE u.created_at >= o.opened_at + AND u.created_at <= DATE_ADD(o.opened_at, INTERVAL 24 HOUR) + GROUP BY o.installation_hash ), first_value_by_install AS ( SELECT installation_hash, MIN(value_at) AS value_at @@ -228,8 +268,6 @@ class ExposedAdminProductAnalyticsRepository( TIMESTAMPDIFF(SECOND, o.opened_at, v.value_at) AS seconds_to_value 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 @@ -277,21 +315,30 @@ class ExposedAdminProductAnalyticsRepository( ) 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 + HAVING opened_at >= ? + AND opened_at <= DATE_SUB(?, INTERVAL 24 HOUR) ), 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 + SELECT e.installation_hash, MIN(e.occurred_at) AS value_at + FROM first_open o + JOIN product_analytics_events e + ON e.installation_hash = o.installation_hash + WHERE e.event_name = 'AI_FEATURE_SUCCEEDED' + AND e.execution_mode IN ('LOCAL', 'BYOK') + AND e.occurred_at >= o.opened_at + AND e.occurred_at <= DATE_ADD(o.opened_at, INTERVAL 24 HOUR) + GROUP BY e.installation_hash ), managed_value AS ( - SELECT i.installation_hash, MIN(u.created_at) AS value_at - FROM product_analytics_installations i + SELECT o.installation_hash, MIN(u.created_at) AS value_at + FROM first_open o + JOIN product_analytics_installations i + ON i.installation_hash = o.installation_hash JOIN credit_usage_records u ON u.user_id = i.account_id - GROUP BY i.installation_hash + WHERE u.created_at >= o.opened_at + AND u.created_at <= DATE_ADD(o.opened_at, INTERVAL 24 HOUR) + GROUP BY o.installation_hash ), first_value_by_install AS ( SELECT installation_hash, MIN(value_at) AS value_at @@ -307,9 +354,7 @@ class ExposedAdminProductAnalyticsRepository( 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 + WHEN v.value_at IS NOT NULL THEN 1 ELSE 0 END ) AS activated FROM first_open o @@ -397,10 +442,8 @@ class ExposedAdminProductAnalyticsRepository( ) } - private fun loadMonetization(range: AdminAnalyticsWindow): AdminAnalyticsMonetizationRow { - val sevenDayMaturity = range.until.minusSeconds(7 * DAY_SECONDS) - val thirtyDayMaturity = range.until.minusSeconds(30 * DAY_SECONDS) - return querySingle( + private fun loadMonetization(range: AdminAnalyticsWindow): AdminAnalyticsMonetizationRow = + querySingle( """ WITH first_purchase AS ( SELECT user_id, MIN(purchased_at) AS first_purchased_at, COUNT(*) AS lifetime_purchases @@ -459,10 +502,10 @@ class ExposedAdminProductAnalyticsRepository( """, 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)) + addAll(maturedCohortArguments(range, 7)) + addAll(maturedCohortArguments(range, 7)) + addAll(maturedCohortArguments(range, 30)) + addAll(maturedCohortArguments(range, 30)) }, ) { AdminAnalyticsMonetizationRow( @@ -483,7 +526,6 @@ class ExposedAdminProductAnalyticsRepository( ), ) } - } private fun loadGrowthFunnel(range: AdminAnalyticsWindow): AdminAnalyticsGrowthFunnelRow = querySingle( @@ -492,8 +534,22 @@ class ExposedAdminProductAnalyticsRepository( 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 + HAVING opened_at >= ? + AND opened_at <= DATE_SUB(?, INTERVAL 24 HOUR) + ), + registered AS ( + SELECT + o.installation_hash, + o.opened_at, + i.account_id, + a.created_at AS registered_at + FROM first_open o + JOIN product_analytics_installations i + ON i.installation_hash = o.installation_hash + JOIN accounts a ON a.id = i.account_id + WHERE a.created_at >= o.opened_at + AND a.created_at <= DATE_ADD(o.opened_at, INTERVAL 24 HOUR) ), client_values AS ( SELECT installation_hash, occurred_at @@ -513,63 +569,42 @@ class ExposedAdminProductAnalyticsRepository( ), activated AS ( SELECT - o.installation_hash, - o.opened_at, + r.installation_hash, + r.opened_at, + r.account_id, 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 + FROM registered r + JOIN values_by_install v ON v.installation_hash = r.installation_hash + WHERE v.occurred_at >= r.registered_at + AND v.occurred_at <= DATE_ADD(r.opened_at, INTERVAL 24 HOUR) + GROUP BY r.installation_hash, r.opened_at, r.account_id + ), + purchased AS ( + SELECT DISTINCT a.installation_hash + FROM activated a + JOIN storekit_credit_purchases p ON p.user_id = a.account_id + WHERE p.purchased_at >= a.first_value_at + AND p.purchased_at <= DATE_ADD(a.opened_at, INTERVAL 24 HOUR) ) SELECT (SELECT 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 registered) 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 + (SELECT COUNT(*) FROM purchased) AS purchased """, - range.arguments() + listOf(INSTANT_COLUMN_TYPE to range.until), + range.arguments(), ) { 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 = queryRows( - valueEventsCte() + + identityValueEventsCte() + """ , first_value_by_identity AS ( SELECT identity_key, MIN(occurred_at) AS first_value_at @@ -737,7 +772,7 @@ class ExposedAdminProductAnalyticsRepository( private fun loadReferrals(range: AdminAnalyticsWindow): AdminAnalyticsReferralRow = querySingle( - valueEventsCte() + + identityValueEventsCte() + """ SELECT ( @@ -755,7 +790,7 @@ class ExposedAdminProductAnalyticsRepository( 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(?) + AND counter_date >= DATE(?) AND counter_date <= DATE(?) ) AS opened, ( SELECT COUNT(*) @@ -779,12 +814,14 @@ class ExposedAdminProductAnalyticsRepository( FROM referral_bindings WHERE bound_at >= ? AND bound_at < ? AND reward_status = 'REWARDED' + AND rewarded_at < ? ) AS rewarded """, buildList { addAll(range.arguments(repetitions = 5)) add(INSTANT_COLUMN_TYPE to range.until) addAll(range.arguments()) + add(INSTANT_COLUMN_TYPE to range.until) }, ) { AdminAnalyticsReferralRow( @@ -796,6 +833,91 @@ class ExposedAdminProductAnalyticsRepository( ) } + private fun loadLatencyDistribution(range: AdminAnalyticsWindow): List = + queryRows( + """ + SELECT + duration_bucket, + SUM(CASE WHEN event_name = 'AI_FEATURE_SUCCEEDED' THEN 1 ELSE 0 END) AS successful, + SUM(CASE WHEN event_name = 'AI_FEATURE_FAILED' THEN 1 ELSE 0 END) AS failed + FROM product_analytics_events + WHERE occurred_at >= ? AND occurred_at < ? + AND event_name IN ('AI_FEATURE_SUCCEEDED', 'AI_FEATURE_FAILED') + AND duration_bucket IS NOT NULL + GROUP BY duration_bucket + ORDER BY FIELD( + duration_bucket, + 'LT_1S', + 'S1_TO_3', + 'S3_TO_10', + 'S10_TO_30', + 'GTE_30S' + ) + """, + range.arguments(), + ) { + AdminAnalyticsLatencyRow( + bucket = it.getString("duration_bucket"), + successful = it.exactLong("successful"), + failed = it.exactLong("failed"), + ) + } + + private fun loadPurchaseFunnel(range: AdminAnalyticsWindow): AdminAnalyticsPurchaseFunnelRow = + querySingle( + """ + WITH viewed AS ( + SELECT installation_hash, MIN(occurred_at) AS viewed_at + FROM product_analytics_events + WHERE event_name = 'PURCHASE_VIEWED' + AND occurred_at >= ? AND occurred_at < ? + GROUP BY installation_hash + ), + started AS ( + SELECT v.installation_hash, MIN(e.occurred_at) AS started_at + FROM viewed v + JOIN product_analytics_events e + ON e.installation_hash = v.installation_hash + AND e.event_name = 'PURCHASE_STARTED' + AND e.occurred_at >= v.viewed_at + AND e.occurred_at < ? + GROUP BY v.installation_hash + ), + verified AS ( + SELECT DISTINCT s.installation_hash + FROM started s + JOIN product_analytics_installations i + ON i.installation_hash = s.installation_hash + JOIN storekit_credit_purchases p ON p.user_id = i.account_id + WHERE p.purchased_at >= s.started_at + AND p.purchased_at < ? + ) + SELECT + (SELECT COUNT(*) FROM viewed) AS viewed, + (SELECT COUNT(*) FROM started) AS started, + (SELECT COUNT(*) FROM verified) AS verified, + ( + SELECT COUNT(DISTINCT installation_hash) + FROM product_analytics_events + WHERE event_name = 'PURCHASE_CANCELLED' + AND occurred_at >= ? AND occurred_at < ? + ) AS cancelled + """, + buildList { + addAll(range.arguments()) + add(INSTANT_COLUMN_TYPE to range.until) + add(INSTANT_COLUMN_TYPE to range.until) + addAll(range.arguments()) + }, + ) { + AdminAnalyticsPurchaseFunnelRow( + viewed = it.exactLong("viewed"), + started = it.exactLong("started"), + verified = it.exactLong("verified"), + cancelled = it.exactLong("cancelled"), + ) + } + private fun loadGuardrails(range: AdminAnalyticsWindow): AdminAnalyticsGuardrailRow = querySingle( """ @@ -861,28 +983,6 @@ private data class ActivationRow( 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, Any?>> = buildList { @@ -892,13 +992,13 @@ private fun AdminAnalyticsWindow.arguments( } } -private fun maturedWindowArguments( - from: Instant, - maturityEnd: Instant, +private fun maturedCohortArguments( + range: AdminAnalyticsWindow, + observationDays: Long, ): List, Any?>> = listOf( - INSTANT_COLUMN_TYPE to from, - INSTANT_COLUMN_TYPE to maxOf(from, maturityEnd), + INSTANT_COLUMN_TYPE to range.from.minusSeconds(observationDays * DAY_SECONDS), + INSTANT_COLUMN_TYPE to range.until.minusSeconds(observationDays * DAY_SECONDS), ) private fun querySingle( diff --git a/src/main/kotlin/com/osglab/account/features/admin/stats/repositories/AdminStatsRepository.kt b/src/main/kotlin/com/osglab/account/features/admin/stats/repositories/AdminStatsRepository.kt index 30557ae..6471d76 100644 --- a/src/main/kotlin/com/osglab/account/features/admin/stats/repositories/AdminStatsRepository.kt +++ b/src/main/kotlin/com/osglab/account/features/admin/stats/repositories/AdminStatsRepository.kt @@ -114,8 +114,28 @@ class ExposedAdminStatsRepository( ) AS registrations, ( SELECT COUNT(DISTINCT user_id) - FROM credit_usage_records - WHERE created_at >= ? AND created_at < ? + FROM ( + SELECT user_id + FROM credit_usage_records u + JOIN accounts a ON a.id = u.user_id + WHERE u.created_at >= ? AND u.created_at < ? + UNION + SELECT i.account_id AS user_id + FROM product_analytics_events e + JOIN product_analytics_installations i + ON i.installation_hash = e.installation_hash + WHERE e.occurred_at >= ? AND e.occurred_at < ? + AND e.event_name = 'AI_FEATURE_SUCCEEDED' + AND e.execution_mode IN ('LOCAL', 'BYOK') + AND i.account_id IS NOT NULL + UNION + SELECT i.account_id AS user_id + FROM keyboard_usage_daily_summaries s + JOIN product_analytics_installations i + ON i.installation_hash = s.installation_hash + WHERE s.summary_date >= DATE(?) AND s.summary_date < DATE(?) + AND i.account_id IS NOT NULL + ) registered_activity ) AS active_users, ( SELECT COALESCE(SUM(balance), 0) @@ -134,7 +154,7 @@ class ExposedAdminStatsRepository( WHERE created_at >= ? AND created_at < ? ) AS consumed_credits """, - range.arguments(repetitions = 4), + range.arguments(repetitions = 6), ) { result -> AdminOverviewDto( totalUsers = result.exactLong("total_users"), @@ -148,7 +168,13 @@ class ExposedAdminStatsRepository( private fun loadReferralFunnel(range: AdminStatsRange): AdminReferralFunnelDto = querySingle( - """ + identityValueEventsCte() + + """ + , binding_cohort AS ( + SELECT * + FROM referral_bindings + WHERE bound_at >= ? AND bound_at < ? + ) SELECT ( SELECT COUNT(*) @@ -157,33 +183,47 @@ class ExposedAdminStatsRepository( ) AS codes_created, ( SELECT COUNT(*) - FROM referral_bindings - WHERE bound_at >= ? AND bound_at < ? + FROM binding_cohort ) AS bindings, ( SELECT COUNT(*) - FROM referral_bindings + FROM binding_cohort r + WHERE EXISTS ( + SELECT 1 + FROM value_events v + WHERE v.identity_key = CONCAT('a:', r.invitee_user_id) + AND v.occurred_at >= r.bound_at + AND v.occurred_at < ? + ) + ) AS activated_bindings, + ( + SELECT COUNT(*) + FROM binding_cohort WHERE reward_status = 'REWARDED' - AND rewarded_at >= ? AND rewarded_at < ? + AND rewarded_at < ? ) AS rewarded_bindings, ( SELECT COUNT(*) - FROM referral_bindings + FROM binding_cohort WHERE reward_status = 'PENDING' - AND bound_at >= ? AND bound_at < ? ) AS pending_bindings, ( SELECT COUNT(*) - FROM referral_bindings + FROM binding_cohort WHERE reward_status = 'INELIGIBLE_BUDGET' - AND bound_at >= ? AND bound_at < ? ) AS ineligible_bindings """, - range.arguments(repetitions = 5), + buildList { + addAll(range.arguments()) + addAll(range.arguments()) + add(INSTANT_COLUMN_TYPE to range.until) + add(INSTANT_COLUMN_TYPE to range.until) + }, ) { result -> AdminReferralFunnelDto( codesCreated = result.exactLong("codes_created"), bindings = result.exactLong("bindings"), + activatedBindings = result.exactLong("activated_bindings"), rewardedBindings = result.exactLong("rewarded_bindings"), pendingBindings = result.exactLong("pending_bindings"), ineligibleBindings = result.exactLong("ineligible_bindings"), @@ -197,23 +237,19 @@ class ExposedAdminStatsRepository( """ SELECT inviter_user_id, - SUM(CASE WHEN bound_at >= ? AND bound_at < ? THEN 1 ELSE 0 END) AS invited_users, + COUNT(*) AS invited_users, SUM( CASE WHEN reward_status = 'REWARDED' - AND rewarded_at >= ? AND rewarded_at < ? + AND rewarded_at < ? THEN 1 ELSE 0 END ) AS rewarded_users FROM referral_bindings - WHERE (bound_at >= ? AND bound_at < ?) - OR ( - reward_status = 'REWARDED' - AND rewarded_at >= ? AND rewarded_at < ? - ) + WHERE bound_at >= ? AND bound_at < ? GROUP BY inviter_user_id """, - range.arguments(repetitions = 4), + listOf(INSTANT_COLUMN_TYPE to range.until) + range.arguments(), ) { result -> ReferralBindingAggregateRow( inviterUserId = result.getString("inviter_user_id"), @@ -225,14 +261,19 @@ class ExposedAdminStatsRepository( private fun loadReferralCreditsByInviter(range: AdminStatsRange): Map = queryRows( """ - SELECT user_id, COALESCE(SUM(amount_delta), 0) AS earned_credits - FROM credit_ledger - WHERE created_at >= ? AND created_at < ? - AND entry_type = 'REFERRAL_INVITER' - AND amount_delta > 0 - GROUP BY user_id + SELECT + r.inviter_user_id AS user_id, + COALESCE(SUM(l.amount_delta), 0) AS earned_credits + FROM referral_bindings r + JOIN credit_ledger l + ON l.reference_id = r.id + AND l.entry_type = 'REFERRAL_INVITER' + AND l.amount_delta > 0 + WHERE r.bound_at >= ? AND r.bound_at < ? + AND l.created_at < ? + GROUP BY r.inviter_user_id """, - range.arguments(), + range.arguments() + listOf(INSTANT_COLUMN_TYPE to range.until), ) { result -> result.getString("user_id") to result.exactLong("earned_credits") }.toMap() @@ -312,13 +353,21 @@ private fun queryRows( sql: String, arguments: List, Any?>>, transform: (ResultSet) -> T, -): List = TransactionManager.current().exec(sql.trimIndent(), arguments) { result -> - buildList { - while (result.next()) { - add(transform(result)) - } +): List { + val normalized = sql.trimIndent() + val executable = if (normalized.startsWith("WITH ", ignoreCase = true)) { + "SELECT * FROM (\n$normalized\n) AS admin_stats_result" + } else { + normalized } -} ?: emptyList() + return TransactionManager.current().exec(executable, arguments) { result -> + buildList { + while (result.next()) { + add(transform(result)) + } + } + } ?: emptyList() +} private fun ResultSet.exactLong(column: String): Long = requireNotNull(getBigDecimal(column)) { "Aggregate column $column must not be null" } diff --git a/src/main/kotlin/com/osglab/account/features/admin/stats/services/AdminProductAnalyticsService.kt b/src/main/kotlin/com/osglab/account/features/admin/stats/services/AdminProductAnalyticsService.kt index c16ac22..53a5a41 100644 --- a/src/main/kotlin/com/osglab/account/features/admin/stats/services/AdminProductAnalyticsService.kt +++ b/src/main/kotlin/com/osglab/account/features/admin/stats/services/AdminProductAnalyticsService.kt @@ -9,10 +9,12 @@ import com.osglab.account.features.admin.stats.models.AdminAnalyticsFunnelStepDt import com.osglab.account.features.admin.stats.models.AdminAnalyticsGrowthDto import com.osglab.account.features.admin.stats.models.AdminAnalyticsGuardrailsDto import com.osglab.account.features.admin.stats.models.AdminAnalyticsKeyboardUsageDto +import com.osglab.account.features.admin.stats.models.AdminAnalyticsLatencyBucketDto import com.osglab.account.features.admin.stats.models.AdminAnalyticsMonetizationDto import com.osglab.account.features.admin.stats.models.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.AdminAnalyticsReferralSignalsDto 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 @@ -100,13 +102,18 @@ class AdminProductAnalyticsService( conversion7d = snapshot.monetization.conversion7d.toRate(), conversion30d = snapshot.monetization.conversion30d.toRate(), repeatPurchaseRate = snapshot.monetization.repeatPurchase.toRate(), + purchaseFunnel = listOf( + AdminAnalyticsFunnelStepDto("浏览购买页", snapshot.purchaseFunnel.viewed), + AdminAnalyticsFunnelStepDto("发起购买", snapshot.purchaseFunnel.started), + AdminAnalyticsFunnelStepDto("StoreKit 验证完成", snapshot.purchaseFunnel.verified), + ), + cancelledUsers = snapshot.purchaseFunnel.cancelled, ), growthFunnel = listOf( - AdminAnalyticsFunnelStepDto("首次启动", growth.opened), - AdminAnalyticsFunnelStepDto("完成注册", growth.registered), + AdminAnalyticsFunnelStepDto("已完成 24h 观察的新安装", growth.opened), + AdminAnalyticsFunnelStepDto("24 小时内完成注册", growth.registered), AdminAnalyticsFunnelStepDto("24 小时内首次 AI 成功", growth.activated), - AdminAnalyticsFunnelStepDto("D7 再次使用 AI", growth.retainedD7), - AdminAnalyticsFunnelStepDto("首次购买", growth.purchased), + AdminAnalyticsFunnelStepDto("24 小时内完成首购", growth.purchased), ), retention = snapshot.retention.map { cohort -> AdminAnalyticsCohortDto( @@ -160,17 +167,26 @@ class AdminProductAnalyticsService( mixedLanguageSessions = keyboard.mixedLanguageSessions, otherOnlySessions = keyboard.otherOnlySessions, ), + referralSignals = AdminAnalyticsReferralSignalsDto( + shared = referrals.shared, + opened = referrals.opened, + ), referralFunnel = listOf( - AdminAnalyticsFunnelStepDto("发起分享", referrals.shared), - AdminAnalyticsFunnelStepDto("打开邀请", referrals.opened), AdminAnalyticsFunnelStepDto("完成绑定", referrals.bound), - AdminAnalyticsFunnelStepDto("首次 AI 成功", referrals.activated), + AdminAnalyticsFunnelStepDto("绑定后首次 AI 成功", referrals.activated), AdminAnalyticsFunnelStepDto("完成奖励", referrals.rewarded), ), guardrails = AdminAnalyticsGuardrailsDto( clientAiSuccessRate = snapshot.guardrails.clientSuccess.toRate(), managedSuccessRate = snapshot.guardrails.managedSuccess.toRate(), creditBlockedUsers = snapshot.guardrails.creditBlockedUsers, + latencyBuckets = snapshot.latencyDistribution.map { + AdminAnalyticsLatencyBucketDto( + bucket = it.bucket, + successful = it.successful, + failed = it.failed, + ) + }, ), ) } diff --git a/src/main/kotlin/com/osglab/account/features/analytics/models/AnalyticsDtos.kt b/src/main/kotlin/com/osglab/account/features/analytics/models/AnalyticsDtos.kt index e8ba816..f2f92fd 100644 --- a/src/main/kotlin/com/osglab/account/features/analytics/models/AnalyticsDtos.kt +++ b/src/main/kotlin/com/osglab/account/features/analytics/models/AnalyticsDtos.kt @@ -12,7 +12,7 @@ import kotlinx.serialization.Serializable @Serializable data class AnalyticsBatchRequest( - val installationId: String, + val installationId: String? = null, val events: List, ) { override fun toString(): String = @@ -32,6 +32,8 @@ data class AnalyticsEventRequest( val durationBucket: AnalyticsDurationBucket? = null, val appVersion: String? = null, val osVersion: String? = null, + // Transitional compatibility for clients released before installationId moved to the batch. + val installationId: String? = null, ) { override fun toString(): String = "AnalyticsEventRequest([REDACTED])" } diff --git a/src/main/kotlin/com/osglab/account/features/analytics/services/AnalyticsService.kt b/src/main/kotlin/com/osglab/account/features/analytics/services/AnalyticsService.kt index 28f95f8..f297235 100644 --- a/src/main/kotlin/com/osglab/account/features/analytics/services/AnalyticsService.kt +++ b/src/main/kotlin/com/osglab/account/features/analytics/services/AnalyticsService.kt @@ -49,7 +49,7 @@ class DefaultAnalyticsService( 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 installationId = parseUuid(resolveInstallationId(request), "installationId") val now = clock.instant() val events = request.events.map { validateAndMap(it, now) } return repository.ingest( @@ -62,6 +62,25 @@ class DefaultAnalyticsService( ) } + private fun resolveInstallationId(request: AnalyticsBatchRequest): String { + val batchInstallationId = request.installationId + val eventInstallationIds = request.events.map(AnalyticsEventRequest::installationId) + if (batchInstallationId == null) { + if (eventInstallationIds.any { it == null }) { + throw InvalidRequestException("installationId is required") + } + val distinctIds = eventInstallationIds.filterNotNull().toSet() + if (distinctIds.size != 1) { + throw InvalidRequestException("event installationId values must match") + } + return distinctIds.single() + } + if (eventInstallationIds.filterNotNull().any { it != batchInstallationId }) { + throw InvalidRequestException("event installationId must match the batch") + } + return batchInstallationId + } + override suspend fun ingestKeyboardUsage( accountId: UUID?, request: KeyboardUsageBatchRequest, diff --git a/src/main/resources/db/migration/V25__analytics_query_indexes.sql b/src/main/resources/db/migration/V25__analytics_query_indexes.sql new file mode 100644 index 0000000..ed7c23b --- /dev/null +++ b/src/main/resources/db/migration/V25__analytics_query_indexes.sql @@ -0,0 +1,3 @@ +CREATE INDEX idx_referral_bindings_bound_at + ON referral_bindings (bound_at); + diff --git a/src/test/kotlin/com/osglab/account/features/admin/routes/AdminStatsRangeTest.kt b/src/test/kotlin/com/osglab/account/features/admin/routes/AdminStatsRangeTest.kt new file mode 100644 index 0000000..2f56606 --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/admin/routes/AdminStatsRangeTest.kt @@ -0,0 +1,26 @@ +package com.osglab.account.features.admin.routes + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset + +class AdminStatsRangeTest : FunSpec({ + val now = Instant.parse("2026-08-20T15:30:00Z") + val clock = Clock.fixed(now, ZoneOffset.UTC) + + test("range presets cover exactly N UTC dates including the partial current date") { + parseAdminStatsRange("7d", clock) shouldBe + (Instant.parse("2026-08-14T00:00:00Z") to now) + parseAdminStatsRange("30d", clock) shouldBe + (Instant.parse("2026-07-22T00:00:00Z") to now) + parseAdminStatsRange("90d", clock) shouldBe + (Instant.parse("2026-05-23T00:00:00Z") to now) + } + + test("unknown range is rejected") { + parseAdminStatsRange("31d", clock) shouldBe null + } +}) + diff --git a/src/test/kotlin/com/osglab/account/features/admin/stats/AdminProductAnalyticsServiceTest.kt b/src/test/kotlin/com/osglab/account/features/admin/stats/AdminProductAnalyticsServiceTest.kt index 4c82af5..61681d0 100644 --- a/src/test/kotlin/com/osglab/account/features/admin/stats/AdminProductAnalyticsServiceTest.kt +++ b/src/test/kotlin/com/osglab/account/features/admin/stats/AdminProductAnalyticsServiceTest.kt @@ -9,6 +9,8 @@ import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsGrowth import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsGuardrailRow import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsKeyboardUsageRow import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsMonetizationRow +import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsLatencyRow +import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsPurchaseFunnelRow import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsReferralRow import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsWindow import com.osglab.account.features.admin.stats.repositories.AdminProductAnalyticsRepository @@ -51,12 +53,14 @@ class AdminProductAnalyticsServiceTest : FunSpec({ result.retention.first().d7?.percent shouldBe 30.0 result.retention.first().d30 shouldBe null result.growthFunnel.map { it.label } shouldBe listOf( - "首次启动", - "完成注册", + "已完成 24h 观察的新安装", + "24 小时内完成注册", "24 小时内首次 AI 成功", - "D7 再次使用 AI", - "首次购买", + "24 小时内完成首购", ) + result.referralSignals.shared shouldBe 20 + result.monetization.purchaseFunnel.last().count shouldBe 4 + result.guardrails.latencyBuckets.single().successful shouldBe 7 captured.single().second.from shouldBe Instant.parse("2026-08-17T00:00:00Z") captured.single().third.from shouldBe Instant.parse("2026-08-10T00:00:00Z") } @@ -141,7 +145,7 @@ private fun snapshot(): AdminProductAnalyticsSnapshot = conversion30d = AdminAnalyticsCountRow(10, 50), repeatPurchase = AdminAnalyticsCountRow(2, 10), ), - growthFunnel = AdminAnalyticsGrowthFunnelRow(100, 80, 60, 20, 10), + growthFunnel = AdminAnalyticsGrowthFunnelRow(100, 80, 60, 10), retention = listOf( AdminAnalyticsCohortRow( cohortDate = LocalDate.parse("2026-08-01"), @@ -176,4 +180,13 @@ private fun snapshot(): AdminProductAnalyticsSnapshot = managedSuccess = AdminAnalyticsCountRow(95, 100), creditBlockedUsers = 3, ), + latencyDistribution = listOf( + AdminAnalyticsLatencyRow("S1_TO_3", successful = 7, failed = 1), + ), + purchaseFunnel = AdminAnalyticsPurchaseFunnelRow( + viewed = 12, + started = 8, + verified = 4, + cancelled = 2, + ), ) diff --git a/src/test/kotlin/com/osglab/account/features/admin/stats/AdminStatsRepositoryIntegrationTest.kt b/src/test/kotlin/com/osglab/account/features/admin/stats/AdminStatsRepositoryIntegrationTest.kt index e2c3f62..eab201f 100644 --- a/src/test/kotlin/com/osglab/account/features/admin/stats/AdminStatsRepositoryIntegrationTest.kt +++ b/src/test/kotlin/com/osglab/account/features/admin/stats/AdminStatsRepositoryIntegrationTest.kt @@ -95,7 +95,10 @@ class AdminStatsRepositoryIntegrationTest : FunSpec({ populatedStats.overview.grantedCredits shouldBeExactly 100 populatedStats.grantedCreditsByDate.values.single() shouldBeExactly 100 - factory.query { seedProductAnalytics() } + factory.query { + seedProductAnalytics() + seedAnalyticsCorrectness() + } val populated = ExposedAdminProductAnalyticsRepository(factory).load( range = AdminAnalyticsWindow( from = Instant.parse("2026-08-10T00:00:00Z"), @@ -111,19 +114,84 @@ class AdminStatsRepositoryIntegrationTest : FunSpec({ ), ) - populated.currentWeeklyUsers shouldBeExactly 1 - populated.newInstallations shouldBeExactly 1 + populated.currentWeeklyUsers shouldBeExactly 4 + populated.newInstallations shouldBeExactly 4 populated.activation24h shouldBe + com.osglab.account.features.admin.stats.repositories.AdminAnalyticsCountRow(2, 3) + populated.periodActiveUsers shouldBeExactly 4 + populated.successfulAiRequests shouldBeExactly 5 + populated.features.single().successes shouldBeExactly 5 + populated.retention + .first { it.cohortDate.toString() == "2026-08-11" } + .d1 shouldBeExactly 1 + populated.keyboardUsage.activeUsers shouldBeExactly 3 + populated.keyboardUsage.keyboardUsers shouldBeExactly 3 + populated.keyboardUsage.chineseCharacters shouldBeExactly 140 + populated.keyboardUsage.englishCharacters shouldBeExactly 80 + populated.keyboardUsage.inputSessions shouldBeExactly 6 + populated.growthFunnel.opened shouldBeExactly 3 + populated.growthFunnel.registered shouldBeExactly 1 + populated.growthFunnel.activated shouldBeExactly 1 + populated.growthFunnel.purchased shouldBeExactly 1 + populated.referrals.bound shouldBeExactly 1 + populated.referrals.activated shouldBeExactly 1 + populated.referrals.rewarded shouldBeExactly 1 + populated.purchaseFunnel.viewed shouldBeExactly 1 + populated.purchaseFunnel.started shouldBeExactly 1 + populated.purchaseFunnel.verified shouldBeExactly 1 + populated.purchaseFunnel.cancelled shouldBeExactly 1 + populated.latencyDistribution.sumOf { it.successful } shouldBeExactly 5 + + val sevenDayMatured = ExposedAdminProductAnalyticsRepository(factory).load( + range = analyticsWindow( + "2026-08-17T12:00:00Z", + "2026-08-18T12:00:00Z", + ), + currentWeek = analyticsWindow( + "2026-08-17T12:00:00Z", + "2026-08-18T12:00:00Z", + ), + previousWeek = analyticsWindow( + "2026-08-10T12:00:00Z", + "2026-08-11T12:00:00Z", + ), + ) + // Account 600...001 completes its seven-day observation window + // inside this report period, despite registering a week earlier. + sevenDayMatured.monetization.conversion7d shouldBe com.osglab.account.features.admin.stats.repositories.AdminAnalyticsCountRow(1, 1) - populated.periodActiveUsers shouldBeExactly 1 - populated.successfulAiRequests shouldBeExactly 2 - populated.features.single().successes shouldBeExactly 2 - populated.retention.single().d1 shouldBeExactly 1 - populated.keyboardUsage.activeUsers shouldBeExactly 1 - populated.keyboardUsage.keyboardUsers shouldBeExactly 1 - populated.keyboardUsage.chineseCharacters shouldBeExactly 100 - populated.keyboardUsage.englishCharacters shouldBeExactly 50 - populated.keyboardUsage.inputSessions shouldBeExactly 4 + + val thirtyDayMatured = ExposedAdminProductAnalyticsRepository(factory).load( + range = analyticsWindow( + "2026-09-09T12:00:00Z", + "2026-09-10T12:00:00Z", + ), + currentWeek = analyticsWindow( + "2026-09-09T12:00:00Z", + "2026-09-10T12:00:00Z", + ), + previousWeek = analyticsWindow( + "2026-09-02T12:00:00Z", + "2026-09-03T12:00:00Z", + ), + ) + thirtyDayMatured.monetization.conversion30d shouldBe + com.osglab.account.features.admin.stats.repositories.AdminAnalyticsCountRow(1, 1) + + val overview = ExposedAdminStatsRepository(factory).load( + AdminStatsRange( + from = Instant.parse("2026-08-10T00:00:00Z"), + until = Instant.parse("2026-08-17T00:00:00Z"), + ), + ) + overview.overview.totalUsers shouldBeExactly 3 + overview.overview.activeUsers shouldBeExactly 2 + overview.referralFunnel.bindings shouldBeExactly 1 + overview.referralFunnel.activatedBindings shouldBeExactly 1 + overview.referralFunnel.rewardedBindings shouldBeExactly 1 + overview.referralRanking.single().invitedUsers shouldBeExactly 1 + overview.referralRanking.single().rewardedUsers shouldBeExactly 1 + overview.referralRanking.single().earnedCredits shouldBeExactly 25 } } finally { factory.close() @@ -222,3 +290,233 @@ private fun seedProductAnalytics() { """.trimIndent(), ) } + +private fun seedAnalyticsCorrectness() { + TransactionManager.current().exec( + """ + INSERT INTO accounts (id, apple_sub, created_at, updated_at) VALUES + ( + '60000000-0000-0000-0000-000000000001', 'stats-apple-1', + '2026-08-11 00:05:00.000000', '2026-08-11 00:05:00.000000' + ), + ( + '60000000-0000-0000-0000-000000000002', 'stats-apple-2', + '2026-08-10 00:00:00.000000', '2026-08-10 00:00:00.000000' + ), + ( + '60000000-0000-0000-0000-000000000003', 'stats-apple-3', + '2026-08-10 00:00:00.000000', '2026-08-10 00:00:00.000000' + ) + """.trimIndent(), + ) + TransactionManager.current().exec( + """ + INSERT INTO product_analytics_installations ( + installation_hash, account_id, created_at, updated_at + ) VALUES + ( + '${"b".repeat(64)}', '60000000-0000-0000-0000-000000000001', + '2026-08-11 00:00:00.000000', '2026-08-11 00:20:00.000000' + ), + ( + '${"c".repeat(64)}', '60000000-0000-0000-0000-000000000002', + '2026-08-11 00:00:00.000000', '2026-08-11 00:20:00.000000' + ), + ( + '${"d".repeat(64)}', NULL, + '2026-08-16 18:00:00.000000', '2026-08-16 18:10:00.000000' + ), + ( + '${"f".repeat(64)}', NULL, + '2026-08-10 23:50:00.000000', '2026-08-11 00:00:00.000000' + ) + """.trimIndent(), + ) + listOf( + eventValues("b", "101", "FIRST_OPEN", "2026-08-11 00:00:00", channel = "REFERRAL"), + eventValues( + "b", + "102", + "AI_FEATURE_SUCCEEDED", + "2026-08-11 00:10:00", + feature = "POLISH", + executionMode = "LOCAL", + durationBucket = "LT_1S", + ), + eventValues("b", "103", "PURCHASE_VIEWED", "2026-08-11 00:12:00"), + eventValues("b", "104", "PURCHASE_STARTED", "2026-08-11 00:13:00"), + eventValues( + "b", + "105", + "PURCHASE_CANCELLED", + "2026-08-11 00:13:30", + failureCategory = "CANCELLED", + ), + eventValues("d", "106", "FIRST_OPEN", "2026-08-16 18:00:00"), + eventValues( + "d", + "107", + "AI_FEATURE_SUCCEEDED", + "2026-08-16 18:10:00", + feature = "POLISH", + executionMode = "LOCAL", + durationBucket = "S3_TO_10", + ), + eventValues( + "f", + "108", + "AI_FEATURE_SUCCEEDED", + "2026-08-10 23:50:00", + feature = "POLISH", + executionMode = "LOCAL", + durationBucket = "S1_TO_3", + ), + eventValues("f", "109", "FIRST_OPEN", "2026-08-11 00:00:00"), + ).forEach { values -> + TransactionManager.current().exec( + """ + INSERT INTO product_analytics_events ( + installation_hash, client_event_id, event_name, occurred_at, surface, + acquisition_channel, feature, execution_mode, failure_category, + duration_bucket, app_version, os_version, payload_hash, received_at + ) VALUES $values + """.trimIndent(), + ) + } + TransactionManager.current().exec( + """ + INSERT INTO keyboard_usage_daily_summaries ( + installation_hash, client_summary_id, summary_date, + chinese_character_count, english_character_count, other_character_count, + input_session_count, chinese_only_session_count, english_only_session_count, + mixed_language_session_count, other_only_session_count, + app_version, os_version, payload_hash, received_at + ) VALUES + ( + '${"b".repeat(64)}', '60000000-0000-0000-0000-000000000101', '2026-08-11', + 20, 10, 0, 1, 0, 0, 1, 0, + '1.0', '18.6', '${"b".repeat(64)}', '2026-08-12 00:01:00.000000' + ), + ( + '${"c".repeat(64)}', '60000000-0000-0000-0000-000000000102', '2026-08-11', + 20, 20, 0, 1, 0, 0, 1, 0, + '1.0', '18.6', '${"c".repeat(64)}', '2026-08-12 00:01:00.000000' + ) + """.trimIndent(), + ) + TransactionManager.current().exec( + """ + INSERT INTO referral_codes (id, owner_user_id, code, created_at) + VALUES ( + '60000000-0000-0000-0000-000000000110', + '60000000-0000-0000-0000-000000000002', + 'STATS-CODE', + '2026-08-11 00:06:00.000000' + ) + """.trimIndent(), + ) + TransactionManager.current().exec( + """ + INSERT INTO referral_bindings ( + id, inviter_user_id, invitee_user_id, code_id, bound_at, + rewarded_at, reward_settlement_id, reward_status + ) VALUES + ( + '60000000-0000-0000-0000-000000000111', + '60000000-0000-0000-0000-000000000002', + '60000000-0000-0000-0000-000000000001', + '60000000-0000-0000-0000-000000000110', + '2026-08-11 00:07:00.000000', + '2026-08-11 00:20:00.000000', + '60000000-0000-0000-0000-000000000112', + 'REWARDED' + ), + ( + '60000000-0000-0000-0000-000000000113', + '60000000-0000-0000-0000-000000000002', + '60000000-0000-0000-0000-000000000003', + '60000000-0000-0000-0000-000000000110', + '2026-08-09 00:07:00.000000', + '2026-08-11 00:20:00.000000', + '60000000-0000-0000-0000-000000000114', + 'REWARDED' + ) + """.trimIndent(), + ) + TransactionManager.current().exec( + """ + INSERT INTO credit_ledger ( + id, user_id, entry_type, amount_delta, balance_after, + idempotency_key, reference_id, created_at + ) VALUES + ( + '60000000-0000-0000-0000-000000000201', + '60000000-0000-0000-0000-000000000002', + 'REFERRAL_INVITER', 25, 25, 'stats-referral-credit', + '60000000-0000-0000-0000-000000000111', + '2026-08-11 00:20:00.000000' + ), + ( + '60000000-0000-0000-0000-000000000202', + '60000000-0000-0000-0000-000000000001', + 'STOREKIT_PURCHASE', 100, 100, 'stats-storekit-credit', + '60000000-0000-0000-0000-000000000203', + '2026-08-11 00:14:00.000000' + ) + """.trimIndent(), + ) + TransactionManager.current().exec( + """ + INSERT INTO storekit_credit_purchases ( + id, transaction_id, original_transaction_id, user_id, app_account_token, + product_id, environment, credits_granted, ledger_entry_id, + signed_transaction_sha256, purchased_at, signed_at, created_at + ) VALUES ( + '60000000-0000-0000-0000-000000000203', + 'stats-transaction', 'stats-original', + '60000000-0000-0000-0000-000000000001', + '60000000-0000-0000-0000-000000000001', + 'com.osglab.credits.test', 'SANDBOX', 100, + '60000000-0000-0000-0000-000000000202', + '${"9".repeat(64)}', + '2026-08-11 00:14:00.000000', + '2026-08-11 00:14:00.000000', + '2026-08-11 00:14:00.000000' + ) + """.trimIndent(), + ) +} + +private fun analyticsWindow( + from: String, + until: String, +): AdminAnalyticsWindow = + AdminAnalyticsWindow( + from = Instant.parse(from), + until = Instant.parse(until), + ) + +private fun eventValues( + hashCharacter: String, + eventSuffix: String, + eventName: String, + occurredAt: String, + channel: String? = null, + feature: String? = null, + executionMode: String? = null, + failureCategory: String? = null, + durationBucket: String? = null, +): String { + val quoted = { value: String? -> value?.let { "'$it'" } ?: "NULL" } + return """ + ( + '${hashCharacter.repeat(64)}', + '60000000-0000-0000-0000-000000000$eventSuffix', + '$eventName', '$occurredAt.000000', 'APP', + ${quoted(channel)}, ${quoted(feature)}, ${quoted(executionMode)}, + ${quoted(failureCategory)}, ${quoted(durationBucket)}, + '1.0', '18.6', '${eventSuffix.padStart(64, '0')}', + '$occurredAt.000001' + ) + """.trimIndent() +} diff --git a/src/test/kotlin/com/osglab/account/features/admin/stats/AdminStatsRepositoryTest.kt b/src/test/kotlin/com/osglab/account/features/admin/stats/AdminStatsRepositoryTest.kt index 7654328..6bd1e0c 100644 --- a/src/test/kotlin/com/osglab/account/features/admin/stats/AdminStatsRepositoryTest.kt +++ b/src/test/kotlin/com/osglab/account/features/admin/stats/AdminStatsRepositoryTest.kt @@ -52,6 +52,7 @@ class AdminStatsRepositoryTest : FunSpec({ referralFunnel = AdminReferralFunnelDto( codesCreated = 5, bindings = 7, + activatedBindings = 5, rewardedBindings = 4, pendingBindings = 2, ineligibleBindings = 1, @@ -111,6 +112,7 @@ class AdminStatsRepositoryTest : FunSpec({ referralFunnel = AdminReferralFunnelDto( codesCreated = 0, bindings = 0, + activatedBindings = 0, rewardedBindings = 0, pendingBindings = 3, ineligibleBindings = 2, @@ -182,7 +184,7 @@ class AdminStatsRepositoryTest : FunSpec({ registrationsByDate = emptyMap(), grantedCreditsByDate = emptyMap(), consumedCreditsByDate = emptyMap(), - referralFunnel = AdminReferralFunnelDto(0, 0, 0, 0, 0), + referralFunnel = AdminReferralFunnelDto(0, 0, 0, 0, 0, 0), referralRanking = listOf( AdminReferralRankDto("user-c", 2, 1, 20), AdminReferralRankDto("user-a", 3, 1, 20), diff --git a/src/test/kotlin/com/osglab/account/features/analytics/AnalyticsRepositoryIntegrationTest.kt b/src/test/kotlin/com/osglab/account/features/analytics/AnalyticsRepositoryIntegrationTest.kt index 6254375..f9e51b6 100644 --- a/src/test/kotlin/com/osglab/account/features/analytics/AnalyticsRepositoryIntegrationTest.kt +++ b/src/test/kotlin/com/osglab/account/features/analytics/AnalyticsRepositoryIntegrationTest.kt @@ -108,8 +108,9 @@ class AnalyticsRepositoryIntegrationTest : FunSpec({ } concurrentResults.sumOf(AnalyticsIngestResult::accepted) shouldBe 1 concurrentResults.sumOf(AnalyticsIngestResult::replayed) shouldBe 7 + val concurrentInstallationId = requireNotNull(concurrentRequest.installationId) val concurrentKeyboardUsage = KeyboardUsageBatchRequest( - installationId = concurrentRequest.installationId, + installationId = concurrentInstallationId, summaries = listOf( keyboardSummary().copy( clientSummaryId = "50000000-0000-0000-0000-000000000099", @@ -135,14 +136,14 @@ class AnalyticsRepositoryIntegrationTest : FunSpec({ keyboardSummaryCount(config) shouldBe 1 markInstallationUpdatedAt( config, - concurrentRequest.installationId.sha256Hex(), + concurrentInstallationId.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 + installationCount(config, concurrentInstallationId.sha256Hex()) shouldBe 0 shouldThrow { service.ingest( diff --git a/src/test/kotlin/com/osglab/account/features/analytics/AnalyticsRoutesTest.kt b/src/test/kotlin/com/osglab/account/features/analytics/AnalyticsRoutesTest.kt index ee96a39..5d303b9 100644 --- a/src/test/kotlin/com/osglab/account/features/analytics/AnalyticsRoutesTest.kt +++ b/src/test/kotlin/com/osglab/account/features/analytics/AnalyticsRoutesTest.kt @@ -129,18 +129,7 @@ class AnalyticsRoutesTest { } 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() + checkNotNull(javaClass.getResource("/contracts/analytics-events-v1.json")).readText() private fun validKeyboardUsageBody(): String = """ diff --git a/src/test/kotlin/com/osglab/account/features/analytics/AnalyticsServiceTest.kt b/src/test/kotlin/com/osglab/account/features/analytics/AnalyticsServiceTest.kt index 3b204bb..7c7c5b8 100644 --- a/src/test/kotlin/com/osglab/account/features/analytics/AnalyticsServiceTest.kt +++ b/src/test/kotlin/com/osglab/account/features/analytics/AnalyticsServiceTest.kt @@ -54,6 +54,47 @@ class AnalyticsServiceTest { request.toString() shouldNotContain firstOpen().clientEventId } + @Test + fun `legacy event installation IDs are accepted only when the whole batch agrees`(): Unit = + kotlinx.coroutines.runBlocking { + val repository = InMemoryAnalyticsRepository() + val service = service(repository) + val legacyEvents = listOf( + sessionStarted(uuid(70)).copy(installationId = installationId), + sessionStarted(uuid(71)).copy(installationId = installationId), + ) + + service.ingest( + accountId = null, + request = AnalyticsBatchRequest(events = legacyEvents), + ) shouldBe AnalyticsIngestResult(accepted = 2, replayed = 0) + repository.lastBatch?.installationHash shouldBe installationId.sha256Hex() + + listOf( + AnalyticsBatchRequest(events = listOf(sessionStarted(uuid(72)))), + AnalyticsBatchRequest( + events = listOf( + sessionStarted(uuid(73)).copy(installationId = installationId), + sessionStarted(uuid(74)).copy( + installationId = "10000000-0000-0000-0000-000000000002" + ), + ), + ), + AnalyticsBatchRequest( + installationId = installationId, + events = listOf( + sessionStarted(uuid(75)).copy( + installationId = "10000000-0000-0000-0000-000000000002" + ) + ), + ), + ).forEach { invalid -> + shouldThrow { + service.ingest(null, invalid) + }.code shouldBe "invalid_request" + } + } + @Test fun `authenticated ingestion links an anonymous installation and rejects another account`(): Unit = kotlinx.coroutines.runBlocking { diff --git a/src/test/resources/contracts/analytics-events-v1.json b/src/test/resources/contracts/analytics-events-v1.json new file mode 100644 index 0000000..3b2ad6c --- /dev/null +++ b/src/test/resources/contracts/analytics-events-v1.json @@ -0,0 +1,12 @@ +{ + "installationId": "10000000-0000-0000-0000-000000000001", + "events": [ + { + "clientEventId": "40000000-0000-0000-0000-000000000001", + "eventType": "SESSION_STARTED", + "occurredAt": "2026-08-20T01:00:00Z", + "surface": "APP", + "appVersion": "1.0" + } + ] +}