Skip to content

Commit 90451f9

Browse files
committed
Adding retention attributed metric
1 parent 287bc5e commit 90451f9

File tree

4 files changed

+536
-0
lines changed

4 files changed

+536
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.app.attributed.metrics
18+
19+
import com.duckduckgo.app.attributed.metrics.api.AttributedMetric
20+
import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient
21+
import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDateUtils
22+
import com.duckduckgo.app.di.AppCoroutineScope
23+
import com.duckduckgo.app.statistics.api.AtbLifecyclePlugin
24+
import com.duckduckgo.browser.api.install.AppInstall
25+
import com.duckduckgo.common.utils.DispatcherProvider
26+
import com.duckduckgo.di.scopes.AppScope
27+
import com.squareup.anvil.annotations.ContributesMultibinding
28+
import dagger.SingleInstanceIn
29+
import kotlinx.coroutines.CoroutineScope
30+
import kotlinx.coroutines.launch
31+
import logcat.logcat
32+
import javax.inject.Inject
33+
34+
@ContributesMultibinding(AppScope::class, AtbLifecyclePlugin::class)
35+
@ContributesMultibinding(AppScope::class, AttributedMetric::class)
36+
@SingleInstanceIn(AppScope::class)
37+
class RetentionMonthAttributedMetric @Inject constructor(
38+
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
39+
private val dispatcherProvider: DispatcherProvider,
40+
private val appInstall: AppInstall,
41+
private val attributedMetricClient: AttributedMetricClient,
42+
private val dateUtils: AttributedMetricsDateUtils,
43+
) : AttributedMetric, AtbLifecyclePlugin {
44+
45+
companion object {
46+
private const val PIXEL_NAME_FIRST_MONTH = "user_retention_month"
47+
private const val DAYS_IN_4_WEEKS = 28 // we consider 1 month after 4 weeks
48+
private const val FIRST_MONTH_DAY_THRESHOLD = DAYS_IN_4_WEEKS + 1
49+
}
50+
51+
override fun onAppRetentionAtbRefreshed(
52+
oldAtb: String,
53+
newAtb: String,
54+
) {
55+
appCoroutineScope.launch(dispatcherProvider.io()) {
56+
if (oldAtb == newAtb) {
57+
logcat(tag = "AttributedMetrics") {
58+
"RetentionMonth: Skip emitting atb not changed"
59+
}
60+
return@launch
61+
}
62+
if (shouldSendPixel().not()) {
63+
logcat(tag = "AttributedMetrics") {
64+
"RetentionMonth: Skip emitting, outside window"
65+
}
66+
return@launch
67+
}
68+
attributedMetricClient.emitMetric(this@RetentionMonthAttributedMetric)
69+
}
70+
}
71+
72+
override fun getPixelName(): String = PIXEL_NAME_FIRST_MONTH
73+
74+
override suspend fun getMetricParameters(): Map<String, String> {
75+
val days = daysSinceInstalled()
76+
if (days < FIRST_MONTH_DAY_THRESHOLD) return emptyMap()
77+
78+
val week = getPeriod(days)
79+
return if (week > 0) {
80+
mutableMapOf("count" to week.toString())
81+
} else {
82+
emptyMap()
83+
}
84+
}
85+
86+
override suspend fun getTag(): String {
87+
val days = daysSinceInstalled()
88+
return getPeriod(days).toString()
89+
}
90+
91+
private fun shouldSendPixel(): Boolean {
92+
val days = daysSinceInstalled()
93+
if (days < FIRST_MONTH_DAY_THRESHOLD) return false
94+
95+
return true
96+
}
97+
98+
private fun getPeriod(day: Int): Int {
99+
if (day < FIRST_MONTH_DAY_THRESHOLD) return 0
100+
return ((day - FIRST_MONTH_DAY_THRESHOLD) / DAYS_IN_4_WEEKS) + 1
101+
}
102+
103+
private fun daysSinceInstalled(): Int {
104+
return dateUtils.daysSince(appInstall.getInstallationTimestamp())
105+
}
106+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.app.attributed.metrics
18+
19+
import com.duckduckgo.app.attributed.metrics.api.AttributedMetric
20+
import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient
21+
import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDateUtils
22+
import com.duckduckgo.app.di.AppCoroutineScope
23+
import com.duckduckgo.app.statistics.api.AtbLifecyclePlugin
24+
import com.duckduckgo.browser.api.install.AppInstall
25+
import com.duckduckgo.common.utils.DispatcherProvider
26+
import com.duckduckgo.di.scopes.AppScope
27+
import com.squareup.anvil.annotations.ContributesMultibinding
28+
import dagger.SingleInstanceIn
29+
import kotlinx.coroutines.CoroutineScope
30+
import kotlinx.coroutines.launch
31+
import logcat.logcat
32+
import javax.inject.Inject
33+
34+
@ContributesMultibinding(AppScope::class, AtbLifecyclePlugin::class)
35+
@ContributesMultibinding(AppScope::class, AttributedMetric::class)
36+
@SingleInstanceIn(AppScope::class)
37+
class RetentionWeekAttributedMetric @Inject constructor(
38+
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
39+
private val dispatcherProvider: DispatcherProvider,
40+
private val appInstall: AppInstall,
41+
private val attributedMetricClient: AttributedMetricClient,
42+
private val dateUtils: AttributedMetricsDateUtils,
43+
) : AttributedMetric, AtbLifecyclePlugin {
44+
45+
companion object {
46+
private const val PIXEL_NAME_FIRST_MONTH = "user_retention_week"
47+
}
48+
49+
override fun onAppRetentionAtbRefreshed(
50+
oldAtb: String,
51+
newAtb: String,
52+
) {
53+
appCoroutineScope.launch(dispatcherProvider.io()) {
54+
if (oldAtb == newAtb) {
55+
logcat(tag = "AttributedMetrics") {
56+
"RetentionFirstMonth: Skip emitting atb not changed"
57+
}
58+
return@launch
59+
}
60+
if (shouldSendPixel().not()) {
61+
logcat(tag = "AttributedMetrics") {
62+
"RetentionFirstMonth: Skip emitting, outside window"
63+
}
64+
return@launch
65+
}
66+
attributedMetricClient.emitMetric(this@RetentionWeekAttributedMetric)
67+
}
68+
}
69+
70+
override fun getPixelName(): String = PIXEL_NAME_FIRST_MONTH
71+
72+
override suspend fun getMetricParameters(): Map<String, String> {
73+
val days = daysSinceInstalled()
74+
if (days <= 0 || days >= 29) return emptyMap()
75+
76+
val week = getWeekFromDays(days)
77+
return if (week > 0) {
78+
mutableMapOf("count" to week.toString())
79+
} else {
80+
emptyMap()
81+
}
82+
}
83+
84+
override suspend fun getTag(): String {
85+
val days = daysSinceInstalled()
86+
return getWeekFromDays(days).toString()
87+
}
88+
89+
private fun shouldSendPixel(): Boolean {
90+
val days = daysSinceInstalled()
91+
if (days <= 0 || days >= 29) return false
92+
93+
return true
94+
}
95+
96+
private fun getWeekFromDays(days: Int): Int {
97+
return when (days) {
98+
in 1..7 -> 1
99+
in 8..14 -> 2
100+
in 15..21 -> 3
101+
in 22..28 -> 4
102+
else -> -1
103+
}
104+
}
105+
106+
private fun daysSinceInstalled(): Int {
107+
return dateUtils.daysSince(appInstall.getInstallationTimestamp())
108+
}
109+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.app.attributed.metrics
18+
19+
import androidx.test.ext.junit.runners.AndroidJUnit4
20+
import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient
21+
import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDateUtils
22+
import com.duckduckgo.browser.api.install.AppInstall
23+
import com.duckduckgo.common.test.CoroutineTestRule
24+
import kotlinx.coroutines.test.runTest
25+
import org.junit.Assert.assertEquals
26+
import org.junit.Before
27+
import org.junit.Rule
28+
import org.junit.Test
29+
import org.junit.runner.RunWith
30+
import org.mockito.kotlin.mock
31+
import org.mockito.kotlin.never
32+
import org.mockito.kotlin.verify
33+
import org.mockito.kotlin.whenever
34+
35+
@RunWith(AndroidJUnit4::class)
36+
class RetentionMonthAttributedMetricTest {
37+
38+
@get:Rule
39+
val coroutineRule = CoroutineTestRule()
40+
41+
private val attributedMetricClient: AttributedMetricClient = mock()
42+
private val appInstall: AppInstall = mock()
43+
private val dateUtils: AttributedMetricsDateUtils = mock()
44+
45+
private lateinit var testee: RetentionMonthAttributedMetric
46+
47+
@Before
48+
fun setup() {
49+
testee = RetentionMonthAttributedMetric(
50+
appCoroutineScope = coroutineRule.testScope,
51+
dispatcherProvider = coroutineRule.testDispatcherProvider,
52+
appInstall = appInstall,
53+
attributedMetricClient = attributedMetricClient,
54+
dateUtils = dateUtils,
55+
)
56+
}
57+
58+
@Test
59+
fun whenPixelNameRequestedThenReturnCorrectName() {
60+
assertEquals("user_retention_month", testee.getPixelName())
61+
}
62+
63+
@Test
64+
fun whenAtbNotChangedThenDoNotEmitMetric() = runTest {
65+
testee.onAppRetentionAtbRefreshed("atb", "atb")
66+
67+
verify(attributedMetricClient, never()).emitMetric(testee)
68+
}
69+
70+
@Test
71+
fun whenAppOpensAndDaysLessThan29ThenDoNotEmitMetric() = runTest {
72+
givenDaysSinceInstalled(28)
73+
74+
testee.onAppRetentionAtbRefreshed("old", "new")
75+
76+
verify(attributedMetricClient, never()).emitMetric(testee)
77+
}
78+
79+
@Test
80+
fun whenAppOpensAndDaysIs29ThenEmitMetric() = runTest {
81+
givenDaysSinceInstalled(29)
82+
83+
testee.onAppRetentionAtbRefreshed("old", "new")
84+
85+
verify(attributedMetricClient).emitMetric(testee)
86+
}
87+
88+
@Test
89+
fun whenDaysLessThan29ThenReturnEmptyParameters() = runTest {
90+
givenDaysSinceInstalled(28)
91+
92+
val params = testee.getMetricParameters()
93+
94+
assertEquals(emptyMap<String, String>(), params)
95+
}
96+
97+
@Test
98+
fun whenDaysInstalledThenReturnCorrectPeriod() = runTest {
99+
// Map of days installed to expected period number
100+
val periodRanges = mapOf(
101+
28 to 0, // Day 28 -> No period (empty map)
102+
29 to 1, // Day 29 -> Period 1 (first month)
103+
45 to 1, // Day 45 -> Period 1 (still first month)
104+
57 to 2, // Day 57 -> Period 2 (second month)
105+
85 to 3, // Day 85 -> Period 3 (third month)
106+
113 to 4, // Day 113 -> Period 4 (fourth month)
107+
141 to 5, // Day 141 -> Period 5 (fifth month)
108+
169 to 6, // Day 169 -> Period 6 (sixth month)
109+
197 to 7, // Day 197 -> Period 7 (seventh month)
110+
)
111+
112+
periodRanges.forEach { (days, expectedPeriod) ->
113+
givenDaysSinceInstalled(days)
114+
115+
val params = testee.getMetricParameters()
116+
117+
val expectedParams = if (expectedPeriod > 0) {
118+
mapOf("count" to expectedPeriod.toString())
119+
} else {
120+
emptyMap()
121+
}
122+
123+
assertEquals(
124+
"For $days days installed, should return period $expectedPeriod",
125+
expectedParams,
126+
params,
127+
)
128+
}
129+
}
130+
131+
@Test
132+
fun whenDaysInstalledThenReturnCorrectTag() = runTest {
133+
// Test different days and expected period numbers
134+
val testCases = mapOf(
135+
28 to "0", // Day 28 -> No period
136+
29 to "1", // Day 29 -> Period 1
137+
57 to "2", // Day 57 -> Period 2
138+
85 to "3", // Day 85 -> Period 3
139+
113 to "4", // Day 113 -> Period 4
140+
141 to "5", // Day 141 -> Period 5
141+
)
142+
143+
testCases.forEach { (days, expectedTag) ->
144+
givenDaysSinceInstalled(days)
145+
146+
val tag = testee.getTag()
147+
148+
assertEquals(
149+
"For $days days installed, should return tag $expectedTag",
150+
expectedTag,
151+
tag,
152+
)
153+
}
154+
}
155+
156+
private fun givenDaysSinceInstalled(days: Int) {
157+
whenever(appInstall.getInstallationTimestamp()).thenReturn(123L)
158+
whenever(dateUtils.daysSince(123L)).thenReturn(days)
159+
}
160+
}

0 commit comments

Comments
 (0)