Correct product analytics cohorts and reporting
This commit is contained in:
@@ -604,7 +604,7 @@ private suspend fun AdminStatsService.getRange(
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseAdminStatsRange(range: String?, clock: Clock): Pair<java.time.Instant, java.time.Instant>? {
|
||||
internal fun parseAdminStatsRange(range: String?, clock: Clock): Pair<java.time.Instant, java.time.Instant>? {
|
||||
val days = when (range) {
|
||||
null, "30d" -> 30L
|
||||
"7d" -> 7L
|
||||
@@ -612,7 +612,9 @@ private fun parseAdminStatsRange(range: String?, clock: Clock): Pair<java.time.I
|
||||
else -> 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<T>(val items: List<T>, 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<AdminFunnelResponse>,
|
||||
|
||||
+17
@@ -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<AdminAnalyticsFunnelStepDto>,
|
||||
val cancelledUsers: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -95,6 +110,7 @@ data class AdminAnalyticsGuardrailsDto(
|
||||
val clientAiSuccessRate: AdminAnalyticsRateDto,
|
||||
val managedSuccessRate: AdminAnalyticsRateDto,
|
||||
val creditBlockedUsers: Long,
|
||||
val latencyBuckets: List<AdminAnalyticsLatencyBucketDto>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -130,6 +146,7 @@ data class AdminProductAnalyticsDto(
|
||||
val retention: List<AdminAnalyticsCohortDto>,
|
||||
val aiFeatures: List<AdminAnalyticsFeatureUsageDto>,
|
||||
val keyboardUsage: AdminAnalyticsKeyboardUsageDto,
|
||||
val referralSignals: AdminAnalyticsReferralSignalsDto,
|
||||
val referralFunnel: List<AdminAnalyticsFunnelStepDto>,
|
||||
val guardrails: AdminAnalyticsGuardrailsDto,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.osglab.account.features.admin.stats.repositories
|
||||
|
||||
/**
|
||||
* Canonical AI value events used by product analytics. Managed usage is sourced
|
||||
* from immutable billing records; LOCAL and BYOK usage comes from terminal
|
||||
* client events. No user content is selected.
|
||||
*/
|
||||
internal fun identityValueEventsCte(): String =
|
||||
"""
|
||||
WITH value_events AS (
|
||||
SELECT
|
||||
CONCAT('a:', user_id) AS identity_key,
|
||||
created_at AS occurred_at
|
||||
FROM credit_usage_records
|
||||
UNION ALL
|
||||
SELECT
|
||||
COALESCE(
|
||||
CONCAT('a:', i.account_id),
|
||||
CONCAT('i:', e.installation_hash)
|
||||
) AS identity_key,
|
||||
e.occurred_at
|
||||
FROM product_analytics_events e
|
||||
JOIN product_analytics_installations i
|
||||
ON i.installation_hash = e.installation_hash
|
||||
WHERE e.event_name = 'AI_FEATURE_SUCCEEDED'
|
||||
AND e.execution_mode IN ('LOCAL', 'BYOK')
|
||||
)
|
||||
""".trimIndent()
|
||||
|
||||
+205
-105
@@ -74,6 +74,19 @@ data class AdminAnalyticsGuardrailRow(
|
||||
val creditBlockedUsers: Long,
|
||||
)
|
||||
|
||||
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<AdminAnalyticsLatencyRow>,
|
||||
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<AdminAnalyticsCohortRow> =
|
||||
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<AdminAnalyticsLatencyRow> =
|
||||
queryRows(
|
||||
"""
|
||||
SELECT
|
||||
duration_bucket,
|
||||
SUM(CASE WHEN event_name = 'AI_FEATURE_SUCCEEDED' THEN 1 ELSE 0 END) AS successful,
|
||||
SUM(CASE WHEN event_name = 'AI_FEATURE_FAILED' THEN 1 ELSE 0 END) AS failed
|
||||
FROM product_analytics_events
|
||||
WHERE occurred_at >= ? AND occurred_at < ?
|
||||
AND event_name IN ('AI_FEATURE_SUCCEEDED', 'AI_FEATURE_FAILED')
|
||||
AND duration_bucket IS NOT NULL
|
||||
GROUP BY duration_bucket
|
||||
ORDER BY FIELD(
|
||||
duration_bucket,
|
||||
'LT_1S',
|
||||
'S1_TO_3',
|
||||
'S3_TO_10',
|
||||
'S10_TO_30',
|
||||
'GTE_30S'
|
||||
)
|
||||
""",
|
||||
range.arguments(),
|
||||
) {
|
||||
AdminAnalyticsLatencyRow(
|
||||
bucket = it.getString("duration_bucket"),
|
||||
successful = it.exactLong("successful"),
|
||||
failed = it.exactLong("failed"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadPurchaseFunnel(range: AdminAnalyticsWindow): AdminAnalyticsPurchaseFunnelRow =
|
||||
querySingle(
|
||||
"""
|
||||
WITH viewed AS (
|
||||
SELECT installation_hash, MIN(occurred_at) AS viewed_at
|
||||
FROM product_analytics_events
|
||||
WHERE event_name = 'PURCHASE_VIEWED'
|
||||
AND occurred_at >= ? AND occurred_at < ?
|
||||
GROUP BY installation_hash
|
||||
),
|
||||
started AS (
|
||||
SELECT v.installation_hash, MIN(e.occurred_at) AS started_at
|
||||
FROM viewed v
|
||||
JOIN product_analytics_events e
|
||||
ON e.installation_hash = v.installation_hash
|
||||
AND e.event_name = 'PURCHASE_STARTED'
|
||||
AND e.occurred_at >= v.viewed_at
|
||||
AND e.occurred_at < ?
|
||||
GROUP BY v.installation_hash
|
||||
),
|
||||
verified AS (
|
||||
SELECT DISTINCT s.installation_hash
|
||||
FROM started s
|
||||
JOIN product_analytics_installations i
|
||||
ON i.installation_hash = s.installation_hash
|
||||
JOIN storekit_credit_purchases p ON p.user_id = i.account_id
|
||||
WHERE p.purchased_at >= s.started_at
|
||||
AND p.purchased_at < ?
|
||||
)
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM viewed) AS viewed,
|
||||
(SELECT COUNT(*) FROM started) AS started,
|
||||
(SELECT COUNT(*) FROM verified) AS verified,
|
||||
(
|
||||
SELECT COUNT(DISTINCT installation_hash)
|
||||
FROM product_analytics_events
|
||||
WHERE event_name = 'PURCHASE_CANCELLED'
|
||||
AND occurred_at >= ? AND occurred_at < ?
|
||||
) AS cancelled
|
||||
""",
|
||||
buildList {
|
||||
addAll(range.arguments())
|
||||
add(INSTANT_COLUMN_TYPE to range.until)
|
||||
add(INSTANT_COLUMN_TYPE to range.until)
|
||||
addAll(range.arguments())
|
||||
},
|
||||
) {
|
||||
AdminAnalyticsPurchaseFunnelRow(
|
||||
viewed = it.exactLong("viewed"),
|
||||
started = it.exactLong("started"),
|
||||
verified = it.exactLong("verified"),
|
||||
cancelled = it.exactLong("cancelled"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadGuardrails(range: AdminAnalyticsWindow): AdminAnalyticsGuardrailRow =
|
||||
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<Pair<IColumnType<*>, 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<Pair<IColumnType<*>, 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 <T> querySingle(
|
||||
|
||||
+83
-34
@@ -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<String, Long> =
|
||||
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 <T> queryRows(
|
||||
sql: String,
|
||||
arguments: List<Pair<IColumnType<*>, Any?>>,
|
||||
transform: (ResultSet) -> T,
|
||||
): List<T> = TransactionManager.current().exec(sql.trimIndent(), arguments) { result ->
|
||||
buildList {
|
||||
while (result.next()) {
|
||||
add(transform(result))
|
||||
}
|
||||
): List<T> {
|
||||
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" }
|
||||
|
||||
+23
-7
@@ -9,10 +9,12 @@ import com.osglab.account.features.admin.stats.models.AdminAnalyticsFunnelStepDt
|
||||
import com.osglab.account.features.admin.stats.models.AdminAnalyticsGrowthDto
|
||||
import com.osglab.account.features.admin.stats.models.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,
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class AnalyticsBatchRequest(
|
||||
val installationId: String,
|
||||
val installationId: String? = null,
|
||||
val events: List<AnalyticsEventRequest>,
|
||||
) {
|
||||
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])"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
CREATE INDEX idx_referral_bindings_bound_at
|
||||
ON referral_bindings (bound_at);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.osglab.account.features.admin.routes
|
||||
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
|
||||
class AdminStatsRangeTest : FunSpec({
|
||||
val now = Instant.parse("2026-08-20T15:30:00Z")
|
||||
val clock = Clock.fixed(now, ZoneOffset.UTC)
|
||||
|
||||
test("range presets cover exactly N UTC dates including the partial current date") {
|
||||
parseAdminStatsRange("7d", clock) shouldBe
|
||||
(Instant.parse("2026-08-14T00:00:00Z") to now)
|
||||
parseAdminStatsRange("30d", clock) shouldBe
|
||||
(Instant.parse("2026-07-22T00:00:00Z") to now)
|
||||
parseAdminStatsRange("90d", clock) shouldBe
|
||||
(Instant.parse("2026-05-23T00:00:00Z") to now)
|
||||
}
|
||||
|
||||
test("unknown range is rejected") {
|
||||
parseAdminStatsRange("31d", clock) shouldBe null
|
||||
}
|
||||
})
|
||||
|
||||
+18
-5
@@ -9,6 +9,8 @@ import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsGrowth
|
||||
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsGuardrailRow
|
||||
import com.osglab.account.features.admin.stats.repositories.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,
|
||||
),
|
||||
)
|
||||
|
||||
+310
-12
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
+4
-3
@@ -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<ConflictException> {
|
||||
service.ingest(
|
||||
|
||||
@@ -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 =
|
||||
"""
|
||||
|
||||
@@ -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<InvalidRequestException> {
|
||||
service.ingest(null, invalid)
|
||||
}.code shouldBe "invalid_request"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `authenticated ingestion links an anonymous installation and rejects another account`(): Unit =
|
||||
kotlinx.coroutines.runBlocking {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"installationId": "10000000-0000-0000-0000-000000000001",
|
||||
"events": [
|
||||
{
|
||||
"clientEventId": "40000000-0000-0000-0000-000000000001",
|
||||
"eventType": "SESSION_STARTED",
|
||||
"occurredAt": "2026-08-20T01:00:00Z",
|
||||
"surface": "APP",
|
||||
"appVersion": "1.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user