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:
@@ -19,6 +19,10 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
let isFirstLaunch = !UserDefaults.standard.bool(forKey: "hasLaunchedBefore")
|
let isFirstLaunch = !UserDefaults.standard.bool(forKey: "hasLaunchedBefore")
|
||||||
if isFirstLaunch {
|
if isFirstLaunch {
|
||||||
UserDefaults.standard.set(true, forKey: "hasLaunchedBefore")
|
UserDefaults.standard.set(true, forKey: "hasLaunchedBefore")
|
||||||
|
}
|
||||||
|
// Before the first log call: every event carries this launch's tenure counters.
|
||||||
|
UsageMetrics.beginLaunch(isFirstLaunch: isFirstLaunch)
|
||||||
|
if isFirstLaunch {
|
||||||
AnalyticsTracker.log("First Launch")
|
AnalyticsTracker.log("First Launch")
|
||||||
}
|
}
|
||||||
ReviewPrompt.migrateIfNeeded(isFirstLaunch: isFirstLaunch)
|
ReviewPrompt.migrateIfNeeded(isFirstLaunch: isFirstLaunch)
|
||||||
@@ -31,8 +35,12 @@ enum AnalyticsTracker {
|
|||||||
static func configure() {}
|
static func configure() {}
|
||||||
|
|
||||||
static func log(_ event: String, properties: [String: Any] = [:]) {
|
static func log(_ event: String, properties: [String: Any] = [:]) {
|
||||||
|
// Tenure counters first so an explicit property of the same name would win.
|
||||||
|
var merged = UsageMetrics.eventProps
|
||||||
|
for (key, value) in properties { merged[key] = value }
|
||||||
|
|
||||||
var converted: [String: Any] = [:]
|
var converted: [String: Any] = [:]
|
||||||
for (key, value) in properties {
|
for (key, value) in merged {
|
||||||
switch value {
|
switch value {
|
||||||
case let s as String: converted[key] = s
|
case let s as String: converted[key] = s
|
||||||
case let i as Int: converted[key] = i
|
case let i as Int: converted[key] = i
|
||||||
@@ -44,10 +52,10 @@ enum AnalyticsTracker {
|
|||||||
}
|
}
|
||||||
Aptabase.shared.trackEvent(event, with: converted)
|
Aptabase.shared.trackEvent(event, with: converted)
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
if properties.isEmpty {
|
if converted.isEmpty {
|
||||||
NSLog("Analytics: %@", event)
|
NSLog("Analytics: %@", event)
|
||||||
} else {
|
} else {
|
||||||
let formatted = properties
|
let formatted = converted
|
||||||
.map { "\($0.key)=\($0.value)" }
|
.map { "\($0.key)=\($0.value)" }
|
||||||
.sorted()
|
.sorted()
|
||||||
.joined(separator: ", ")
|
.joined(separator: ", ")
|
||||||
|
|||||||
80
Cable/UsageMetrics.swift
Normal file
80
Cable/UsageMetrics.swift
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
//
|
||||||
|
// UsageMetrics.swift
|
||||||
|
// Cable
|
||||||
|
//
|
||||||
|
// Makes retention measurable although the analytics backend cannot identify a device across
|
||||||
|
// days: Aptabase derives its `user_id` from a hash of IP address + user agent plus a salt that
|
||||||
|
// rotates every 24 h, so events can never be linked to the previous day's events. Sessions
|
||||||
|
// expire after an hour of inactivity, so `session_id` cannot bridge days either.
|
||||||
|
//
|
||||||
|
// Instead of an identity, every tracked event carries this install's own tenure counters, which
|
||||||
|
// never leave the device in raw form — only the derived day counts are sent. Exact retention
|
||||||
|
// curves can then be reconstructed by *counting events* in the export:
|
||||||
|
//
|
||||||
|
// installs on a given day launch_no == 1 && tenure_days == 0
|
||||||
|
// installs active on day k dormant_days >= 1 && tenure_days == k
|
||||||
|
// D_k retention the latter / the former, k days earlier
|
||||||
|
//
|
||||||
|
// `dormant_days >= 1` holds for exactly one launch per calendar day, which is what makes the
|
||||||
|
// second line count installs rather than launches.
|
||||||
|
//
|
||||||
|
// Days are UTC day indices so they line up with the timestamps in the analytics export.
|
||||||
|
// Mirrors the Android `UsageMetrics` object, which reports into the same Aptabase project.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum UsageMetrics {
|
||||||
|
private enum Key {
|
||||||
|
static let installDay = "usage.installDay"
|
||||||
|
static let launchCount = "usage.launchCount"
|
||||||
|
static let activeDays = "usage.activeDays"
|
||||||
|
static let lastActiveDay = "usage.lastActiveDay"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Injection seams for tests; production always uses `.standard` and the real clock.
|
||||||
|
static var store: UserDefaults = .standard
|
||||||
|
static var clock: () -> Date = Date.init
|
||||||
|
|
||||||
|
/// Merged into every event by `AnalyticsTracker.log`. Empty until `beginLaunch` has run.
|
||||||
|
private(set) static var eventProps: [String: Any] = [:]
|
||||||
|
|
||||||
|
/// Advances the counters once per process start and freezes this launch's props.
|
||||||
|
///
|
||||||
|
/// `isFirstLaunch` is the app's own install marker (`hasLaunchedBefore`). Installs that
|
||||||
|
/// predate these counters have no known install date and report `tenure_days == -1` for the
|
||||||
|
/// rest of their life, so cohort analysis can exclude them instead of mistaking their first
|
||||||
|
/// instrumented launch for a fresh install.
|
||||||
|
static func beginLaunch(isFirstLaunch: Bool) {
|
||||||
|
let today = dayIndex(clock())
|
||||||
|
|
||||||
|
if isFirstLaunch, store.object(forKey: Key.installDay) == nil {
|
||||||
|
store.set(today, forKey: Key.installDay)
|
||||||
|
}
|
||||||
|
|
||||||
|
let launchCount = store.integer(forKey: Key.launchCount) + 1
|
||||||
|
store.set(launchCount, forKey: Key.launchCount)
|
||||||
|
|
||||||
|
// nil on the very first instrumented launch — reported as -1 ("no previous use"), which
|
||||||
|
// keeps it out of the `dormant_days >= 1` day-boundary count.
|
||||||
|
let lastActiveDay = store.object(forKey: Key.lastActiveDay) as? Int
|
||||||
|
let dormantDays = lastActiveDay.map { max(0, today - $0) } ?? -1
|
||||||
|
if lastActiveDay != today {
|
||||||
|
store.set(today, forKey: Key.lastActiveDay)
|
||||||
|
store.set(store.integer(forKey: Key.activeDays) + 1, forKey: Key.activeDays)
|
||||||
|
}
|
||||||
|
|
||||||
|
let installDay = store.object(forKey: Key.installDay) as? Int
|
||||||
|
eventProps = [
|
||||||
|
"tenure_days": installDay.map { max(0, today - $0) } ?? -1,
|
||||||
|
"launch_no": launchCount,
|
||||||
|
"active_days": store.integer(forKey: Key.activeDays),
|
||||||
|
"dormant_days": dormantDays,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whole days since the Unix epoch, in UTC.
|
||||||
|
static func dayIndex(_ date: Date) -> Int {
|
||||||
|
Int(floor(date.timeIntervalSince1970 / 86_400))
|
||||||
|
}
|
||||||
|
}
|
||||||
128
CableTests/UsageMetricsTests.swift
Normal file
128
CableTests/UsageMetricsTests.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,6 +122,8 @@ dependencies {
|
|||||||
// Installs the bundled baseline profile on devices that do not get it from Play.
|
// Installs the bundled baseline profile on devices that do not get it from Play.
|
||||||
implementation(libs.androidx.profileinstaller)
|
implementation(libs.androidx.profileinstaller)
|
||||||
|
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
|
||||||
// Consumes the profile produced by :baselineprofile.
|
// Consumes the profile produced by :baselineprofile.
|
||||||
baselineProfile(project(":baselineprofile"))
|
baselineProfile(project(":baselineprofile"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package app.voltplan.cable
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import app.voltplan.cable.analytics.Analytics
|
import app.voltplan.cable.analytics.Analytics
|
||||||
|
import app.voltplan.cable.analytics.UsageMetrics
|
||||||
import app.voltplan.cable.data.CableRepository
|
import app.voltplan.cable.data.CableRepository
|
||||||
import app.voltplan.cable.data.ReviewPrompt
|
import app.voltplan.cable.data.ReviewPrompt
|
||||||
import app.voltplan.cable.data.UnitSystemSettings
|
import app.voltplan.cable.data.UnitSystemSettings
|
||||||
@@ -26,6 +27,8 @@ class CableApplication : Application() {
|
|||||||
// Mirrors AppDelegate.application(_:didFinishLaunchingWithOptions:).
|
// Mirrors AppDelegate.application(_:didFinishLaunchingWithOptions:).
|
||||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||||
val isFirstLaunch = settings.consumeFirstLaunch()
|
val isFirstLaunch = settings.consumeFirstLaunch()
|
||||||
|
// Before the first log call: every event carries this launch's tenure counters.
|
||||||
|
UsageMetrics.beginLaunch(this@CableApplication, isFirstLaunch)
|
||||||
if (isFirstLaunch) {
|
if (isFirstLaunch) {
|
||||||
Analytics.log("First Launch")
|
Analytics.log("First Launch")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,17 +78,19 @@ object Analytics {
|
|||||||
|
|
||||||
/** Tracks an event. [properties] values are coerced to String/Number/Boolean like the iOS tracker. */
|
/** Tracks an event. [properties] values are coerced to String/Number/Boolean like the iOS tracker. */
|
||||||
fun log(event: String, properties: Map<String, Any?> = emptyMap()) {
|
fun log(event: String, properties: Map<String, Any?> = emptyMap()) {
|
||||||
|
// Tenure counters first so an explicit property of the same name would win.
|
||||||
|
val merged = UsageMetrics.eventProps + properties
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
if (properties.isEmpty()) {
|
if (merged.isEmpty()) {
|
||||||
Log.d(TAG, "Analytics: $event")
|
Log.d(TAG, "Analytics: $event")
|
||||||
} else {
|
} else {
|
||||||
val formatted = properties.entries.sortedBy { it.key }
|
val formatted = merged.entries.sortedBy { it.key }
|
||||||
.joinToString(", ") { "${it.key}=${it.value}" }
|
.joinToString(", ") { "${it.key}=${it.value}" }
|
||||||
Log.d(TAG, "Analytics: $event { $formatted }")
|
Log.d(TAG, "Analytics: $event { $formatted }")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val props = buildJsonObject {
|
val props = buildJsonObject {
|
||||||
for ((key, value) in properties) {
|
for ((key, value) in merged) {
|
||||||
when (value) {
|
when (value) {
|
||||||
null -> {}
|
null -> {}
|
||||||
is String -> put(key, value)
|
is String -> put(key, value)
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package app.voltplan.cable.analytics
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
|
import app.voltplan.cable.data.dataStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes retention measurable although the analytics backend cannot identify a device across days:
|
||||||
|
* Aptabase derives its `user_id` from a hash of IP address + user agent plus a salt that rotates
|
||||||
|
* every 24 h, so events can never be linked to the previous day's events. Sessions expire after an
|
||||||
|
* hour of inactivity, so `sessionId` cannot bridge days either.
|
||||||
|
*
|
||||||
|
* Instead of an identity, every tracked event carries this install's own tenure counters, which
|
||||||
|
* never leave the device in raw form — only the derived day counts are sent. Exact retention
|
||||||
|
* curves can then be reconstructed by *counting events* in the export:
|
||||||
|
*
|
||||||
|
* installs on a given day launch_no == 1 && tenure_days == 0
|
||||||
|
* installs active on day k dormant_days >= 1 && tenure_days == k
|
||||||
|
* D_k retention the latter / the former, k days earlier
|
||||||
|
*
|
||||||
|
* `dormant_days >= 1` holds for exactly one launch per calendar day, which is what makes the
|
||||||
|
* second line count installs rather than launches.
|
||||||
|
*
|
||||||
|
* Days are UTC day indices so they line up with the timestamps in the analytics export.
|
||||||
|
* Mirrors the iOS `UsageMetrics` enum, which reports into the same Aptabase project. The counter
|
||||||
|
* arithmetic lives in the pure [advance] so it can be tested without an Android context; iOS tests
|
||||||
|
* the same rules through its injectable `UserDefaults`.
|
||||||
|
*/
|
||||||
|
object UsageMetrics {
|
||||||
|
private val INSTALL_DAY = intPreferencesKey("usage.installDay")
|
||||||
|
private val LAUNCH_COUNT = intPreferencesKey("usage.launchCount")
|
||||||
|
private val ACTIVE_DAYS = intPreferencesKey("usage.activeDays")
|
||||||
|
private val LAST_ACTIVE_DAY = intPreferencesKey("usage.lastActiveDay")
|
||||||
|
|
||||||
|
private const val DAY_MS = 86_400_000L
|
||||||
|
|
||||||
|
/** Persisted counters. `installDay` and `lastActiveDay` are null until the first launch. */
|
||||||
|
internal data class State(
|
||||||
|
val installDay: Int? = null,
|
||||||
|
val launchCount: Int = 0,
|
||||||
|
val activeDays: Int = 0,
|
||||||
|
val lastActiveDay: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal data class Launch(val state: State, val props: Map<String, Any>)
|
||||||
|
|
||||||
|
/** Merged into every event by [Analytics.log]. Empty until [beginLaunch] has run. */
|
||||||
|
@Volatile
|
||||||
|
var eventProps: Map<String, Any> = emptyMap()
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advances the counters once per process start and freezes this launch's props.
|
||||||
|
*
|
||||||
|
* [isFirstLaunch] is the app's own install marker (`hasLaunchedBefore`, consumed by
|
||||||
|
* `UnitSystemSettings.consumeFirstLaunch`). Installs that predate these counters have no known
|
||||||
|
* install date and report `tenure_days == -1` for the rest of their life, so cohort analysis
|
||||||
|
* can exclude them instead of mistaking their first instrumented launch for a fresh install.
|
||||||
|
*/
|
||||||
|
suspend fun beginLaunch(
|
||||||
|
context: Context,
|
||||||
|
isFirstLaunch: Boolean,
|
||||||
|
nowMillis: Long = System.currentTimeMillis(),
|
||||||
|
) {
|
||||||
|
val today = dayIndex(nowMillis)
|
||||||
|
var props: Map<String, Any> = emptyMap()
|
||||||
|
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
val launch = advance(
|
||||||
|
State(
|
||||||
|
installDay = prefs[INSTALL_DAY],
|
||||||
|
launchCount = prefs[LAUNCH_COUNT] ?: 0,
|
||||||
|
activeDays = prefs[ACTIVE_DAYS] ?: 0,
|
||||||
|
lastActiveDay = prefs[LAST_ACTIVE_DAY],
|
||||||
|
),
|
||||||
|
isFirstLaunch,
|
||||||
|
today,
|
||||||
|
)
|
||||||
|
launch.state.installDay?.let { prefs[INSTALL_DAY] = it }
|
||||||
|
prefs[LAUNCH_COUNT] = launch.state.launchCount
|
||||||
|
prefs[ACTIVE_DAYS] = launch.state.activeDays
|
||||||
|
launch.state.lastActiveDay?.let { prefs[LAST_ACTIVE_DAY] = it }
|
||||||
|
props = launch.props
|
||||||
|
}
|
||||||
|
|
||||||
|
eventProps = props
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pure counter arithmetic: the new state plus the props this launch reports. */
|
||||||
|
internal fun advance(state: State, isFirstLaunch: Boolean, today: Int): Launch {
|
||||||
|
val installDay = state.installDay ?: today.takeIf { isFirstLaunch }
|
||||||
|
val launchCount = state.launchCount + 1
|
||||||
|
|
||||||
|
// null on the very first instrumented launch — reported as -1 ("no previous use"), which
|
||||||
|
// keeps it out of the `dormant_days >= 1` day-boundary count.
|
||||||
|
val dormantDays = state.lastActiveDay?.let { maxOf(0, today - it) } ?: -1
|
||||||
|
val isNewDay = state.lastActiveDay != today
|
||||||
|
val activeDays = if (isNewDay) state.activeDays + 1 else state.activeDays
|
||||||
|
|
||||||
|
return Launch(
|
||||||
|
State(installDay, launchCount, activeDays, if (isNewDay) today else state.lastActiveDay),
|
||||||
|
mapOf(
|
||||||
|
"tenure_days" to (installDay?.let { maxOf(0, today - it) } ?: -1),
|
||||||
|
"launch_no" to launchCount,
|
||||||
|
"active_days" to activeDays,
|
||||||
|
"dormant_days" to dormantDays,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whole days since the Unix epoch, in UTC. */
|
||||||
|
fun dayIndex(epochMillis: Long): Int = Math.floorDiv(epochMillis, DAY_MS).toInt()
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package app.voltplan.cable.analytics
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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. Mirrors `CableTests/UsageMetricsTests.swift`.
|
||||||
|
*/
|
||||||
|
class UsageMetricsTest {
|
||||||
|
|
||||||
|
private fun props(vararg pairs: Pair<String, Any>) = mapOf(*pairs)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun firstLaunchOfANewInstallStartsTheCounters() {
|
||||||
|
val launch = UsageMetrics.advance(UsageMetrics.State(), isFirstLaunch = true, today = 20_000)
|
||||||
|
assertEquals(
|
||||||
|
props("tenure_days" to 0, "launch_no" to 1, "active_days" to 1, "dormant_days" to -1),
|
||||||
|
launch.props,
|
||||||
|
)
|
||||||
|
assertEquals(UsageMetrics.State(20_000, 1, 1, 20_000), launch.state)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun furtherLaunchesOnTheSameDayDoNotCountAsANewActiveDay() {
|
||||||
|
var state = UsageMetrics.State()
|
||||||
|
var props: Map<String, Any> = emptyMap()
|
||||||
|
repeat(3) { index ->
|
||||||
|
val launch = UsageMetrics.advance(state, isFirstLaunch = index == 0, today = 20_000)
|
||||||
|
state = launch.state
|
||||||
|
props = launch.props
|
||||||
|
}
|
||||||
|
assertEquals(
|
||||||
|
props("tenure_days" to 0, "launch_no" to 3, "active_days" to 1, "dormant_days" to 0),
|
||||||
|
props,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tenureAndActiveDaysAdvanceAcrossDays() {
|
||||||
|
var launch = UsageMetrics.advance(UsageMetrics.State(), isFirstLaunch = true, today = 20_000)
|
||||||
|
launch = UsageMetrics.advance(launch.state, isFirstLaunch = false, today = 20_001)
|
||||||
|
assertEquals(
|
||||||
|
props("tenure_days" to 1, "launch_no" to 2, "active_days" to 2, "dormant_days" to 1),
|
||||||
|
launch.props,
|
||||||
|
)
|
||||||
|
launch = UsageMetrics.advance(launch.state, isFirstLaunch = false, today = 20_007)
|
||||||
|
assertEquals(
|
||||||
|
props("tenure_days" to 7, "launch_no" to 3, "active_days" to 3, "dormant_days" to 6),
|
||||||
|
launch.props,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
fun exactlyOneLaunchPerDayMarksTheDayBoundary() {
|
||||||
|
var state = UsageMetrics.State()
|
||||||
|
var installMarkers = 0
|
||||||
|
var dayMarkers = 0
|
||||||
|
var isFirst = true
|
||||||
|
var props: Map<String, Any> = emptyMap()
|
||||||
|
for (day in 20_000..20_004) {
|
||||||
|
repeat(3) {
|
||||||
|
val launch = UsageMetrics.advance(state, isFirst, day)
|
||||||
|
isFirst = false
|
||||||
|
state = launch.state
|
||||||
|
props = launch.props
|
||||||
|
if (props["launch_no"] == 1) installMarkers++
|
||||||
|
if ((props["dormant_days"] as Int) >= 1) dayMarkers++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals(1, installMarkers)
|
||||||
|
// Day 0 is counted by the install marker, so the boundary marks days 1..4.
|
||||||
|
assertEquals(4, dayMarkers)
|
||||||
|
assertEquals(5, props["active_days"])
|
||||||
|
assertEquals(15, props["launch_no"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
fun installsPredatingTheCountersReportUnknownTenure() {
|
||||||
|
var launch = UsageMetrics.advance(UsageMetrics.State(), isFirstLaunch = false, today = 20_000)
|
||||||
|
assertEquals(
|
||||||
|
props("tenure_days" to -1, "launch_no" to 1, "active_days" to 1, "dormant_days" to -1),
|
||||||
|
launch.props,
|
||||||
|
)
|
||||||
|
launch = UsageMetrics.advance(launch.state, isFirstLaunch = false, today = 20_003)
|
||||||
|
assertEquals(
|
||||||
|
props("tenure_days" to -1, "launch_no" to 2, "active_days" to 2, "dormant_days" to 3),
|
||||||
|
launch.props,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aBackwardsClockNeverProducesNegativeCounts() {
|
||||||
|
val first = UsageMetrics.advance(UsageMetrics.State(), isFirstLaunch = true, today = 20_010)
|
||||||
|
val second = UsageMetrics.advance(first.state, isFirstLaunch = false, today = 20_002)
|
||||||
|
assertEquals(0, second.props["tenure_days"])
|
||||||
|
assertEquals(0, second.props["dormant_days"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dayIndexIsAUtcDayCount() {
|
||||||
|
assertEquals(0, UsageMetrics.dayIndex(0L))
|
||||||
|
assertEquals(0, UsageMetrics.dayIndex(86_399_000L))
|
||||||
|
assertEquals(1, UsageMetrics.dayIndex(86_400_000L))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ play-review-ktx = { group = "com.google.android.play", name = "review-ktx", vers
|
|||||||
androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "profileinstaller" }
|
androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "profileinstaller" }
|
||||||
androidx-benchmark-macro-junit4 = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "benchmark" }
|
androidx-benchmark-macro-junit4 = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "benchmark" }
|
||||||
androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version = "1.2.1" }
|
androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version = "1.2.1" }
|
||||||
|
junit = { group = "junit", name = "junit", version = "4.13.2" }
|
||||||
androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version = "2.3.0" }
|
androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version = "2.3.0" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
|
|||||||
Reference in New Issue
Block a user