Add privacy-safe product analytics
Establish an idempotent analytics pipeline and internal decision dashboard while keeping event metadata allowlisted and account deletion enforceable.
This commit is contained in:
@@ -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,
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package com.osglab.account.features.admin.stats
|
||||
|
||||
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsChannelRow
|
||||
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsCohortRow
|
||||
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsConsumptionRow
|
||||
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsCountRow
|
||||
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsFeatureRow
|
||||
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsGrowthFunnelRow
|
||||
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsGuardrailRow
|
||||
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsMonetizationRow
|
||||
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsReferralRow
|
||||
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsWindow
|
||||
import com.osglab.account.features.admin.stats.repositories.AdminProductAnalyticsRepository
|
||||
import com.osglab.account.features.admin.stats.repositories.AdminProductAnalyticsSnapshot
|
||||
import com.osglab.account.features.admin.stats.services.AdminProductAnalyticsService
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
|
||||
class AdminProductAnalyticsServiceTest : FunSpec({
|
||||
test("maps product metrics with explicit rates and mature retention windows") {
|
||||
val captured = mutableListOf<Triple<AdminAnalyticsWindow, AdminAnalyticsWindow, AdminAnalyticsWindow>>()
|
||||
val repository = object : AdminProductAnalyticsRepository {
|
||||
override suspend fun load(
|
||||
range: AdminAnalyticsWindow,
|
||||
currentWeek: AdminAnalyticsWindow,
|
||||
previousWeek: AdminAnalyticsWindow,
|
||||
): AdminProductAnalyticsSnapshot {
|
||||
captured += Triple(range, currentWeek, previousWeek)
|
||||
return snapshot()
|
||||
}
|
||||
}
|
||||
val service = AdminProductAnalyticsService(repository)
|
||||
val until = Instant.parse("2026-08-20T09:00:00Z")
|
||||
|
||||
val result = service.get(Instant.parse("2026-07-21T09:00:00Z"), until)
|
||||
|
||||
result.northStar.weeklyAiActiveUsers shouldBe 120
|
||||
result.northStar.weekOverWeekPercent shouldBe 20.0
|
||||
result.growth.activation24h.percent shouldBe 60.0
|
||||
result.activity.stickinessPercent shouldBe 20.0
|
||||
result.activity.successfulRequestsPerActiveUser shouldBe 5.0
|
||||
result.consumption.averageCreditsPerManagedRequest shouldBe 2.5
|
||||
result.retention.first().d1?.percent shouldBe 50.0
|
||||
result.retention.first().d7?.percent shouldBe 30.0
|
||||
result.retention.first().d30 shouldBe null
|
||||
result.growthFunnel.map { it.label } shouldBe listOf(
|
||||
"首次启动",
|
||||
"完成注册",
|
||||
"24 小时内首次 AI 成功",
|
||||
"D7 再次使用 AI",
|
||||
"首次购买",
|
||||
)
|
||||
captured.single().second.from shouldBe Instant.parse("2026-08-17T00:00:00Z")
|
||||
captured.single().third.from shouldBe Instant.parse("2026-08-10T00:00:00Z")
|
||||
}
|
||||
|
||||
test("zero denominators remain unavailable instead of reporting false zero rates") {
|
||||
val empty = snapshot().copy(
|
||||
previousWeeklyUsers = 0,
|
||||
mau = 0,
|
||||
periodActiveUsers = 0,
|
||||
activation24h = AdminAnalyticsCountRow(0, 0),
|
||||
consumption = AdminAnalyticsConsumptionRow(0, 0, null, null),
|
||||
)
|
||||
val service = AdminProductAnalyticsService(
|
||||
object : AdminProductAnalyticsRepository {
|
||||
override suspend fun load(
|
||||
range: AdminAnalyticsWindow,
|
||||
currentWeek: AdminAnalyticsWindow,
|
||||
previousWeek: AdminAnalyticsWindow,
|
||||
) = empty
|
||||
},
|
||||
)
|
||||
|
||||
val result = service.get(
|
||||
Instant.parse("2026-08-19T00:00:00Z"),
|
||||
Instant.parse("2026-08-20T00:00:00Z"),
|
||||
)
|
||||
|
||||
result.northStar.weekOverWeekPercent shouldBe null
|
||||
result.growth.activation24h.percent shouldBe null
|
||||
result.activity.stickinessPercent shouldBe null
|
||||
result.activity.successfulRequestsPerActiveUser shouldBe null
|
||||
result.consumption.averageCreditsPerManagedRequest shouldBe null
|
||||
}
|
||||
})
|
||||
|
||||
private fun snapshot(): AdminProductAnalyticsSnapshot =
|
||||
AdminProductAnalyticsSnapshot(
|
||||
currentWeeklyUsers = 120,
|
||||
previousWeeklyUsers = 100,
|
||||
newInstallations = 100,
|
||||
newAccounts = 80,
|
||||
activation24h = AdminAnalyticsCountRow(60, 100),
|
||||
medianTimeToValueMinutes = 7.6,
|
||||
channels = listOf(
|
||||
AdminAnalyticsChannelRow("APP_STORE_ORGANIC", 100, 60),
|
||||
),
|
||||
dau = 30,
|
||||
wau = 120,
|
||||
mau = 150,
|
||||
periodActiveUsers = 100,
|
||||
successfulAiRequests = 500,
|
||||
consumption = AdminAnalyticsConsumptionRow(
|
||||
totalCredits = 1_000,
|
||||
managedRequests = 400,
|
||||
averageDailyCreditsPerActiveUser = 12.25,
|
||||
medianUserDailyCredits = 8.0,
|
||||
),
|
||||
monetization = AdminAnalyticsMonetizationRow(
|
||||
payingUsers = 10,
|
||||
purchases = 12,
|
||||
creditsPurchased = 8_000,
|
||||
conversion7d = AdminAnalyticsCountRow(8, 70),
|
||||
conversion30d = AdminAnalyticsCountRow(10, 50),
|
||||
repeatPurchase = AdminAnalyticsCountRow(2, 10),
|
||||
),
|
||||
growthFunnel = AdminAnalyticsGrowthFunnelRow(100, 80, 60, 20, 10),
|
||||
retention = listOf(
|
||||
AdminAnalyticsCohortRow(
|
||||
cohortDate = LocalDate.parse("2026-08-01"),
|
||||
size = 20,
|
||||
d1 = 10,
|
||||
d7 = 6,
|
||||
d30 = 2,
|
||||
),
|
||||
),
|
||||
features = listOf(
|
||||
AdminAnalyticsFeatureRow("POLISH", "MANAGED", 30, 100),
|
||||
),
|
||||
referrals = AdminAnalyticsReferralRow(20, 15, 10, 8, 5),
|
||||
guardrails = AdminAnalyticsGuardrailRow(
|
||||
clientSuccess = AdminAnalyticsCountRow(90, 100),
|
||||
managedSuccess = AdminAnalyticsCountRow(95, 100),
|
||||
creditBlockedUsers = 3,
|
||||
),
|
||||
)
|
||||
+96
@@ -3,6 +3,8 @@ package com.osglab.account.features.admin.stats
|
||||
import com.osglab.account.config.DatabaseConfig
|
||||
import com.osglab.account.config.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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
package com.osglab.account.features.analytics
|
||||
|
||||
import com.osglab.account.common.errors.ConflictException
|
||||
import com.osglab.account.config.DatabaseConfig
|
||||
import com.osglab.account.config.DatabaseFactory
|
||||
import com.osglab.account.features.analytics.domain.AnalyticsEventType
|
||||
import com.osglab.account.features.analytics.domain.AnalyticsIngestResult
|
||||
import com.osglab.account.features.analytics.domain.AnalyticsSurface
|
||||
import com.osglab.account.features.analytics.models.AnalyticsBatchRequest
|
||||
import com.osglab.account.features.analytics.models.AnalyticsEventRequest
|
||||
import com.osglab.account.features.analytics.repositories.ExposedAnalyticsRepository
|
||||
import com.osglab.account.features.analytics.services.DefaultAnalyticsService
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import java.security.MessageDigest
|
||||
import java.sql.DriverManager
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
import org.opentest4j.TestAbortedException
|
||||
import org.testcontainers.DockerClientFactory
|
||||
import org.testcontainers.containers.MySQLContainer
|
||||
|
||||
class AnalyticsRepositoryIntegrationTest : FunSpec({
|
||||
test("V16 repository links accounts replays atomically and cascades account deletion") {
|
||||
withAnalyticsDatabase { config, databaseFactory ->
|
||||
val now = Instant.parse("2026-08-20T01:00:00Z")
|
||||
val accountId = UUID.fromString("20000000-0000-0000-0000-000000000001")
|
||||
val otherAccountId = UUID.fromString("20000000-0000-0000-0000-000000000002")
|
||||
val installationId = "10000000-0000-0000-0000-000000000001"
|
||||
insertAccounts(config, listOf(accountId, otherAccountId), now)
|
||||
val repository = ExposedAnalyticsRepository(databaseFactory)
|
||||
val service = DefaultAnalyticsService(
|
||||
repository = repository,
|
||||
clock = Clock.fixed(now, ZoneOffset.UTC),
|
||||
)
|
||||
val original = event(
|
||||
id = "30000000-0000-0000-0000-000000000001",
|
||||
now = now,
|
||||
surface = AnalyticsSurface.APP,
|
||||
)
|
||||
val request = AnalyticsBatchRequest(installationId, listOf(original))
|
||||
|
||||
service.ingest(null, request) shouldBe AnalyticsIngestResult(1, 0)
|
||||
service.ingest(accountId, request) shouldBe AnalyticsIngestResult(0, 1)
|
||||
service.ingest(accountId, request) shouldBe AnalyticsIngestResult(0, 1)
|
||||
shouldThrow<ConflictException> {
|
||||
service.ingest(otherAccountId, request)
|
||||
}
|
||||
|
||||
installationCount(config, installationId) shouldBe 0
|
||||
installationCount(config, installationId.sha256Hex()) shouldBe 1
|
||||
linkedAccount(config, installationId.sha256Hex()) shouldBe accountId.toString()
|
||||
eventCount(config) shouldBe 1
|
||||
repository.recordInvitePageOpen(now)
|
||||
repository.recordInvitePageOpen(now.plusSeconds(30))
|
||||
scalarInt(
|
||||
config,
|
||||
"SELECT counter_value FROM product_analytics_daily_counters " +
|
||||
"WHERE counter_name = 'INVITE_PAGE_OPENED'",
|
||||
) shouldBe 2
|
||||
|
||||
val concurrentRequest = AnalyticsBatchRequest(
|
||||
installationId = "10000000-0000-0000-0000-000000000099",
|
||||
events = listOf(
|
||||
event(
|
||||
id = "30000000-0000-0000-0000-000000000099",
|
||||
now = now,
|
||||
surface = AnalyticsSurface.APP,
|
||||
),
|
||||
),
|
||||
)
|
||||
val concurrentResults = coroutineScope {
|
||||
List(8) {
|
||||
async { service.ingest(null, concurrentRequest) }
|
||||
}.awaitAll()
|
||||
}
|
||||
concurrentResults.sumOf(AnalyticsIngestResult::accepted) shouldBe 1
|
||||
concurrentResults.sumOf(AnalyticsIngestResult::replayed) shouldBe 7
|
||||
markInstallationUpdatedAt(
|
||||
config,
|
||||
concurrentRequest.installationId.sha256Hex(),
|
||||
now.minusSeconds(91L * 24 * 60 * 60),
|
||||
)
|
||||
repository.purgeAnonymousInstallations(
|
||||
before = now.minusSeconds(90L * 24 * 60 * 60),
|
||||
limit = 100,
|
||||
) shouldBe 1
|
||||
installationCount(config, concurrentRequest.installationId.sha256Hex()) shouldBe 0
|
||||
|
||||
shouldThrow<ConflictException> {
|
||||
service.ingest(
|
||||
accountId,
|
||||
AnalyticsBatchRequest(
|
||||
installationId = installationId,
|
||||
events = listOf(
|
||||
event(
|
||||
id = "30000000-0000-0000-0000-000000000002",
|
||||
now = now,
|
||||
surface = AnalyticsSurface.APP,
|
||||
),
|
||||
original.copy(surface = AnalyticsSurface.KEYBOARD),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
eventCount(config) shouldBe 1
|
||||
|
||||
deleteAccount(config, accountId)
|
||||
installationCount(config, installationId.sha256Hex()) shouldBe 0
|
||||
eventCount(config) shouldBe 0
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
private fun event(
|
||||
id: String,
|
||||
now: Instant,
|
||||
surface: AnalyticsSurface,
|
||||
) = AnalyticsEventRequest(
|
||||
clientEventId = id,
|
||||
eventType = AnalyticsEventType.SESSION_STARTED,
|
||||
occurredAt = now.toString(),
|
||||
surface = surface,
|
||||
)
|
||||
|
||||
private suspend fun withAnalyticsDatabase(
|
||||
block: suspend (DatabaseConfig, DatabaseFactory) -> Unit,
|
||||
) {
|
||||
val externalJdbcUrl = System.getenv("TEST_MYSQL_JDBC_URL")?.takeIf(String::isNotBlank)
|
||||
if (externalJdbcUrl == null && !DockerClientFactory.instance().isDockerAvailable) {
|
||||
throw TestAbortedException("Docker is unavailable; MySQL integration test skipped")
|
||||
}
|
||||
val mysql = if (externalJdbcUrl == null) {
|
||||
AnalyticsMySqlContainer("mysql:8.4")
|
||||
.withDatabaseName("osg_analytics_test")
|
||||
.withUsername("test")
|
||||
.withPassword("test")
|
||||
.also(AnalyticsMySqlContainer::start)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val config = DatabaseConfig(
|
||||
jdbcUrl = externalJdbcUrl ?: requireNotNull(mysql).jdbcUrl,
|
||||
username = System.getenv("TEST_MYSQL_USER")?.takeIf(String::isNotBlank)
|
||||
?: mysql?.username
|
||||
?: "root",
|
||||
password = System.getenv("TEST_MYSQL_PASSWORD") ?: mysql?.password ?: "",
|
||||
maximumPoolSize = 4,
|
||||
)
|
||||
val databaseFactory = DatabaseFactory(config)
|
||||
try {
|
||||
databaseFactory.database
|
||||
block(config, databaseFactory)
|
||||
} finally {
|
||||
databaseFactory.close()
|
||||
mysql?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
private fun insertAccounts(
|
||||
config: DatabaseConfig,
|
||||
accountIds: List<UUID>,
|
||||
now: Instant,
|
||||
) {
|
||||
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
|
||||
connection.prepareStatement(
|
||||
"""
|
||||
INSERT INTO accounts (id, apple_sub, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""".trimIndent()
|
||||
).use { statement ->
|
||||
accountIds.forEach { accountId ->
|
||||
statement.setString(1, accountId.toString())
|
||||
statement.setString(2, "analytics-test-$accountId")
|
||||
statement.setTimestamp(3, java.sql.Timestamp.from(now))
|
||||
statement.setTimestamp(4, java.sql.Timestamp.from(now))
|
||||
statement.addBatch()
|
||||
}
|
||||
statement.executeBatch()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun installationCount(config: DatabaseConfig, hash: String): Int =
|
||||
scalarInt(
|
||||
config,
|
||||
"SELECT COUNT(*) FROM product_analytics_installations WHERE installation_hash = ?",
|
||||
hash,
|
||||
)
|
||||
|
||||
private fun eventCount(config: DatabaseConfig): Int =
|
||||
scalarInt(config, "SELECT COUNT(*) FROM product_analytics_events")
|
||||
|
||||
private fun linkedAccount(config: DatabaseConfig, hash: String): String? =
|
||||
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
|
||||
connection.prepareStatement(
|
||||
"SELECT account_id FROM product_analytics_installations WHERE installation_hash = ?"
|
||||
).use { statement ->
|
||||
statement.setString(1, hash)
|
||||
statement.executeQuery().use { result ->
|
||||
result.next()
|
||||
result.getString(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun scalarInt(config: DatabaseConfig, sql: String, argument: String? = null): Int =
|
||||
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
|
||||
connection.prepareStatement(sql).use { statement ->
|
||||
argument?.let { statement.setString(1, it) }
|
||||
statement.executeQuery().use { result ->
|
||||
result.next()
|
||||
result.getInt(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteAccount(config: DatabaseConfig, accountId: UUID) {
|
||||
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
|
||||
connection.prepareStatement("DELETE FROM accounts WHERE id = ?").use { statement ->
|
||||
statement.setString(1, accountId.toString())
|
||||
statement.executeUpdate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun markInstallationUpdatedAt(
|
||||
config: DatabaseConfig,
|
||||
installationHash: String,
|
||||
updatedAt: Instant,
|
||||
) {
|
||||
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
|
||||
connection.prepareStatement(
|
||||
"UPDATE product_analytics_installations SET updated_at = ? WHERE installation_hash = ?"
|
||||
).use { statement ->
|
||||
statement.setTimestamp(1, java.sql.Timestamp.from(updatedAt))
|
||||
statement.setString(2, installationHash)
|
||||
statement.executeUpdate() shouldBe 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.sha256Hex(): String =
|
||||
MessageDigest.getInstance("SHA-256")
|
||||
.digest(toByteArray(Charsets.UTF_8))
|
||||
.joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) }
|
||||
|
||||
private class AnalyticsMySqlContainer(image: String) :
|
||||
MySQLContainer<AnalyticsMySqlContainer>(image)
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.osglab.account.features.analytics
|
||||
|
||||
import com.osglab.account.common.api.installApiStatusPages
|
||||
import com.osglab.account.common.errors.ConflictException
|
||||
import com.osglab.account.common.security.AccountPrincipal
|
||||
import com.osglab.account.common.security.installSessionAuthentication
|
||||
import com.osglab.account.features.analytics.domain.AnalyticsEventTimeException
|
||||
import com.osglab.account.features.analytics.domain.AnalyticsIngestResult
|
||||
import com.osglab.account.features.analytics.models.AnalyticsBatchRequest
|
||||
import com.osglab.account.features.analytics.routes.analyticsRoutes
|
||||
import com.osglab.account.features.analytics.services.AnalyticsService
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.string.shouldContain
|
||||
import io.kotest.matchers.string.shouldNotContain
|
||||
import io.ktor.client.request.bearerAuth
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import io.ktor.server.application.install
|
||||
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.server.routing.routing
|
||||
import io.ktor.server.testing.testApplication
|
||||
import java.util.UUID
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.test.Test
|
||||
|
||||
class AnalyticsRoutesTest {
|
||||
private val accountId = UUID.fromString("20000000-0000-0000-0000-000000000001")
|
||||
private val sessionId = UUID.fromString("30000000-0000-0000-0000-000000000001")
|
||||
|
||||
@Test
|
||||
fun `route accepts anonymous and authenticated batches without returning identity`() =
|
||||
testApplication {
|
||||
val service = RecordingAnalyticsService()
|
||||
application {
|
||||
install(ContentNegotiation) { json(Json { explicitNulls = false }) }
|
||||
installApiStatusPages()
|
||||
installSessionAuthentication { token ->
|
||||
token.takeIf { it == "valid-token" }?.let {
|
||||
AccountPrincipal(accountId, sessionId)
|
||||
}
|
||||
}
|
||||
routing { analyticsRoutes(service) }
|
||||
}
|
||||
|
||||
val anonymous = client.post("/v1/analytics/events") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(validBody())
|
||||
}
|
||||
val authenticated = client.post("/v1/analytics/events") {
|
||||
bearerAuth("valid-token")
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(validBody())
|
||||
}
|
||||
val invalidBearer = client.post("/v1/analytics/events") {
|
||||
bearerAuth("invalid-token")
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(validBody())
|
||||
}
|
||||
|
||||
anonymous.status shouldBe HttpStatusCode.OK
|
||||
anonymous.bodyAsText() shouldBe """{"accepted":1,"replayed":0}"""
|
||||
authenticated.status shouldBe HttpStatusCode.OK
|
||||
authenticated.bodyAsText() shouldNotContain accountId.toString()
|
||||
authenticated.bodyAsText() shouldNotContain "installationId"
|
||||
invalidBearer.status shouldBe HttpStatusCode.Unauthorized
|
||||
service.accountIds shouldBe listOf(null, accountId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `route returns stable validation conflict and event-time errors`() = testApplication {
|
||||
val service = ErrorAnalyticsService()
|
||||
application {
|
||||
install(ContentNegotiation) { json(Json { explicitNulls = false }) }
|
||||
installApiStatusPages()
|
||||
installSessionAuthentication { null }
|
||||
routing { analyticsRoutes(service) }
|
||||
}
|
||||
|
||||
val invalidEnum = client.post("/v1/analytics/events") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(validBody().replace("SESSION_STARTED", "ARBITRARY_EVENT"))
|
||||
}
|
||||
val unknownField = client.post("/v1/analytics/events") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(validBody().replace("\"appVersion\"", "\"userText\":\"forbidden\",\"appVersion\""))
|
||||
}
|
||||
val conflict = client.post("/v1/analytics/events") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(validBody().replace(INSTALLATION_ID, CONFLICT_INSTALLATION_ID))
|
||||
}
|
||||
val invalidTime = client.post("/v1/analytics/events") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(validBody().replace(INSTALLATION_ID, INVALID_TIME_INSTALLATION_ID))
|
||||
}
|
||||
|
||||
invalidEnum.status shouldBe HttpStatusCode.BadRequest
|
||||
invalidEnum.bodyAsText() shouldContain """"code":"invalid_request""""
|
||||
unknownField.status shouldBe HttpStatusCode.BadRequest
|
||||
unknownField.bodyAsText() shouldContain """"code":"invalid_request""""
|
||||
conflict.status shouldBe HttpStatusCode.Conflict
|
||||
conflict.bodyAsText() shouldContain """"code":"conflict""""
|
||||
invalidTime.status shouldBe HttpStatusCode.UnprocessableEntity
|
||||
invalidTime.bodyAsText() shouldContain """"code":"event_time_invalid""""
|
||||
}
|
||||
|
||||
private fun validBody(): String =
|
||||
"""
|
||||
{
|
||||
"installationId":"$INSTALLATION_ID",
|
||||
"events":[{
|
||||
"clientEventId":"40000000-0000-0000-0000-000000000001",
|
||||
"eventType":"SESSION_STARTED",
|
||||
"occurredAt":"2026-08-20T01:00:00Z",
|
||||
"surface":"APP",
|
||||
"appVersion":"1.0"
|
||||
}]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
private companion object {
|
||||
const val INSTALLATION_ID = "10000000-0000-0000-0000-000000000001"
|
||||
const val CONFLICT_INSTALLATION_ID = "10000000-0000-0000-0000-000000000002"
|
||||
const val INVALID_TIME_INSTALLATION_ID = "10000000-0000-0000-0000-000000000003"
|
||||
}
|
||||
}
|
||||
|
||||
private class RecordingAnalyticsService : AnalyticsService {
|
||||
val accountIds = mutableListOf<UUID?>()
|
||||
|
||||
override suspend fun ingest(
|
||||
accountId: UUID?,
|
||||
request: AnalyticsBatchRequest,
|
||||
): AnalyticsIngestResult {
|
||||
accountIds += accountId
|
||||
return AnalyticsIngestResult(accepted = request.events.size, replayed = 0)
|
||||
}
|
||||
}
|
||||
|
||||
private class ErrorAnalyticsService : AnalyticsService {
|
||||
override suspend fun ingest(
|
||||
accountId: UUID?,
|
||||
request: AnalyticsBatchRequest,
|
||||
): AnalyticsIngestResult = when (request.installationId) {
|
||||
"10000000-0000-0000-0000-000000000002" -> throw ConflictException("Conflict")
|
||||
"10000000-0000-0000-0000-000000000003" -> throw AnalyticsEventTimeException()
|
||||
else -> AnalyticsIngestResult(request.events.size, 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package com.osglab.account.features.analytics
|
||||
|
||||
import com.osglab.account.common.errors.ConflictException
|
||||
import com.osglab.account.common.errors.InvalidRequestException
|
||||
import com.osglab.account.features.analytics.domain.AnalyticsAcquisitionChannel
|
||||
import com.osglab.account.features.analytics.domain.AnalyticsBatch
|
||||
import com.osglab.account.features.analytics.domain.AnalyticsDurationBucket
|
||||
import com.osglab.account.features.analytics.domain.AnalyticsEventType
|
||||
import com.osglab.account.features.analytics.domain.AnalyticsEventTimeException
|
||||
import com.osglab.account.features.analytics.domain.AnalyticsExecutionMode
|
||||
import com.osglab.account.features.analytics.domain.AnalyticsFailureCategory
|
||||
import com.osglab.account.features.analytics.domain.AnalyticsFeature
|
||||
import com.osglab.account.features.analytics.domain.AnalyticsIngestResult
|
||||
import com.osglab.account.features.analytics.domain.AnalyticsSurface
|
||||
import com.osglab.account.features.analytics.models.AnalyticsBatchRequest
|
||||
import com.osglab.account.features.analytics.models.AnalyticsEventRequest
|
||||
import com.osglab.account.features.analytics.repositories.AnalyticsRepository
|
||||
import com.osglab.account.features.analytics.services.AnalyticsMaintenanceService
|
||||
import com.osglab.account.features.analytics.services.DefaultAnalyticsService
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.string.shouldNotContain
|
||||
import java.security.MessageDigest
|
||||
import java.time.Clock
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
import kotlin.test.Test
|
||||
|
||||
class AnalyticsServiceTest {
|
||||
private val now = Instant.parse("2026-08-20T01:00:00Z")
|
||||
private val installationId = "10000000-0000-0000-0000-000000000001"
|
||||
private val accountId = UUID.fromString("20000000-0000-0000-0000-000000000001")
|
||||
private val otherAccountId = UUID.fromString("20000000-0000-0000-0000-000000000002")
|
||||
|
||||
@Test
|
||||
fun `anonymous events are accepted and only the installation digest reaches persistence`(): Unit =
|
||||
kotlinx.coroutines.runBlocking {
|
||||
val repository = InMemoryAnalyticsRepository()
|
||||
val request = batch(firstOpen())
|
||||
val result = service(repository).ingest(null, request)
|
||||
|
||||
result shouldBe AnalyticsIngestResult(accepted = 1, replayed = 0)
|
||||
repository.lastBatch?.accountId shouldBe null
|
||||
repository.lastBatch?.installationHash shouldBe installationId.sha256Hex()
|
||||
repository.lastBatch.toString() shouldNotContain installationId
|
||||
request.toString() shouldNotContain installationId
|
||||
request.toString() shouldNotContain firstOpen().clientEventId
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `authenticated ingestion links an anonymous installation and rejects another account`(): Unit =
|
||||
kotlinx.coroutines.runBlocking {
|
||||
val repository = InMemoryAnalyticsRepository()
|
||||
val service = service(repository)
|
||||
val request = batch(sessionStarted())
|
||||
|
||||
service.ingest(null, request) shouldBe AnalyticsIngestResult(1, 0)
|
||||
service.ingest(accountId, request) shouldBe AnalyticsIngestResult(0, 1)
|
||||
service.ingest(accountId, request) shouldBe AnalyticsIngestResult(0, 1)
|
||||
shouldThrow<ConflictException> {
|
||||
service.ingest(otherAccountId, request)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `complete event catalog accepts only its declared dimensions`(): Unit =
|
||||
kotlinx.coroutines.runBlocking {
|
||||
val repository = InMemoryAnalyticsRepository()
|
||||
val validEvents = listOf(
|
||||
firstOpen(),
|
||||
sessionStarted(id = uuid(2)),
|
||||
event(
|
||||
id = uuid(3),
|
||||
type = AnalyticsEventType.KEYBOARD_ACTIVATED,
|
||||
surface = AnalyticsSurface.KEYBOARD,
|
||||
),
|
||||
event(
|
||||
id = uuid(4),
|
||||
type = AnalyticsEventType.AI_FEATURE_STARTED,
|
||||
feature = AnalyticsFeature.POLISH,
|
||||
executionMode = AnalyticsExecutionMode.LOCAL,
|
||||
),
|
||||
event(
|
||||
id = uuid(5),
|
||||
type = AnalyticsEventType.AI_FEATURE_SUCCEEDED,
|
||||
feature = AnalyticsFeature.AI_ASSISTANT,
|
||||
executionMode = AnalyticsExecutionMode.BYOK,
|
||||
durationBucket = AnalyticsDurationBucket.S1_TO_3,
|
||||
),
|
||||
event(
|
||||
id = uuid(6),
|
||||
type = AnalyticsEventType.AI_FEATURE_FAILED,
|
||||
feature = AnalyticsFeature.TRANSCRIPTION,
|
||||
executionMode = AnalyticsExecutionMode.MANAGED,
|
||||
failureCategory = AnalyticsFailureCategory.TIMEOUT,
|
||||
durationBucket = AnalyticsDurationBucket.S10_TO_30,
|
||||
),
|
||||
event(id = uuid(7), type = AnalyticsEventType.PURCHASE_VIEWED),
|
||||
event(id = uuid(8), type = AnalyticsEventType.PURCHASE_STARTED),
|
||||
event(
|
||||
id = uuid(9),
|
||||
type = AnalyticsEventType.PURCHASE_CANCELLED,
|
||||
failureCategory = AnalyticsFailureCategory.CANCELLED,
|
||||
),
|
||||
event(id = uuid(10), type = AnalyticsEventType.REFERRAL_SHARED),
|
||||
event(
|
||||
id = uuid(11),
|
||||
type = AnalyticsEventType.INVITE_OPENED,
|
||||
surface = AnalyticsSurface.INVITE_WEB,
|
||||
acquisitionChannel = AnalyticsAcquisitionChannel.REFERRAL,
|
||||
),
|
||||
)
|
||||
|
||||
service(repository).ingest(null, batch(events = validEvents)) shouldBe
|
||||
AnalyticsIngestResult(validEvents.size, 0)
|
||||
|
||||
listOf(
|
||||
firstOpen().copy(acquisitionChannel = null),
|
||||
sessionStarted().copy(surface = AnalyticsSurface.INVITE_WEB),
|
||||
event(
|
||||
type = AnalyticsEventType.KEYBOARD_ACTIVATED,
|
||||
surface = AnalyticsSurface.APP,
|
||||
),
|
||||
event(
|
||||
type = AnalyticsEventType.AI_FEATURE_STARTED,
|
||||
feature = AnalyticsFeature.POLISH,
|
||||
executionMode = AnalyticsExecutionMode.LOCAL,
|
||||
durationBucket = AnalyticsDurationBucket.LT_1S,
|
||||
),
|
||||
event(
|
||||
type = AnalyticsEventType.AI_FEATURE_SUCCEEDED,
|
||||
feature = AnalyticsFeature.POLISH,
|
||||
executionMode = AnalyticsExecutionMode.LOCAL,
|
||||
),
|
||||
event(
|
||||
type = AnalyticsEventType.AI_FEATURE_FAILED,
|
||||
feature = AnalyticsFeature.POLISH,
|
||||
executionMode = AnalyticsExecutionMode.LOCAL,
|
||||
),
|
||||
event(
|
||||
type = AnalyticsEventType.PURCHASE_CANCELLED,
|
||||
failureCategory = AnalyticsFailureCategory.NETWORK,
|
||||
),
|
||||
event(
|
||||
type = AnalyticsEventType.INVITE_OPENED,
|
||||
surface = AnalyticsSurface.INVITE_WEB,
|
||||
acquisitionChannel = AnalyticsAcquisitionChannel.UNKNOWN,
|
||||
),
|
||||
).forEach { invalidEvent ->
|
||||
shouldThrow<InvalidRequestException> {
|
||||
service(repository).ingest(null, batch(invalidEvent))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `batch UUID release identifier and timestamp validation use stable errors`(): Unit =
|
||||
kotlinx.coroutines.runBlocking {
|
||||
val repository = InMemoryAnalyticsRepository()
|
||||
val service = service(repository)
|
||||
|
||||
listOf(
|
||||
AnalyticsBatchRequest("not-a-uuid", listOf(sessionStarted())),
|
||||
batch(sessionStarted().copy(clientEventId = "not-a-uuid")),
|
||||
batch(sessionStarted().copy(appVersion = "")),
|
||||
batch(sessionStarted().copy(appVersion = "1.0 beta")),
|
||||
batch(sessionStarted().copy(osVersion = "版本")),
|
||||
batch(sessionStarted().copy(osVersion = "x".repeat(33))),
|
||||
batch(sessionStarted().copy(occurredAt = "2026-08-20T01:00:00+01:00")),
|
||||
AnalyticsBatchRequest(installationId, emptyList()),
|
||||
AnalyticsBatchRequest(installationId, List(51) { sessionStarted() }),
|
||||
).forEach { invalid ->
|
||||
shouldThrow<InvalidRequestException> {
|
||||
service.ingest(null, invalid)
|
||||
}.code shouldBe "invalid_request"
|
||||
}
|
||||
repository.eventCount shouldBe 0
|
||||
|
||||
listOf(
|
||||
now.minusSeconds(35L * 24 * 60 * 60 + 1),
|
||||
now.plusSeconds(5L * 60 + 1),
|
||||
).forEach { outside ->
|
||||
shouldThrow<AnalyticsEventTimeException> {
|
||||
service.ingest(null, batch(sessionStarted().copy(occurredAt = outside.toString())))
|
||||
}.code shouldBe "event_time_invalid"
|
||||
}
|
||||
|
||||
val maximumBatch = List(50) { index -> sessionStarted(uuid(100 + index)) }
|
||||
service.ingest(null, batch(maximumBatch)) shouldBe AnalyticsIngestResult(50, 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `time boundaries are inclusive and batch persistence is atomic on conflict`(): Unit =
|
||||
kotlinx.coroutines.runBlocking {
|
||||
val repository = InMemoryAnalyticsRepository()
|
||||
val service = service(repository)
|
||||
val oldest = sessionStarted(uuid(20)).copy(
|
||||
occurredAt = now.minusSeconds(35L * 24 * 60 * 60).toString()
|
||||
)
|
||||
val newest = sessionStarted(uuid(21)).copy(occurredAt = now.plusSeconds(5 * 60).toString())
|
||||
|
||||
service.ingest(null, batch(events = listOf(oldest, newest))) shouldBe
|
||||
AnalyticsIngestResult(2, 0)
|
||||
|
||||
val original = sessionStarted(uuid(30))
|
||||
service.ingest(null, batch(original)) shouldBe AnalyticsIngestResult(1, 0)
|
||||
service.ingest(null, batch(original)) shouldBe AnalyticsIngestResult(0, 1)
|
||||
val eventCountBeforeConflict = repository.eventCount
|
||||
shouldThrow<ConflictException> {
|
||||
service.ingest(
|
||||
null,
|
||||
batch(
|
||||
events = listOf(
|
||||
sessionStarted(uuid(31)),
|
||||
original.copy(surface = AnalyticsSurface.KEYBOARD),
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
repository.eventCount shouldBe eventCountBeforeConflict
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `maintenance purges only anonymous installations older than ninety days`(): Unit =
|
||||
kotlinx.coroutines.runBlocking {
|
||||
val repository = InMemoryAnalyticsRepository()
|
||||
val maintenance = AnalyticsMaintenanceService(
|
||||
repository = repository,
|
||||
clock = Clock.fixed(now, ZoneOffset.UTC),
|
||||
anonymousRetention = Duration.ofDays(90),
|
||||
)
|
||||
|
||||
maintenance.purgeStaleAnonymousInstallations() shouldBe 0
|
||||
repository.lastPurgeBefore shouldBe now.minus(Duration.ofDays(90))
|
||||
repository.lastPurgeLimit shouldBe 1_000
|
||||
}
|
||||
|
||||
private fun service(repository: AnalyticsRepository) =
|
||||
DefaultAnalyticsService(repository, Clock.fixed(now, ZoneOffset.UTC))
|
||||
|
||||
private fun batch(
|
||||
event: AnalyticsEventRequest,
|
||||
) = batch(events = listOf(event))
|
||||
|
||||
private fun batch(
|
||||
events: List<AnalyticsEventRequest>,
|
||||
) = AnalyticsBatchRequest(installationId = installationId, events = events)
|
||||
|
||||
private fun firstOpen() = event(
|
||||
id = uuid(1),
|
||||
type = AnalyticsEventType.FIRST_OPEN,
|
||||
acquisitionChannel = AnalyticsAcquisitionChannel.APP_STORE_ORGANIC,
|
||||
appVersion = "1.2.3",
|
||||
osVersion = "18.6",
|
||||
)
|
||||
|
||||
private fun sessionStarted(id: String = uuid(12)) = event(
|
||||
id = id,
|
||||
type = AnalyticsEventType.SESSION_STARTED,
|
||||
)
|
||||
|
||||
private fun event(
|
||||
id: String = uuid(40),
|
||||
type: AnalyticsEventType,
|
||||
surface: AnalyticsSurface = AnalyticsSurface.APP,
|
||||
acquisitionChannel: AnalyticsAcquisitionChannel? = null,
|
||||
feature: AnalyticsFeature? = null,
|
||||
executionMode: AnalyticsExecutionMode? = null,
|
||||
failureCategory: AnalyticsFailureCategory? = null,
|
||||
durationBucket: AnalyticsDurationBucket? = null,
|
||||
appVersion: String? = null,
|
||||
osVersion: String? = null,
|
||||
) = AnalyticsEventRequest(
|
||||
clientEventId = id,
|
||||
eventType = type,
|
||||
occurredAt = now.toString(),
|
||||
surface = surface,
|
||||
acquisitionChannel = acquisitionChannel,
|
||||
feature = feature,
|
||||
executionMode = executionMode,
|
||||
failureCategory = failureCategory,
|
||||
durationBucket = durationBucket,
|
||||
appVersion = appVersion,
|
||||
osVersion = osVersion,
|
||||
)
|
||||
|
||||
private fun uuid(number: Int): String =
|
||||
"30000000-0000-0000-0000-${number.toString().padStart(12, '0')}"
|
||||
}
|
||||
|
||||
private class InMemoryAnalyticsRepository : AnalyticsRepository {
|
||||
private val linkedAccounts = mutableMapOf<String, UUID?>()
|
||||
private val payloads = mutableMapOf<Pair<String, UUID>, String>()
|
||||
var lastBatch: AnalyticsBatch? = null
|
||||
private set
|
||||
val eventCount: Int get() = payloads.size
|
||||
var lastPurgeBefore: Instant? = null
|
||||
private set
|
||||
var lastPurgeLimit: Int? = null
|
||||
private set
|
||||
|
||||
override suspend fun recordInvitePageOpen(occurredAt: Instant) = Unit
|
||||
override suspend fun purgeAnonymousInstallations(before: Instant, limit: Int): Int {
|
||||
lastPurgeBefore = before
|
||||
lastPurgeLimit = limit
|
||||
return 0
|
||||
}
|
||||
|
||||
override suspend fun ingest(batch: AnalyticsBatch): AnalyticsIngestResult {
|
||||
val accountsCopy = linkedAccounts.toMutableMap()
|
||||
val payloadsCopy = payloads.toMutableMap()
|
||||
val existingAccount = accountsCopy[batch.installationHash]
|
||||
if (batch.installationHash !in accountsCopy) {
|
||||
accountsCopy[batch.installationHash] = batch.accountId
|
||||
} else if (batch.accountId != null) {
|
||||
when {
|
||||
existingAccount == null -> accountsCopy[batch.installationHash] = batch.accountId
|
||||
existingAccount != batch.accountId ->
|
||||
throw ConflictException("Installation is linked to another account")
|
||||
}
|
||||
}
|
||||
|
||||
var accepted = 0
|
||||
var replayed = 0
|
||||
batch.events.forEach { event ->
|
||||
val key = batch.installationHash to event.clientEventId
|
||||
val existingHash = payloadsCopy[key]
|
||||
when {
|
||||
existingHash == null -> {
|
||||
payloadsCopy[key] = event.payloadHash
|
||||
accepted += 1
|
||||
}
|
||||
existingHash == event.payloadHash -> replayed += 1
|
||||
else -> throw ConflictException("Client event ID was reused with another payload")
|
||||
}
|
||||
}
|
||||
linkedAccounts.clear()
|
||||
linkedAccounts.putAll(accountsCopy)
|
||||
payloads.clear()
|
||||
payloads.putAll(payloadsCopy)
|
||||
lastBatch = batch
|
||||
return AnalyticsIngestResult(accepted, replayed)
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.sha256Hex(): String =
|
||||
MessageDigest.getInstance("SHA-256")
|
||||
.digest(toByteArray(Charsets.UTF_8))
|
||||
.joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) }
|
||||
@@ -22,9 +22,14 @@ class InviteWebRoutesTest {
|
||||
|
||||
@Test
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user