Skip to content

Commit ec07123

Browse files
authored
Attributed metrics module and skeleton (#6887)
Task/Issue URL: https://app.asana.com/1/137249556945/project/1202552961248957/task/1211500171306010?focus=true ### Description Scope - Create attributed-metrics module - Add database to store events - Connect with FF: enable/disable client - Connect with Atb, privacy config - Handles initialization and returning users checks - Handles expiry date to disable client ### Steps to test this PR While testing this PR you need to add the following in your logcat filter: `tag~:"AttributedMetrics"` _Feature 1_ - [x] We need fresh install (including removing DDG folder in file system) - [x] open the app - [x] wait for privacy config download, since we need atb init - [x] Detected new install not reinstall Log: `New install detected, attributed metrics active` - [x] Log: `Client status running: true...` - [x] Skip onboarding and go to browser - [x] Access feature flag inventory - [x] Disable `attributedMetrics` FF - [x] restart the app - [x] See in the log `Privacy config downloaded, attributed metrics enabled: false` (that should disable the client) - [x] and log `Client status running: false -> isActive: true, isEnabled: false...` _Feature 2_ - [x] fresh install but keep DDG folder - [x] open the app - [x] wait for privacy config download, since we need atb init - [x] check logs for `App reinstall detected, attributed metrics will not be active` - [x] and `Client status running: false -> isActive: false, isEnabled: true...` ### UI changes | Before | After | | ------ | ----- | !(Upload before screenshot)|(Upload after screenshot)|
1 parent 21894a2 commit ec07123

File tree

76 files changed

+6680
-6
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

76 files changed

+6680
-6
lines changed

ad-click/ad-click-impl/build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ dependencies {
4848
implementation project(path: ':privacy-config-api')
4949
implementation project(path: ':feature-toggles-api')
5050
implementation project(path: ':app-build-config-api')
51+
implementation project(path: ':attributed-metrics-api')
5152

5253
implementation AndroidX.core.ktx
5354

@@ -97,6 +98,7 @@ dependencies {
9798
testImplementation Testing.robolectric
9899

99100
testImplementation project(path: ':common-test')
101+
testImplementation project(path: ':feature-toggles-test')
100102

101103
coreLibraryDesugaring Android.tools.desugarJdkLibs
102104
}

ad-click/ad-click-impl/src/main/java/com/duckduckgo/adclick/impl/DuckDuckGoAdClickManager.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package com.duckduckgo.adclick.impl
1818

1919
import com.duckduckgo.adclick.api.AdClickManager
20+
import com.duckduckgo.adclick.impl.metrics.AdClickCollector
2021
import com.duckduckgo.adclick.impl.pixels.AdClickPixelName
2122
import com.duckduckgo.adclick.impl.pixels.AdClickPixels
2223
import com.duckduckgo.app.browser.UriString
@@ -33,6 +34,7 @@ class DuckDuckGoAdClickManager @Inject constructor(
3334
private val adClickData: AdClickData,
3435
private val adClickAttribution: AdClickAttribution,
3536
private val adClickPixels: AdClickPixels,
37+
private val adClickCollector: AdClickCollector,
3638
) : AdClickManager {
3739

3840
private val publicSuffixDatabase = PublicSuffixDatabase()
@@ -223,6 +225,7 @@ class DuckDuckGoAdClickManager @Inject constructor(
223225
exemptionDeadline = System.currentTimeMillis() + adClickAttribution.getTotalExpirationMillis(),
224226
),
225227
)
228+
adClickCollector.onAdClick()
226229
adClickPixels.fireAdClickDetectedPixel(
227230
savedAdDomain = savedAdDomain,
228231
urlAdDomain = urlAdDomain,
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
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.adclick.impl.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.api.AttributedMetricConfig
22+
import com.duckduckgo.app.attributed.metrics.api.EventStats
23+
import com.duckduckgo.app.attributed.metrics.api.MetricBucket
24+
import com.duckduckgo.app.di.AppCoroutineScope
25+
import com.duckduckgo.browser.api.install.AppInstall
26+
import com.duckduckgo.common.utils.DispatcherProvider
27+
import com.duckduckgo.di.scopes.AppScope
28+
import com.squareup.anvil.annotations.ContributesBinding
29+
import com.squareup.anvil.annotations.ContributesMultibinding
30+
import dagger.SingleInstanceIn
31+
import kotlinx.coroutines.CoroutineScope
32+
import kotlinx.coroutines.CoroutineStart.LAZY
33+
import kotlinx.coroutines.Deferred
34+
import kotlinx.coroutines.async
35+
import kotlinx.coroutines.launch
36+
import logcat.logcat
37+
import java.time.Instant
38+
import java.time.ZoneId
39+
import java.time.temporal.ChronoUnit
40+
import javax.inject.Inject
41+
import kotlin.math.roundToInt
42+
43+
interface AdClickCollector {
44+
fun onAdClick()
45+
}
46+
47+
/**
48+
* Ad clicks 7d avg Attributed Metric
49+
* Trigger: on first Ad click of day
50+
* Type: Daily pixel
51+
* Report: 7d rolling average of ad clicks (bucketed value). Not sent if count is 0.
52+
* Specs: https://app.asana.com/1/137249556945/project/1206716555947156/task/1211301604929610?focus=true
53+
*/
54+
@ContributesMultibinding(AppScope::class, AttributedMetric::class)
55+
@ContributesBinding(AppScope::class, AdClickCollector::class)
56+
@SingleInstanceIn(AppScope::class)
57+
class RealAdClickAttributedMetric @Inject constructor(
58+
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
59+
private val dispatcherProvider: DispatcherProvider,
60+
private val attributedMetricClient: AttributedMetricClient,
61+
private val appInstall: AppInstall,
62+
private val attributedMetricConfig: AttributedMetricConfig,
63+
) : AttributedMetric, AdClickCollector {
64+
65+
companion object {
66+
private const val EVENT_NAME = "ad_click"
67+
private const val PIXEL_NAME = "attributed_metric_average_ad_clicks_past_week"
68+
private const val FEATURE_TOGGLE_NAME = "adClickCountAvg"
69+
private const val FEATURE_EMIT_TOGGLE_NAME = "canEmitAdClickCountAvg"
70+
private const val DAYS_WINDOW = 7
71+
}
72+
73+
private val isEnabled: Deferred<Boolean> = appCoroutineScope.async(start = LAZY) {
74+
getToggle(FEATURE_TOGGLE_NAME)?.isEnabled() ?: false
75+
}
76+
77+
private val canEmit: Deferred<Boolean> = appCoroutineScope.async(start = LAZY) {
78+
getToggle(FEATURE_EMIT_TOGGLE_NAME)?.isEnabled() ?: false
79+
}
80+
81+
private val bucketConfig: Deferred<MetricBucket> = appCoroutineScope.async(start = LAZY) {
82+
attributedMetricConfig.getBucketConfiguration()[PIXEL_NAME] ?: MetricBucket(
83+
buckets = listOf(2, 5),
84+
version = 0,
85+
)
86+
}
87+
88+
override fun onAdClick() {
89+
appCoroutineScope.launch(dispatcherProvider.io()) {
90+
if (!isEnabled.await()) return@launch
91+
attributedMetricClient.collectEvent(EVENT_NAME)
92+
if (shouldSendPixel().not()) {
93+
logcat(tag = "AttributedMetrics") {
94+
"AdClickCount7d: Skip emitting, not enough data or no events"
95+
}
96+
return@launch
97+
}
98+
99+
if (canEmit.await()) {
100+
attributedMetricClient.emitMetric(this@RealAdClickAttributedMetric)
101+
}
102+
}
103+
}
104+
105+
override fun getPixelName(): String = PIXEL_NAME
106+
107+
override suspend fun getMetricParameters(): Map<String, String> {
108+
val stats = getEventStats()
109+
val params = mutableMapOf(
110+
"count" to getBucketValue(stats.rollingAverage.roundToInt()).toString(),
111+
"version" to bucketConfig.await().version.toString(),
112+
)
113+
if (!hasCompleteDataWindow()) {
114+
params["dayAverage"] = daysSinceInstalled().toString()
115+
}
116+
return params
117+
}
118+
119+
override suspend fun getTag(): String {
120+
return daysSinceInstalled().toString()
121+
}
122+
123+
private suspend fun getBucketValue(avg: Int): Int {
124+
val buckets = bucketConfig.await().buckets
125+
return buckets.indexOfFirst { bucket -> avg <= bucket }.let { index ->
126+
if (index == -1) buckets.size else index
127+
}
128+
}
129+
130+
private suspend fun shouldSendPixel(): Boolean {
131+
if (daysSinceInstalled() <= 0) {
132+
// installation day, we don't emit
133+
return false
134+
}
135+
136+
val eventStats = getEventStats()
137+
if (eventStats.daysWithEvents == 0 || eventStats.rollingAverage == 0.0) {
138+
// no events, nothing to emit
139+
return false
140+
}
141+
142+
return true
143+
}
144+
145+
private fun hasCompleteDataWindow(): Boolean {
146+
val daysSinceInstalled = daysSinceInstalled()
147+
return daysSinceInstalled >= DAYS_WINDOW
148+
}
149+
150+
private suspend fun getEventStats(): EventStats {
151+
val daysSinceInstall = daysSinceInstalled()
152+
val stats = if (daysSinceInstall >= DAYS_WINDOW) {
153+
attributedMetricClient.getEventStats(EVENT_NAME, DAYS_WINDOW)
154+
} else {
155+
attributedMetricClient.getEventStats(EVENT_NAME, daysSinceInstall)
156+
}
157+
158+
return stats
159+
}
160+
161+
private fun daysSinceInstalled(): Int {
162+
val etZone = ZoneId.of("America/New_York")
163+
val installInstant = Instant.ofEpochMilli(appInstall.getInstallationTimestamp())
164+
val nowInstant = Instant.now()
165+
166+
val installInEt = installInstant.atZone(etZone)
167+
val nowInEt = nowInstant.atZone(etZone)
168+
169+
return ChronoUnit.DAYS.between(installInEt.toLocalDate(), nowInEt.toLocalDate()).toInt()
170+
}
171+
172+
private suspend fun getToggle(toggleName: String) =
173+
attributedMetricConfig.metricsToggles().firstOrNull { toggle ->
174+
toggle.featureName().name == toggleName
175+
}
176+
}

ad-click/ad-click-impl/src/test/java/com/duckduckgo/adclick/impl/DuckDuckGoAdClickManagerTest.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package com.duckduckgo.adclick.impl
1818

1919
import androidx.test.ext.junit.runners.AndroidJUnit4
2020
import com.duckduckgo.adclick.api.AdClickManager
21+
import com.duckduckgo.adclick.impl.metrics.AdClickCollector
2122
import com.duckduckgo.adclick.impl.pixels.AdClickPixelName
2223
import com.duckduckgo.adclick.impl.pixels.AdClickPixels
2324
import org.junit.Assert.assertFalse
@@ -40,11 +41,12 @@ class DuckDuckGoAdClickManagerTest {
4041
private val mockAdClickData: AdClickData = mock()
4142
private val mockAdClickAttribution: AdClickAttribution = mock()
4243
private val mockAdClickPixels: AdClickPixels = mock()
44+
private val mockAdClickCollector: AdClickCollector = mock()
4345
private lateinit var testee: AdClickManager
4446

4547
@Before
4648
fun before() {
47-
testee = DuckDuckGoAdClickManager(mockAdClickData, mockAdClickAttribution, mockAdClickPixels)
49+
testee = DuckDuckGoAdClickManager(mockAdClickData, mockAdClickAttribution, mockAdClickPixels, mockAdClickCollector)
4850
}
4951

5052
@Test

0 commit comments

Comments
 (0)