Analytics: make retention measurable

Aptabase hashes its user_id from IP + user agent with a salt that rotates
daily, so events can never be linked across days and no retention or MAU
figure can come out of the export. In 19 days of data not one of 82 user_ids
appears on two dates, which is the artefact, not the behaviour.

Instead of an identity, every event now carries this install's own counters,
kept in UserDefaults/DataStore: tenure_days, launch_no, active_days and
dormant_days. Only derived day counts leave the device, so the privacy
position is unchanged.

They make the curve countable in the export: launch_no == 1 marks exactly one
launch per install, dormant_days >= 1 exactly one launch per calendar day, so
D_k is the share of installs seen again with tenure_days == k. Event date
minus tenure_days is the install date, which gives full cohort tables.

Installs predating the counters have no install date and report
tenure_days == -1 forever, so they can be excluded instead of inflating the
new-install cohort.

The Kotlin counter arithmetic sits in a pure advance() so it can be tested
without a Context; this adds the app module's first JVM test source set.
This commit is contained in:
2026-08-19 17:43:45 +02:00
parent 61e0cb061f
commit 5476209c50
9 changed files with 460 additions and 6 deletions

View File

@@ -0,0 +1,128 @@
import Foundation
import Testing
@testable import Cable
/// The retention counters are the only way to reconstruct D_k curves from an analytics backend
/// that cannot identify a device across days, so their arithmetic is verified here rather than
/// trusted in production. Serialized because `UsageMetrics.store` is process-wide state.
@Suite(.serialized)
struct UsageMetricsTests {
/// Runs `body` against an isolated defaults suite and a clock the test drives itself.
private func withFreshStore(_ body: (UserDefaults, _ setDay: (Int) -> Void) -> Void) {
let name = "usage.tests.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: name) else {
Issue.record("could not create a test defaults suite")
return
}
let previousStore = UsageMetrics.store
let previousClock = UsageMetrics.clock
var day = 20_000
UsageMetrics.store = defaults
UsageMetrics.clock = { Date(timeIntervalSince1970: Double(day) * 86_400 + 3_600) }
defer {
UsageMetrics.store = previousStore
UsageMetrics.clock = previousClock
defaults.removePersistentDomain(forName: name)
}
body(defaults, { day = $0 })
}
private func props() -> [String: Int] {
UsageMetrics.eventProps.compactMapValues { $0 as? Int }
}
@Test func firstLaunchOfANewInstallStartsTheCounters() {
withFreshStore { _, _ in
UsageMetrics.beginLaunch(isFirstLaunch: true)
#expect(props() == [
"tenure_days": 0, "launch_no": 1, "active_days": 1, "dormant_days": -1,
])
}
}
@Test func furtherLaunchesOnTheSameDayDoNotCountAsANewActiveDay() {
withFreshStore { _, _ in
UsageMetrics.beginLaunch(isFirstLaunch: true)
UsageMetrics.beginLaunch(isFirstLaunch: false)
UsageMetrics.beginLaunch(isFirstLaunch: false)
#expect(props() == [
"tenure_days": 0, "launch_no": 3, "active_days": 1, "dormant_days": 0,
])
}
}
@Test func tenureAndActiveDaysAdvanceAcrossDays() {
withFreshStore { _, setDay in
UsageMetrics.beginLaunch(isFirstLaunch: true)
setDay(20_001)
UsageMetrics.beginLaunch(isFirstLaunch: false)
#expect(props() == [
"tenure_days": 1, "launch_no": 2, "active_days": 2, "dormant_days": 1,
])
setDay(20_007)
UsageMetrics.beginLaunch(isFirstLaunch: false)
#expect(props() == [
"tenure_days": 7, "launch_no": 3, "active_days": 3, "dormant_days": 6,
])
}
}
/// The D_k denominator: exactly one launch per install carries `launch_no == 1`, and exactly
/// one launch per calendar day carries `dormant_days >= 1`. Both must hold or the counts in
/// the export measure launches instead of installs.
@Test func exactlyOneLaunchPerDayMarksTheDayBoundary() {
withFreshStore { _, setDay in
var installMarkers = 0
var dayMarkers = 0
var isFirst = true
for day in 20_000...20_004 {
setDay(day)
for _ in 0..<3 {
UsageMetrics.beginLaunch(isFirstLaunch: isFirst)
isFirst = false
if props()["launch_no"] == 1 { installMarkers += 1 }
if props()["dormant_days", default: 0] >= 1 { dayMarkers += 1 }
}
}
#expect(installMarkers == 1)
// Day 0 is counted by the install marker, so the boundary marks days 1...4.
#expect(dayMarkers == 4)
#expect(props()["active_days"] == 5)
#expect(props()["launch_no"] == 15)
}
}
/// Installs that predate the counters have no install date. They must stay distinguishable
/// from fresh installs forever, otherwise the update inflates the new-install cohort.
@Test func installsPredatingTheCountersReportUnknownTenure() {
withFreshStore { _, setDay in
UsageMetrics.beginLaunch(isFirstLaunch: false)
#expect(props() == [
"tenure_days": -1, "launch_no": 1, "active_days": 1, "dormant_days": -1,
])
setDay(20_003)
UsageMetrics.beginLaunch(isFirstLaunch: false)
#expect(props() == [
"tenure_days": -1, "launch_no": 2, "active_days": 2, "dormant_days": 3,
])
}
}
@Test func aBackwardsClockNeverProducesNegativeCounts() {
withFreshStore { _, setDay in
setDay(20_010)
UsageMetrics.beginLaunch(isFirstLaunch: true)
setDay(20_002)
UsageMetrics.beginLaunch(isFirstLaunch: false)
#expect(props()["tenure_days"] == 0)
#expect(props()["dormant_days"] == 0)
}
}
@Test func dayIndexIsAUTCDayCount() {
#expect(UsageMetrics.dayIndex(Date(timeIntervalSince1970: 0)) == 0)
#expect(UsageMetrics.dayIndex(Date(timeIntervalSince1970: 86_399)) == 0)
#expect(UsageMetrics.dayIndex(Date(timeIntervalSince1970: 86_400)) == 1)
}
}