Skip to content

Commit c806241

Browse files
committed
Subscription status attributed metric implementation
1 parent d3ce0d5 commit c806241

File tree

6 files changed

+546
-0
lines changed

6 files changed

+546
-0
lines changed

subscriptions/subscriptions-impl/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ dependencies {
6262
implementation project(':content-scope-scripts-api')
6363
implementation project(':duckchat-api')
6464
implementation project(':pir-api')
65+
implementation project(':attributed-metrics-api')
6566

6667
implementation AndroidX.appCompat
6768
implementation KotlinX.coroutines.core
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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.subscriptions.impl.metrics
18+
19+
import androidx.lifecycle.LifecycleOwner
20+
import com.duckduckgo.app.attributed.metrics.api.AttributedMetric
21+
import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient
22+
import com.duckduckgo.app.attributed.metrics.api.AttributedMetricConfig
23+
import com.duckduckgo.app.attributed.metrics.api.MetricBucket
24+
import com.duckduckgo.app.di.AppCoroutineScope
25+
import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver
26+
import com.duckduckgo.common.utils.DispatcherProvider
27+
import com.duckduckgo.di.scopes.AppScope
28+
import com.duckduckgo.subscriptions.api.SubscriptionStatus
29+
import com.duckduckgo.subscriptions.impl.repository.AuthRepository
30+
import com.squareup.anvil.annotations.ContributesMultibinding
31+
import dagger.SingleInstanceIn
32+
import kotlinx.coroutines.CoroutineScope
33+
import kotlinx.coroutines.CoroutineStart.LAZY
34+
import kotlinx.coroutines.Deferred
35+
import kotlinx.coroutines.async
36+
import kotlinx.coroutines.launch
37+
import java.time.Instant
38+
import java.time.ZoneId
39+
import java.time.temporal.ChronoUnit
40+
import javax.inject.Inject
41+
42+
@ContributesMultibinding(AppScope::class, AttributedMetric::class)
43+
@ContributesMultibinding(AppScope::class, MainProcessLifecycleObserver::class)
44+
@SingleInstanceIn(AppScope::class)
45+
class SubscriptionStatusAttributedMetric @Inject constructor(
46+
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
47+
private val dispatcherProvider: DispatcherProvider,
48+
private val attributedMetricClient: AttributedMetricClient,
49+
private val authRepository: AuthRepository,
50+
private val attributedMetricConfig: AttributedMetricConfig,
51+
) : AttributedMetric, MainProcessLifecycleObserver {
52+
53+
companion object {
54+
private const val PIXEL_NAME = "user_subscribed"
55+
private const val FEATURE_TOGGLE_NAME = "subscriptionRetention"
56+
private const val FEATURE_EMIT_TOGGLE_NAME = "canEmitSubscriptionRetention"
57+
}
58+
59+
private val isEnabled: Deferred<Boolean> = appCoroutineScope.async(start = LAZY) {
60+
getToggle(FEATURE_TOGGLE_NAME)?.isEnabled() ?: false
61+
}
62+
63+
private val canEmit: Deferred<Boolean> = appCoroutineScope.async(start = LAZY) {
64+
getToggle(FEATURE_EMIT_TOGGLE_NAME)?.isEnabled() ?: false
65+
}
66+
67+
private val bucketConfig: Deferred<MetricBucket> = appCoroutineScope.async(start = LAZY) {
68+
attributedMetricConfig.getBucketConfiguration()[FEATURE_TOGGLE_NAME] ?: MetricBucket(
69+
buckets = listOf(0, 1),
70+
version = 0,
71+
)
72+
}
73+
74+
override fun onCreate(owner: LifecycleOwner) {
75+
appCoroutineScope.launch(dispatcherProvider.io()) {
76+
if (!isEnabled.await() || !canEmit.await()) {
77+
return@launch
78+
}
79+
if (shouldSendPixel()) {
80+
attributedMetricClient.emitMetric(
81+
this@SubscriptionStatusAttributedMetric,
82+
)
83+
}
84+
}
85+
}
86+
87+
override fun getPixelName(): String = PIXEL_NAME
88+
89+
override suspend fun getMetricParameters(): Map<String, String> {
90+
val daysSinceSubscribed = daysSinceSubscribed()
91+
val isOnTrial = authRepository.isFreeTrialActive()
92+
val params = mutableMapOf(
93+
"month" to getBucketValue(daysSinceSubscribed, isOnTrial).toString(),
94+
)
95+
96+
return params
97+
}
98+
99+
override suspend fun getTag(): String {
100+
val daysSinceSubscribed = daysSinceSubscribed()
101+
val isOnTrial = authRepository.isFreeTrialActive()
102+
return getBucketValue(daysSinceSubscribed, isOnTrial).toString()
103+
}
104+
105+
private suspend fun shouldSendPixel(): Boolean {
106+
val isActive = isSubscriptionActive()
107+
return isActive
108+
}
109+
110+
private suspend fun isSubscriptionActive(): Boolean {
111+
return authRepository.getStatus() == SubscriptionStatus.AUTO_RENEWABLE ||
112+
authRepository.getStatus() == SubscriptionStatus.NOT_AUTO_RENEWABLE
113+
}
114+
115+
private suspend fun daysSinceSubscribed(): Int {
116+
// TODO: validate if startedAt is the correct field to use
117+
return authRepository.getSubscriptionEnrollmentDate()?.let { nonNullStartedAt ->
118+
val etZone = ZoneId.of("America/New_York")
119+
val installInstant = Instant.ofEpochMilli(nonNullStartedAt)
120+
val nowInstant = Instant.now()
121+
122+
val installInEt = installInstant.atZone(etZone)
123+
val nowInEt = nowInstant.atZone(etZone)
124+
125+
return ChronoUnit.DAYS.between(installInEt.toLocalDate(), nowInEt.toLocalDate()).toInt()
126+
} ?: 0
127+
}
128+
129+
private suspend fun getBucketValue(
130+
days: Int,
131+
isOnTrial: Boolean,
132+
): Int {
133+
if (isOnTrial) {
134+
return 0
135+
}
136+
137+
// Calculate which month the user is in (1-based)
138+
// Each 28 days is a new month
139+
val monthNumber = days / 28 + 1
140+
141+
// Get the bucket configuration
142+
val buckets = bucketConfig.await().buckets
143+
return buckets.indexOfFirst { bucket -> monthNumber <= bucket }.let { index ->
144+
if (index == -1) buckets.size else index
145+
}
146+
}
147+
148+
private suspend fun getToggle(toggleName: String) =
149+
attributedMetricConfig.metricsToggles().firstOrNull { toggle ->
150+
toggle.featureName().name == toggleName
151+
}
152+
}

subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/AuthRepository.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ interface AuthRepository {
6464
suspend fun canSupportEncryption(): Boolean
6565
suspend fun setFeatures(basePlanId: String, features: Set<String>)
6666
suspend fun getFeatures(basePlanId: String): Set<String>
67+
suspend fun isFreeTrialActive(): Boolean
68+
suspend fun getSubscriptionEnrollmentDate(): Long?
6769
}
6870

6971
@Module
@@ -233,6 +235,14 @@ internal class RealAuthRepository constructor(
233235
val accessToken = subscriptionsDataStore.run { accessTokenV2 ?: accessToken }
234236
serpPromo.injectCookie(accessToken)
235237
}
238+
239+
override suspend fun isFreeTrialActive(): Boolean {
240+
return subscriptionsDataStore.freeTrialActive
241+
}
242+
243+
override suspend fun getSubscriptionEnrollmentDate(): Long? {
244+
return subscriptionsDataStore.startedAt
245+
}
236246
}
237247

238248
data class AccessToken(

0 commit comments

Comments
 (0)