Add privacy-safe product analytics
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled

Establish an idempotent analytics pipeline and internal decision dashboard while keeping event metadata allowlisted and account deletion enforceable.
This commit is contained in:
Rocky
2026-08-20 15:20:01 +08:00
parent 231c5040a5
commit 4b465e0e5e
38 changed files with 4275 additions and 80 deletions
@@ -123,6 +123,29 @@ class DeploymentConsistencyTest : FunSpec({
}
}
test("product analytics contract stays allowlisted and account deletions cascade") {
val migration = root.read(
"src/main/resources/db/migration/V16__product_analytics_events.sql",
)
migration shouldContain "product_analytics_installations"
migration shouldContain "product_analytics_events"
migration shouldContain "REFERENCES accounts (id) ON DELETE CASCADE"
migration shouldContain "PRIMARY KEY (installation_hash, client_event_id)"
val openApi = root.read("docs/openapi.yaml")
val eventSchema = openApi
.substringAfter(" ProductAnalyticsEvent:")
.substringBefore(" AdminSessionState:")
eventSchema shouldContain "additionalProperties: false"
eventSchema shouldContain "AI_FEATURE_SUCCEEDED"
eventSchema shouldContain "INSUFFICIENT_CREDITS"
eventSchema shouldNotContain "prompt"
eventSchema shouldNotContain "transcript"
eventSchema shouldNotContain "audio"
eventSchema shouldNotContain "modelOutput"
openApi shouldContain "schema: { \$ref: \"#/components/schemas/AdminProductAnalytics\" }"
}
test("production Compose reuses private MySQL and hardens the application container") {
val compose = root.read("compose.yaml")
@@ -228,6 +251,7 @@ private val EXPECTED_PUBLIC_PATHS = setOf(
"/v1/auth/logout",
"/v1/account",
"/v1/apple/events",
"/v1/analytics/events",
"/v1/credits/balance",
"/v1/credits/ledger",
"/v1/credits/rates",
@@ -255,6 +279,7 @@ private val EXPECTED_PUBLIC_PATHS = setOf(
"/v1/admin/auth/logout",
"/v1/admin/overview",
"/v1/admin/referrals",
"/v1/admin/analytics",
"/v1/admin/users",
"/v1/admin/users/{userId}",
"/v1/admin/users/{userId}/ledger",
@@ -51,7 +51,7 @@ class SmokeDeploymentTest : FunSpec({
test("runtime grants cover every migrated table without mutable history privileges") {
val grants = root.read("deploy/smoke/runtime-grants.sql")
val migrationTables = (1..13)
val migrationTables = (1..16)
.flatMap { version ->
val migration = Files.list(root.resolve("src/main/resources/db/migration")).use { paths ->
paths.filter { it.fileName.toString().startsWith("V${version}__") }
@@ -11,6 +11,7 @@ import com.osglab.account.features.admin.services.AdminOperatorService
import com.osglab.account.features.admin.services.AdminOperatorErrorCode
import com.osglab.account.features.admin.services.AdminOperatorException
import com.osglab.account.features.admin.services.AdminSessionService
import com.osglab.account.features.admin.stats.services.AdminProductAnalyticsService
import com.osglab.account.features.admin.stats.services.AdminStatsService
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerPageDto
@@ -315,6 +316,7 @@ private fun io.ktor.server.application.Application.installAdminTestRoutes(
authService = authService,
sessionService = sessionService,
statsService = mockk<AdminStatsService>(relaxed = true),
productAnalyticsService = mockk<AdminProductAnalyticsService>(relaxed = true),
usersService = usersService,
grantService = grantService,
operatorService = operatorService,
@@ -0,0 +1,139 @@
package com.osglab.account.features.admin.stats
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsChannelRow
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsCohortRow
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsConsumptionRow
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsCountRow
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsFeatureRow
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsGrowthFunnelRow
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsGuardrailRow
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsMonetizationRow
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsReferralRow
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsWindow
import com.osglab.account.features.admin.stats.repositories.AdminProductAnalyticsRepository
import com.osglab.account.features.admin.stats.repositories.AdminProductAnalyticsSnapshot
import com.osglab.account.features.admin.stats.services.AdminProductAnalyticsService
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import java.time.Instant
import java.time.LocalDate
class AdminProductAnalyticsServiceTest : FunSpec({
test("maps product metrics with explicit rates and mature retention windows") {
val captured = mutableListOf<Triple<AdminAnalyticsWindow, AdminAnalyticsWindow, AdminAnalyticsWindow>>()
val repository = object : AdminProductAnalyticsRepository {
override suspend fun load(
range: AdminAnalyticsWindow,
currentWeek: AdminAnalyticsWindow,
previousWeek: AdminAnalyticsWindow,
): AdminProductAnalyticsSnapshot {
captured += Triple(range, currentWeek, previousWeek)
return snapshot()
}
}
val service = AdminProductAnalyticsService(repository)
val until = Instant.parse("2026-08-20T09:00:00Z")
val result = service.get(Instant.parse("2026-07-21T09:00:00Z"), until)
result.northStar.weeklyAiActiveUsers shouldBe 120
result.northStar.weekOverWeekPercent shouldBe 20.0
result.growth.activation24h.percent shouldBe 60.0
result.activity.stickinessPercent shouldBe 20.0
result.activity.successfulRequestsPerActiveUser shouldBe 5.0
result.consumption.averageCreditsPerManagedRequest shouldBe 2.5
result.retention.first().d1?.percent shouldBe 50.0
result.retention.first().d7?.percent shouldBe 30.0
result.retention.first().d30 shouldBe null
result.growthFunnel.map { it.label } shouldBe listOf(
"首次启动",
"完成注册",
"24 小时内首次 AI 成功",
"D7 再次使用 AI",
"首次购买",
)
captured.single().second.from shouldBe Instant.parse("2026-08-17T00:00:00Z")
captured.single().third.from shouldBe Instant.parse("2026-08-10T00:00:00Z")
}
test("zero denominators remain unavailable instead of reporting false zero rates") {
val empty = snapshot().copy(
previousWeeklyUsers = 0,
mau = 0,
periodActiveUsers = 0,
activation24h = AdminAnalyticsCountRow(0, 0),
consumption = AdminAnalyticsConsumptionRow(0, 0, null, null),
)
val service = AdminProductAnalyticsService(
object : AdminProductAnalyticsRepository {
override suspend fun load(
range: AdminAnalyticsWindow,
currentWeek: AdminAnalyticsWindow,
previousWeek: AdminAnalyticsWindow,
) = empty
},
)
val result = service.get(
Instant.parse("2026-08-19T00:00:00Z"),
Instant.parse("2026-08-20T00:00:00Z"),
)
result.northStar.weekOverWeekPercent shouldBe null
result.growth.activation24h.percent shouldBe null
result.activity.stickinessPercent shouldBe null
result.activity.successfulRequestsPerActiveUser shouldBe null
result.consumption.averageCreditsPerManagedRequest shouldBe null
}
})
private fun snapshot(): AdminProductAnalyticsSnapshot =
AdminProductAnalyticsSnapshot(
currentWeeklyUsers = 120,
previousWeeklyUsers = 100,
newInstallations = 100,
newAccounts = 80,
activation24h = AdminAnalyticsCountRow(60, 100),
medianTimeToValueMinutes = 7.6,
channels = listOf(
AdminAnalyticsChannelRow("APP_STORE_ORGANIC", 100, 60),
),
dau = 30,
wau = 120,
mau = 150,
periodActiveUsers = 100,
successfulAiRequests = 500,
consumption = AdminAnalyticsConsumptionRow(
totalCredits = 1_000,
managedRequests = 400,
averageDailyCreditsPerActiveUser = 12.25,
medianUserDailyCredits = 8.0,
),
monetization = AdminAnalyticsMonetizationRow(
payingUsers = 10,
purchases = 12,
creditsPurchased = 8_000,
conversion7d = AdminAnalyticsCountRow(8, 70),
conversion30d = AdminAnalyticsCountRow(10, 50),
repeatPurchase = AdminAnalyticsCountRow(2, 10),
),
growthFunnel = AdminAnalyticsGrowthFunnelRow(100, 80, 60, 20, 10),
retention = listOf(
AdminAnalyticsCohortRow(
cohortDate = LocalDate.parse("2026-08-01"),
size = 20,
d1 = 10,
d7 = 6,
d30 = 2,
),
),
features = listOf(
AdminAnalyticsFeatureRow("POLISH", "MANAGED", 30, 100),
),
referrals = AdminAnalyticsReferralRow(20, 15, 10, 8, 5),
guardrails = AdminAnalyticsGuardrailRow(
clientSuccess = AdminAnalyticsCountRow(90, 100),
managedSuccess = AdminAnalyticsCountRow(95, 100),
creditBlockedUsers = 3,
),
)
@@ -3,6 +3,8 @@ package com.osglab.account.features.admin.stats
import com.osglab.account.config.DatabaseConfig
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.admin.stats.repositories.AdminStatsRange
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsWindow
import com.osglab.account.features.admin.stats.repositories.ExposedAdminProductAnalyticsRepository
import com.osglab.account.features.admin.stats.repositories.ExposedAdminStatsRepository
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldBeEmpty
@@ -11,6 +13,7 @@ import io.kotest.matchers.shouldBe
import org.opentest4j.TestAbortedException
import org.testcontainers.DockerClientFactory
import org.testcontainers.containers.MySQLContainer
import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager
import java.time.Instant
class AdminStatsRepositoryIntegrationTest : FunSpec({
@@ -48,6 +51,20 @@ class AdminStatsRepositoryIntegrationTest : FunSpec({
until = Instant.parse("2026-08-17T12:00:00Z"),
),
)
val analytics = ExposedAdminProductAnalyticsRepository(factory).load(
range = AdminAnalyticsWindow(
from = Instant.parse("2026-08-10T12:00:00Z"),
until = Instant.parse("2026-08-17T12:00:00Z"),
),
currentWeek = AdminAnalyticsWindow(
from = Instant.parse("2026-08-17T00:00:00Z"),
until = Instant.parse("2026-08-17T12:00:00Z"),
),
previousWeek = AdminAnalyticsWindow(
from = Instant.parse("2026-08-10T00:00:00Z"),
until = Instant.parse("2026-08-10T12:00:00Z"),
),
)
// A dedicated container starts empty, so every scalar and grouped aggregate is explicit.
if (mysql != null) {
@@ -59,6 +76,36 @@ class AdminStatsRepositoryIntegrationTest : FunSpec({
snapshot.registrationsByDate shouldBe emptyMap()
snapshot.referralRanking.shouldBeEmpty()
snapshot.usage.shouldBeEmpty()
analytics.currentWeeklyUsers shouldBeExactly 0
analytics.newInstallations shouldBeExactly 0
analytics.retention.shouldBeEmpty()
analytics.features.shouldBeEmpty()
analytics.consumption.totalCredits shouldBeExactly 0
factory.query { seedProductAnalytics() }
val populated = ExposedAdminProductAnalyticsRepository(factory).load(
range = AdminAnalyticsWindow(
from = Instant.parse("2026-08-10T00:00:00Z"),
until = Instant.parse("2026-08-17T00:00:00Z"),
),
currentWeek = AdminAnalyticsWindow(
from = Instant.parse("2026-08-10T00:00:00Z"),
until = Instant.parse("2026-08-17T00:00:00Z"),
),
previousWeek = AdminAnalyticsWindow(
from = Instant.parse("2026-08-03T00:00:00Z"),
until = Instant.parse("2026-08-10T00:00:00Z"),
),
)
populated.currentWeeklyUsers shouldBeExactly 1
populated.newInstallations shouldBeExactly 1
populated.activation24h shouldBe
com.osglab.account.features.admin.stats.repositories.AdminAnalyticsCountRow(1, 1)
populated.periodActiveUsers shouldBeExactly 1
populated.successfulAiRequests shouldBeExactly 2
populated.features.single().successes shouldBeExactly 2
populated.retention.single().d1 shouldBeExactly 1
}
} finally {
factory.close()
@@ -69,3 +116,52 @@ class AdminStatsRepositoryIntegrationTest : FunSpec({
private class StatsMySqlContainer(image: String) :
MySQLContainer<StatsMySqlContainer>(image)
private fun seedProductAnalytics() {
TransactionManager.current().exec(
"""
INSERT INTO product_analytics_installations (
installation_hash, account_id, created_at, updated_at
) VALUES (
'${"a".repeat(64)}', NULL,
'2026-08-11 00:00:00.000000', '2026-08-12 00:10:00.000000'
)
""".trimIndent(),
)
listOf(
"""
(
'${"a".repeat(64)}', '40000000-0000-0000-0000-000000000001',
'FIRST_OPEN', '2026-08-11 00:00:00.000000', 'APP',
'APP_STORE_ORGANIC', NULL, NULL, NULL, NULL,
'1.0', '18.6', '${"1".repeat(64)}', '2026-08-11 00:00:01.000000'
)
""".trimIndent(),
"""
(
'${"a".repeat(64)}', '40000000-0000-0000-0000-000000000002',
'AI_FEATURE_SUCCEEDED', '2026-08-11 00:10:00.000000', 'KEYBOARD',
NULL, 'POLISH', 'LOCAL', NULL, 'S1_TO_3',
'1.0', '18.6', '${"2".repeat(64)}', '2026-08-11 00:10:01.000000'
)
""".trimIndent(),
"""
(
'${"a".repeat(64)}', '40000000-0000-0000-0000-000000000003',
'AI_FEATURE_SUCCEEDED', '2026-08-12 00:10:00.000000', 'KEYBOARD',
NULL, 'POLISH', 'LOCAL', NULL, 'S1_TO_3',
'1.0', '18.6', '${"3".repeat(64)}', '2026-08-12 00:10:01.000000'
)
""".trimIndent(),
).forEach { values ->
TransactionManager.current().exec(
"""
INSERT INTO product_analytics_events (
installation_hash, client_event_id, event_name, occurred_at, surface,
acquisition_channel, feature, execution_mode, failure_category,
duration_bucket, app_version, os_version, payload_hash, received_at
) VALUES $values
""".trimIndent(),
)
}
}
@@ -0,0 +1,255 @@
package com.osglab.account.features.analytics
import com.osglab.account.common.errors.ConflictException
import com.osglab.account.config.DatabaseConfig
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.analytics.domain.AnalyticsEventType
import com.osglab.account.features.analytics.domain.AnalyticsIngestResult
import com.osglab.account.features.analytics.domain.AnalyticsSurface
import com.osglab.account.features.analytics.models.AnalyticsBatchRequest
import com.osglab.account.features.analytics.models.AnalyticsEventRequest
import com.osglab.account.features.analytics.repositories.ExposedAnalyticsRepository
import com.osglab.account.features.analytics.services.DefaultAnalyticsService
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import java.security.MessageDigest
import java.sql.DriverManager
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.UUID
import org.opentest4j.TestAbortedException
import org.testcontainers.DockerClientFactory
import org.testcontainers.containers.MySQLContainer
class AnalyticsRepositoryIntegrationTest : FunSpec({
test("V16 repository links accounts replays atomically and cascades account deletion") {
withAnalyticsDatabase { config, databaseFactory ->
val now = Instant.parse("2026-08-20T01:00:00Z")
val accountId = UUID.fromString("20000000-0000-0000-0000-000000000001")
val otherAccountId = UUID.fromString("20000000-0000-0000-0000-000000000002")
val installationId = "10000000-0000-0000-0000-000000000001"
insertAccounts(config, listOf(accountId, otherAccountId), now)
val repository = ExposedAnalyticsRepository(databaseFactory)
val service = DefaultAnalyticsService(
repository = repository,
clock = Clock.fixed(now, ZoneOffset.UTC),
)
val original = event(
id = "30000000-0000-0000-0000-000000000001",
now = now,
surface = AnalyticsSurface.APP,
)
val request = AnalyticsBatchRequest(installationId, listOf(original))
service.ingest(null, request) shouldBe AnalyticsIngestResult(1, 0)
service.ingest(accountId, request) shouldBe AnalyticsIngestResult(0, 1)
service.ingest(accountId, request) shouldBe AnalyticsIngestResult(0, 1)
shouldThrow<ConflictException> {
service.ingest(otherAccountId, request)
}
installationCount(config, installationId) shouldBe 0
installationCount(config, installationId.sha256Hex()) shouldBe 1
linkedAccount(config, installationId.sha256Hex()) shouldBe accountId.toString()
eventCount(config) shouldBe 1
repository.recordInvitePageOpen(now)
repository.recordInvitePageOpen(now.plusSeconds(30))
scalarInt(
config,
"SELECT counter_value FROM product_analytics_daily_counters " +
"WHERE counter_name = 'INVITE_PAGE_OPENED'",
) shouldBe 2
val concurrentRequest = AnalyticsBatchRequest(
installationId = "10000000-0000-0000-0000-000000000099",
events = listOf(
event(
id = "30000000-0000-0000-0000-000000000099",
now = now,
surface = AnalyticsSurface.APP,
),
),
)
val concurrentResults = coroutineScope {
List(8) {
async { service.ingest(null, concurrentRequest) }
}.awaitAll()
}
concurrentResults.sumOf(AnalyticsIngestResult::accepted) shouldBe 1
concurrentResults.sumOf(AnalyticsIngestResult::replayed) shouldBe 7
markInstallationUpdatedAt(
config,
concurrentRequest.installationId.sha256Hex(),
now.minusSeconds(91L * 24 * 60 * 60),
)
repository.purgeAnonymousInstallations(
before = now.minusSeconds(90L * 24 * 60 * 60),
limit = 100,
) shouldBe 1
installationCount(config, concurrentRequest.installationId.sha256Hex()) shouldBe 0
shouldThrow<ConflictException> {
service.ingest(
accountId,
AnalyticsBatchRequest(
installationId = installationId,
events = listOf(
event(
id = "30000000-0000-0000-0000-000000000002",
now = now,
surface = AnalyticsSurface.APP,
),
original.copy(surface = AnalyticsSurface.KEYBOARD),
),
),
)
}
eventCount(config) shouldBe 1
deleteAccount(config, accountId)
installationCount(config, installationId.sha256Hex()) shouldBe 0
eventCount(config) shouldBe 0
}
}
})
private fun event(
id: String,
now: Instant,
surface: AnalyticsSurface,
) = AnalyticsEventRequest(
clientEventId = id,
eventType = AnalyticsEventType.SESSION_STARTED,
occurredAt = now.toString(),
surface = surface,
)
private suspend fun withAnalyticsDatabase(
block: suspend (DatabaseConfig, DatabaseFactory) -> Unit,
) {
val externalJdbcUrl = System.getenv("TEST_MYSQL_JDBC_URL")?.takeIf(String::isNotBlank)
if (externalJdbcUrl == null && !DockerClientFactory.instance().isDockerAvailable) {
throw TestAbortedException("Docker is unavailable; MySQL integration test skipped")
}
val mysql = if (externalJdbcUrl == null) {
AnalyticsMySqlContainer("mysql:8.4")
.withDatabaseName("osg_analytics_test")
.withUsername("test")
.withPassword("test")
.also(AnalyticsMySqlContainer::start)
} else {
null
}
val config = DatabaseConfig(
jdbcUrl = externalJdbcUrl ?: requireNotNull(mysql).jdbcUrl,
username = System.getenv("TEST_MYSQL_USER")?.takeIf(String::isNotBlank)
?: mysql?.username
?: "root",
password = System.getenv("TEST_MYSQL_PASSWORD") ?: mysql?.password ?: "",
maximumPoolSize = 4,
)
val databaseFactory = DatabaseFactory(config)
try {
databaseFactory.database
block(config, databaseFactory)
} finally {
databaseFactory.close()
mysql?.stop()
}
}
private fun insertAccounts(
config: DatabaseConfig,
accountIds: List<UUID>,
now: Instant,
) {
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
connection.prepareStatement(
"""
INSERT INTO accounts (id, apple_sub, created_at, updated_at)
VALUES (?, ?, ?, ?)
""".trimIndent()
).use { statement ->
accountIds.forEach { accountId ->
statement.setString(1, accountId.toString())
statement.setString(2, "analytics-test-$accountId")
statement.setTimestamp(3, java.sql.Timestamp.from(now))
statement.setTimestamp(4, java.sql.Timestamp.from(now))
statement.addBatch()
}
statement.executeBatch()
}
}
}
private fun installationCount(config: DatabaseConfig, hash: String): Int =
scalarInt(
config,
"SELECT COUNT(*) FROM product_analytics_installations WHERE installation_hash = ?",
hash,
)
private fun eventCount(config: DatabaseConfig): Int =
scalarInt(config, "SELECT COUNT(*) FROM product_analytics_events")
private fun linkedAccount(config: DatabaseConfig, hash: String): String? =
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
connection.prepareStatement(
"SELECT account_id FROM product_analytics_installations WHERE installation_hash = ?"
).use { statement ->
statement.setString(1, hash)
statement.executeQuery().use { result ->
result.next()
result.getString(1)
}
}
}
private fun scalarInt(config: DatabaseConfig, sql: String, argument: String? = null): Int =
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
connection.prepareStatement(sql).use { statement ->
argument?.let { statement.setString(1, it) }
statement.executeQuery().use { result ->
result.next()
result.getInt(1)
}
}
}
private fun deleteAccount(config: DatabaseConfig, accountId: UUID) {
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
connection.prepareStatement("DELETE FROM accounts WHERE id = ?").use { statement ->
statement.setString(1, accountId.toString())
statement.executeUpdate()
}
}
}
private fun markInstallationUpdatedAt(
config: DatabaseConfig,
installationHash: String,
updatedAt: Instant,
) {
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
connection.prepareStatement(
"UPDATE product_analytics_installations SET updated_at = ? WHERE installation_hash = ?"
).use { statement ->
statement.setTimestamp(1, java.sql.Timestamp.from(updatedAt))
statement.setString(2, installationHash)
statement.executeUpdate() shouldBe 1
}
}
}
private fun String.sha256Hex(): String =
MessageDigest.getInstance("SHA-256")
.digest(toByteArray(Charsets.UTF_8))
.joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) }
private class AnalyticsMySqlContainer(image: String) :
MySQLContainer<AnalyticsMySqlContainer>(image)
@@ -0,0 +1,153 @@
package com.osglab.account.features.analytics
import com.osglab.account.common.api.installApiStatusPages
import com.osglab.account.common.errors.ConflictException
import com.osglab.account.common.security.AccountPrincipal
import com.osglab.account.common.security.installSessionAuthentication
import com.osglab.account.features.analytics.domain.AnalyticsEventTimeException
import com.osglab.account.features.analytics.domain.AnalyticsIngestResult
import com.osglab.account.features.analytics.models.AnalyticsBatchRequest
import com.osglab.account.features.analytics.routes.analyticsRoutes
import com.osglab.account.features.analytics.services.AnalyticsService
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain
import io.ktor.client.request.bearerAuth
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.routing.routing
import io.ktor.server.testing.testApplication
import java.util.UUID
import kotlinx.serialization.json.Json
import kotlin.test.Test
class AnalyticsRoutesTest {
private val accountId = UUID.fromString("20000000-0000-0000-0000-000000000001")
private val sessionId = UUID.fromString("30000000-0000-0000-0000-000000000001")
@Test
fun `route accepts anonymous and authenticated batches without returning identity`() =
testApplication {
val service = RecordingAnalyticsService()
application {
install(ContentNegotiation) { json(Json { explicitNulls = false }) }
installApiStatusPages()
installSessionAuthentication { token ->
token.takeIf { it == "valid-token" }?.let {
AccountPrincipal(accountId, sessionId)
}
}
routing { analyticsRoutes(service) }
}
val anonymous = client.post("/v1/analytics/events") {
contentType(ContentType.Application.Json)
setBody(validBody())
}
val authenticated = client.post("/v1/analytics/events") {
bearerAuth("valid-token")
contentType(ContentType.Application.Json)
setBody(validBody())
}
val invalidBearer = client.post("/v1/analytics/events") {
bearerAuth("invalid-token")
contentType(ContentType.Application.Json)
setBody(validBody())
}
anonymous.status shouldBe HttpStatusCode.OK
anonymous.bodyAsText() shouldBe """{"accepted":1,"replayed":0}"""
authenticated.status shouldBe HttpStatusCode.OK
authenticated.bodyAsText() shouldNotContain accountId.toString()
authenticated.bodyAsText() shouldNotContain "installationId"
invalidBearer.status shouldBe HttpStatusCode.Unauthorized
service.accountIds shouldBe listOf(null, accountId)
}
@Test
fun `route returns stable validation conflict and event-time errors`() = testApplication {
val service = ErrorAnalyticsService()
application {
install(ContentNegotiation) { json(Json { explicitNulls = false }) }
installApiStatusPages()
installSessionAuthentication { null }
routing { analyticsRoutes(service) }
}
val invalidEnum = client.post("/v1/analytics/events") {
contentType(ContentType.Application.Json)
setBody(validBody().replace("SESSION_STARTED", "ARBITRARY_EVENT"))
}
val unknownField = client.post("/v1/analytics/events") {
contentType(ContentType.Application.Json)
setBody(validBody().replace("\"appVersion\"", "\"userText\":\"forbidden\",\"appVersion\""))
}
val conflict = client.post("/v1/analytics/events") {
contentType(ContentType.Application.Json)
setBody(validBody().replace(INSTALLATION_ID, CONFLICT_INSTALLATION_ID))
}
val invalidTime = client.post("/v1/analytics/events") {
contentType(ContentType.Application.Json)
setBody(validBody().replace(INSTALLATION_ID, INVALID_TIME_INSTALLATION_ID))
}
invalidEnum.status shouldBe HttpStatusCode.BadRequest
invalidEnum.bodyAsText() shouldContain """"code":"invalid_request""""
unknownField.status shouldBe HttpStatusCode.BadRequest
unknownField.bodyAsText() shouldContain """"code":"invalid_request""""
conflict.status shouldBe HttpStatusCode.Conflict
conflict.bodyAsText() shouldContain """"code":"conflict""""
invalidTime.status shouldBe HttpStatusCode.UnprocessableEntity
invalidTime.bodyAsText() shouldContain """"code":"event_time_invalid""""
}
private fun validBody(): String =
"""
{
"installationId":"$INSTALLATION_ID",
"events":[{
"clientEventId":"40000000-0000-0000-0000-000000000001",
"eventType":"SESSION_STARTED",
"occurredAt":"2026-08-20T01:00:00Z",
"surface":"APP",
"appVersion":"1.0"
}]
}
""".trimIndent()
private companion object {
const val INSTALLATION_ID = "10000000-0000-0000-0000-000000000001"
const val CONFLICT_INSTALLATION_ID = "10000000-0000-0000-0000-000000000002"
const val INVALID_TIME_INSTALLATION_ID = "10000000-0000-0000-0000-000000000003"
}
}
private class RecordingAnalyticsService : AnalyticsService {
val accountIds = mutableListOf<UUID?>()
override suspend fun ingest(
accountId: UUID?,
request: AnalyticsBatchRequest,
): AnalyticsIngestResult {
accountIds += accountId
return AnalyticsIngestResult(accepted = request.events.size, replayed = 0)
}
}
private class ErrorAnalyticsService : AnalyticsService {
override suspend fun ingest(
accountId: UUID?,
request: AnalyticsBatchRequest,
): AnalyticsIngestResult = when (request.installationId) {
"10000000-0000-0000-0000-000000000002" -> throw ConflictException("Conflict")
"10000000-0000-0000-0000-000000000003" -> throw AnalyticsEventTimeException()
else -> AnalyticsIngestResult(request.events.size, 0)
}
}
@@ -0,0 +1,351 @@
package com.osglab.account.features.analytics
import com.osglab.account.common.errors.ConflictException
import com.osglab.account.common.errors.InvalidRequestException
import com.osglab.account.features.analytics.domain.AnalyticsAcquisitionChannel
import com.osglab.account.features.analytics.domain.AnalyticsBatch
import com.osglab.account.features.analytics.domain.AnalyticsDurationBucket
import com.osglab.account.features.analytics.domain.AnalyticsEventType
import com.osglab.account.features.analytics.domain.AnalyticsEventTimeException
import com.osglab.account.features.analytics.domain.AnalyticsExecutionMode
import com.osglab.account.features.analytics.domain.AnalyticsFailureCategory
import com.osglab.account.features.analytics.domain.AnalyticsFeature
import com.osglab.account.features.analytics.domain.AnalyticsIngestResult
import com.osglab.account.features.analytics.domain.AnalyticsSurface
import com.osglab.account.features.analytics.models.AnalyticsBatchRequest
import com.osglab.account.features.analytics.models.AnalyticsEventRequest
import com.osglab.account.features.analytics.repositories.AnalyticsRepository
import com.osglab.account.features.analytics.services.AnalyticsMaintenanceService
import com.osglab.account.features.analytics.services.DefaultAnalyticsService
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldNotContain
import java.security.MessageDigest
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.time.ZoneOffset
import java.util.UUID
import kotlin.test.Test
class AnalyticsServiceTest {
private val now = Instant.parse("2026-08-20T01:00:00Z")
private val installationId = "10000000-0000-0000-0000-000000000001"
private val accountId = UUID.fromString("20000000-0000-0000-0000-000000000001")
private val otherAccountId = UUID.fromString("20000000-0000-0000-0000-000000000002")
@Test
fun `anonymous events are accepted and only the installation digest reaches persistence`(): Unit =
kotlinx.coroutines.runBlocking {
val repository = InMemoryAnalyticsRepository()
val request = batch(firstOpen())
val result = service(repository).ingest(null, request)
result shouldBe AnalyticsIngestResult(accepted = 1, replayed = 0)
repository.lastBatch?.accountId shouldBe null
repository.lastBatch?.installationHash shouldBe installationId.sha256Hex()
repository.lastBatch.toString() shouldNotContain installationId
request.toString() shouldNotContain installationId
request.toString() shouldNotContain firstOpen().clientEventId
}
@Test
fun `authenticated ingestion links an anonymous installation and rejects another account`(): Unit =
kotlinx.coroutines.runBlocking {
val repository = InMemoryAnalyticsRepository()
val service = service(repository)
val request = batch(sessionStarted())
service.ingest(null, request) shouldBe AnalyticsIngestResult(1, 0)
service.ingest(accountId, request) shouldBe AnalyticsIngestResult(0, 1)
service.ingest(accountId, request) shouldBe AnalyticsIngestResult(0, 1)
shouldThrow<ConflictException> {
service.ingest(otherAccountId, request)
}
}
@Test
fun `complete event catalog accepts only its declared dimensions`(): Unit =
kotlinx.coroutines.runBlocking {
val repository = InMemoryAnalyticsRepository()
val validEvents = listOf(
firstOpen(),
sessionStarted(id = uuid(2)),
event(
id = uuid(3),
type = AnalyticsEventType.KEYBOARD_ACTIVATED,
surface = AnalyticsSurface.KEYBOARD,
),
event(
id = uuid(4),
type = AnalyticsEventType.AI_FEATURE_STARTED,
feature = AnalyticsFeature.POLISH,
executionMode = AnalyticsExecutionMode.LOCAL,
),
event(
id = uuid(5),
type = AnalyticsEventType.AI_FEATURE_SUCCEEDED,
feature = AnalyticsFeature.AI_ASSISTANT,
executionMode = AnalyticsExecutionMode.BYOK,
durationBucket = AnalyticsDurationBucket.S1_TO_3,
),
event(
id = uuid(6),
type = AnalyticsEventType.AI_FEATURE_FAILED,
feature = AnalyticsFeature.TRANSCRIPTION,
executionMode = AnalyticsExecutionMode.MANAGED,
failureCategory = AnalyticsFailureCategory.TIMEOUT,
durationBucket = AnalyticsDurationBucket.S10_TO_30,
),
event(id = uuid(7), type = AnalyticsEventType.PURCHASE_VIEWED),
event(id = uuid(8), type = AnalyticsEventType.PURCHASE_STARTED),
event(
id = uuid(9),
type = AnalyticsEventType.PURCHASE_CANCELLED,
failureCategory = AnalyticsFailureCategory.CANCELLED,
),
event(id = uuid(10), type = AnalyticsEventType.REFERRAL_SHARED),
event(
id = uuid(11),
type = AnalyticsEventType.INVITE_OPENED,
surface = AnalyticsSurface.INVITE_WEB,
acquisitionChannel = AnalyticsAcquisitionChannel.REFERRAL,
),
)
service(repository).ingest(null, batch(events = validEvents)) shouldBe
AnalyticsIngestResult(validEvents.size, 0)
listOf(
firstOpen().copy(acquisitionChannel = null),
sessionStarted().copy(surface = AnalyticsSurface.INVITE_WEB),
event(
type = AnalyticsEventType.KEYBOARD_ACTIVATED,
surface = AnalyticsSurface.APP,
),
event(
type = AnalyticsEventType.AI_FEATURE_STARTED,
feature = AnalyticsFeature.POLISH,
executionMode = AnalyticsExecutionMode.LOCAL,
durationBucket = AnalyticsDurationBucket.LT_1S,
),
event(
type = AnalyticsEventType.AI_FEATURE_SUCCEEDED,
feature = AnalyticsFeature.POLISH,
executionMode = AnalyticsExecutionMode.LOCAL,
),
event(
type = AnalyticsEventType.AI_FEATURE_FAILED,
feature = AnalyticsFeature.POLISH,
executionMode = AnalyticsExecutionMode.LOCAL,
),
event(
type = AnalyticsEventType.PURCHASE_CANCELLED,
failureCategory = AnalyticsFailureCategory.NETWORK,
),
event(
type = AnalyticsEventType.INVITE_OPENED,
surface = AnalyticsSurface.INVITE_WEB,
acquisitionChannel = AnalyticsAcquisitionChannel.UNKNOWN,
),
).forEach { invalidEvent ->
shouldThrow<InvalidRequestException> {
service(repository).ingest(null, batch(invalidEvent))
}
}
}
@Test
fun `batch UUID release identifier and timestamp validation use stable errors`(): Unit =
kotlinx.coroutines.runBlocking {
val repository = InMemoryAnalyticsRepository()
val service = service(repository)
listOf(
AnalyticsBatchRequest("not-a-uuid", listOf(sessionStarted())),
batch(sessionStarted().copy(clientEventId = "not-a-uuid")),
batch(sessionStarted().copy(appVersion = "")),
batch(sessionStarted().copy(appVersion = "1.0 beta")),
batch(sessionStarted().copy(osVersion = "版本")),
batch(sessionStarted().copy(osVersion = "x".repeat(33))),
batch(sessionStarted().copy(occurredAt = "2026-08-20T01:00:00+01:00")),
AnalyticsBatchRequest(installationId, emptyList()),
AnalyticsBatchRequest(installationId, List(51) { sessionStarted() }),
).forEach { invalid ->
shouldThrow<InvalidRequestException> {
service.ingest(null, invalid)
}.code shouldBe "invalid_request"
}
repository.eventCount shouldBe 0
listOf(
now.minusSeconds(35L * 24 * 60 * 60 + 1),
now.plusSeconds(5L * 60 + 1),
).forEach { outside ->
shouldThrow<AnalyticsEventTimeException> {
service.ingest(null, batch(sessionStarted().copy(occurredAt = outside.toString())))
}.code shouldBe "event_time_invalid"
}
val maximumBatch = List(50) { index -> sessionStarted(uuid(100 + index)) }
service.ingest(null, batch(maximumBatch)) shouldBe AnalyticsIngestResult(50, 0)
}
@Test
fun `time boundaries are inclusive and batch persistence is atomic on conflict`(): Unit =
kotlinx.coroutines.runBlocking {
val repository = InMemoryAnalyticsRepository()
val service = service(repository)
val oldest = sessionStarted(uuid(20)).copy(
occurredAt = now.minusSeconds(35L * 24 * 60 * 60).toString()
)
val newest = sessionStarted(uuid(21)).copy(occurredAt = now.plusSeconds(5 * 60).toString())
service.ingest(null, batch(events = listOf(oldest, newest))) shouldBe
AnalyticsIngestResult(2, 0)
val original = sessionStarted(uuid(30))
service.ingest(null, batch(original)) shouldBe AnalyticsIngestResult(1, 0)
service.ingest(null, batch(original)) shouldBe AnalyticsIngestResult(0, 1)
val eventCountBeforeConflict = repository.eventCount
shouldThrow<ConflictException> {
service.ingest(
null,
batch(
events = listOf(
sessionStarted(uuid(31)),
original.copy(surface = AnalyticsSurface.KEYBOARD),
)
),
)
}
repository.eventCount shouldBe eventCountBeforeConflict
}
@Test
fun `maintenance purges only anonymous installations older than ninety days`(): Unit =
kotlinx.coroutines.runBlocking {
val repository = InMemoryAnalyticsRepository()
val maintenance = AnalyticsMaintenanceService(
repository = repository,
clock = Clock.fixed(now, ZoneOffset.UTC),
anonymousRetention = Duration.ofDays(90),
)
maintenance.purgeStaleAnonymousInstallations() shouldBe 0
repository.lastPurgeBefore shouldBe now.minus(Duration.ofDays(90))
repository.lastPurgeLimit shouldBe 1_000
}
private fun service(repository: AnalyticsRepository) =
DefaultAnalyticsService(repository, Clock.fixed(now, ZoneOffset.UTC))
private fun batch(
event: AnalyticsEventRequest,
) = batch(events = listOf(event))
private fun batch(
events: List<AnalyticsEventRequest>,
) = AnalyticsBatchRequest(installationId = installationId, events = events)
private fun firstOpen() = event(
id = uuid(1),
type = AnalyticsEventType.FIRST_OPEN,
acquisitionChannel = AnalyticsAcquisitionChannel.APP_STORE_ORGANIC,
appVersion = "1.2.3",
osVersion = "18.6",
)
private fun sessionStarted(id: String = uuid(12)) = event(
id = id,
type = AnalyticsEventType.SESSION_STARTED,
)
private fun event(
id: String = uuid(40),
type: AnalyticsEventType,
surface: AnalyticsSurface = AnalyticsSurface.APP,
acquisitionChannel: AnalyticsAcquisitionChannel? = null,
feature: AnalyticsFeature? = null,
executionMode: AnalyticsExecutionMode? = null,
failureCategory: AnalyticsFailureCategory? = null,
durationBucket: AnalyticsDurationBucket? = null,
appVersion: String? = null,
osVersion: String? = null,
) = AnalyticsEventRequest(
clientEventId = id,
eventType = type,
occurredAt = now.toString(),
surface = surface,
acquisitionChannel = acquisitionChannel,
feature = feature,
executionMode = executionMode,
failureCategory = failureCategory,
durationBucket = durationBucket,
appVersion = appVersion,
osVersion = osVersion,
)
private fun uuid(number: Int): String =
"30000000-0000-0000-0000-${number.toString().padStart(12, '0')}"
}
private class InMemoryAnalyticsRepository : AnalyticsRepository {
private val linkedAccounts = mutableMapOf<String, UUID?>()
private val payloads = mutableMapOf<Pair<String, UUID>, String>()
var lastBatch: AnalyticsBatch? = null
private set
val eventCount: Int get() = payloads.size
var lastPurgeBefore: Instant? = null
private set
var lastPurgeLimit: Int? = null
private set
override suspend fun recordInvitePageOpen(occurredAt: Instant) = Unit
override suspend fun purgeAnonymousInstallations(before: Instant, limit: Int): Int {
lastPurgeBefore = before
lastPurgeLimit = limit
return 0
}
override suspend fun ingest(batch: AnalyticsBatch): AnalyticsIngestResult {
val accountsCopy = linkedAccounts.toMutableMap()
val payloadsCopy = payloads.toMutableMap()
val existingAccount = accountsCopy[batch.installationHash]
if (batch.installationHash !in accountsCopy) {
accountsCopy[batch.installationHash] = batch.accountId
} else if (batch.accountId != null) {
when {
existingAccount == null -> accountsCopy[batch.installationHash] = batch.accountId
existingAccount != batch.accountId ->
throw ConflictException("Installation is linked to another account")
}
}
var accepted = 0
var replayed = 0
batch.events.forEach { event ->
val key = batch.installationHash to event.clientEventId
val existingHash = payloadsCopy[key]
when {
existingHash == null -> {
payloadsCopy[key] = event.payloadHash
accepted += 1
}
existingHash == event.payloadHash -> replayed += 1
else -> throw ConflictException("Client event ID was reused with another payload")
}
}
linkedAccounts.clear()
linkedAccounts.putAll(accountsCopy)
payloads.clear()
payloads.putAll(payloadsCopy)
lastBatch = batch
return AnalyticsIngestResult(accepted, replayed)
}
}
private fun String.sha256Hex(): String =
MessageDigest.getInstance("SHA-256")
.digest(toByteArray(Charsets.UTF_8))
.joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) }
@@ -22,9 +22,14 @@ class InviteWebRoutesTest {
@Test
fun `valid referral renders a bilingual first-party page with hardened headers`() = testApplication {
var recordedOpens = 0
application {
routing {
configureInviteWebRoutes(ReferralLookupPort { true }, config)
configureInviteWebRoutes(
ReferralLookupPort { true },
config,
InviteOpenRecorder { recordedOpens += 1 },
)
}
}
@@ -49,6 +54,7 @@ class InviteWebRoutesTest {
body shouldNotContain "branch.io"
body shouldNotContain "appsflyer"
body shouldNotContain "adjust.com"
recordedOpens shouldBe 1
}
@Test
@@ -108,6 +114,21 @@ class InviteWebRoutesTest {
body shouldContain "temporarily unavailable"
}
@Test
fun `analytics counter failure never makes a valid invitation unavailable`() = testApplication {
application {
routing {
configureInviteWebRoutes(
ReferralLookupPort { true },
config,
InviteOpenRecorder { error("analytics unavailable") },
)
}
}
client.get("/i/$VALID_CODE").status shouldBe HttpStatusCode.OK
}
@Test
fun `lookup timeout fails closed with retry guidance`() = testApplication {
application {