Skip to content

Commit 6633627

Browse files
committed
Adds attributed metric for ad clicks
1 parent 7c167da commit 6633627

File tree

7 files changed

+368
-1
lines changed

7 files changed

+368
-1
lines changed

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

Lines changed: 1 addition & 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

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: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
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.EventStats
22+
import com.duckduckgo.app.di.AppCoroutineScope
23+
import com.duckduckgo.browser.api.install.AppInstall
24+
import com.duckduckgo.common.utils.DispatcherProvider
25+
import com.duckduckgo.di.scopes.AppScope
26+
import com.squareup.anvil.annotations.ContributesBinding
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 java.time.Instant
33+
import java.time.ZoneId
34+
import java.time.temporal.ChronoUnit
35+
import javax.inject.Inject
36+
import kotlin.math.roundToInt
37+
38+
interface AdClickCollector {
39+
fun onAdClick()
40+
}
41+
42+
/**
43+
* Ad clicks 7d avg Attributed Metric
44+
* Trigger: on first Ad click of day
45+
* Type: Daily pixel
46+
* Report: 7d rolling average of ad clicks (bucketed value). Not sent if count is 0.
47+
* Specs: https://app.asana.com/1/137249556945/project/1206716555947156/task/1211301604929610?focus=true
48+
*/
49+
@ContributesMultibinding(AppScope::class, AttributedMetric::class)
50+
@ContributesBinding(AppScope::class, AdClickCollector::class)
51+
@SingleInstanceIn(AppScope::class)
52+
class RealAdClickAttributedMetric @Inject constructor(
53+
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
54+
private val dispatcherProvider: DispatcherProvider,
55+
private val attributedMetricClient: AttributedMetricClient,
56+
private val appInstall: AppInstall,
57+
) : AttributedMetric, AdClickCollector {
58+
59+
companion object {
60+
private const val EVENT_NAME = "ad_click"
61+
private const val PIXEL_NAME = "user_average_ad_clicks_past_week"
62+
private const val DAYS_WINDOW = 7
63+
private val AD_CLICK_BUCKETS = arrayOf(2, 5)
64+
}
65+
66+
override fun onAdClick() {
67+
attributedMetricClient.collectEvent(EVENT_NAME)
68+
69+
appCoroutineScope.launch(dispatcherProvider.io()) {
70+
if (shouldSendPixel().not()) {
71+
logcat(tag = "AttributedMetrics") {
72+
"AdClickCount7d: Skip emitting, not enough data or no events"
73+
}
74+
return@launch
75+
}
76+
attributedMetricClient.emitMetric(this@RealAdClickAttributedMetric)
77+
}
78+
}
79+
80+
override fun getPixelName(): String = PIXEL_NAME
81+
82+
override suspend fun getMetricParameters(): Map<String, String> {
83+
val stats = getEventStats()
84+
val params = mutableMapOf(
85+
"count" to getBucketValue(stats.rollingAverage.roundToInt()).toString(),
86+
)
87+
if (!hasCompleteDataWindow()) {
88+
params["dayAverage"] = daysSinceInstalled().toString()
89+
}
90+
return params
91+
}
92+
93+
override suspend fun getTag(): String {
94+
return daysSinceInstalled().toString()
95+
}
96+
97+
private fun getBucketValue(avg: Int): Int {
98+
return AD_CLICK_BUCKETS.indexOfFirst { bucket -> avg <= bucket }.let { index ->
99+
if (index == -1) AD_CLICK_BUCKETS.size else index
100+
}
101+
}
102+
103+
private suspend fun shouldSendPixel(): Boolean {
104+
if (daysSinceInstalled() <= 0) {
105+
// installation day, we don't emit
106+
return false
107+
}
108+
109+
val eventStats = getEventStats()
110+
if (eventStats.daysWithEvents == 0 || eventStats.rollingAverage == 0.0) {
111+
// no events, nothing to emit
112+
return false
113+
}
114+
115+
return true
116+
}
117+
118+
private fun hasCompleteDataWindow(): Boolean {
119+
val daysSinceInstalled = daysSinceInstalled()
120+
return daysSinceInstalled >= DAYS_WINDOW
121+
}
122+
123+
private suspend fun getEventStats(): EventStats {
124+
val daysSinceInstall = daysSinceInstalled()
125+
val stats = if (daysSinceInstall >= DAYS_WINDOW) {
126+
attributedMetricClient.getEventStats(EVENT_NAME, DAYS_WINDOW)
127+
} else {
128+
attributedMetricClient.getEventStats(EVENT_NAME, daysSinceInstall)
129+
}
130+
131+
return stats
132+
}
133+
134+
private fun daysSinceInstalled(): Int {
135+
val etZone = ZoneId.of("America/New_York")
136+
val installInstant = Instant.ofEpochMilli(appInstall.getInstallationTimestamp())
137+
val nowInstant = Instant.now()
138+
139+
val installInEt = installInstant.atZone(etZone)
140+
val nowInEt = nowInstant.atZone(etZone)
141+
142+
return ChronoUnit.DAYS.between(installInEt.toLocalDate(), nowInEt.toLocalDate()).toInt()
143+
}
144+
}

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
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
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 androidx.test.ext.junit.runners.AndroidJUnit4
20+
import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient
21+
import com.duckduckgo.app.attributed.metrics.api.EventStats
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.Assert.assertNull
27+
import org.junit.Before
28+
import org.junit.Rule
29+
import org.junit.Test
30+
import org.junit.runner.RunWith
31+
import org.mockito.kotlin.mock
32+
import org.mockito.kotlin.never
33+
import org.mockito.kotlin.verify
34+
import org.mockito.kotlin.whenever
35+
import java.time.Instant
36+
import java.time.ZoneId
37+
38+
@RunWith(AndroidJUnit4::class)
39+
class RealAdClickAttributedMetricTest {
40+
41+
@get:Rule val coroutineRule = CoroutineTestRule()
42+
43+
private val attributedMetricClient: AttributedMetricClient = mock()
44+
private val appInstall: AppInstall = mock()
45+
46+
private lateinit var testee: RealAdClickAttributedMetric
47+
48+
@Before fun setup() {
49+
testee = RealAdClickAttributedMetric(
50+
appCoroutineScope = coroutineRule.testScope,
51+
dispatcherProvider = coroutineRule.testDispatcherProvider,
52+
attributedMetricClient = attributedMetricClient,
53+
appInstall = appInstall,
54+
)
55+
}
56+
57+
@Test fun whenPixelNameRequestedThenReturnCorrectName() {
58+
assertEquals("user_average_ad_clicks_past_week", testee.getPixelName())
59+
}
60+
61+
@Test fun whenAdClickAndDaysInstalledIsZeroThenDoNotEmitMetric() = runTest {
62+
givenDaysSinceInstalled(0)
63+
64+
testee.onAdClick()
65+
66+
verify(attributedMetricClient).collectEvent("ad_click")
67+
verify(attributedMetricClient, never()).emitMetric(testee)
68+
}
69+
70+
@Test fun whenAdClickAndNoEventsThenDoNotEmitMetric() = runTest {
71+
givenDaysSinceInstalled(7)
72+
whenever(attributedMetricClient.getEventStats("ad_click", 7)).thenReturn(
73+
EventStats(
74+
daysWithEvents = 0,
75+
rollingAverage = 0.0,
76+
totalEvents = 0,
77+
),
78+
)
79+
80+
testee.onAdClick()
81+
82+
verify(attributedMetricClient).collectEvent("ad_click")
83+
verify(attributedMetricClient, never()).emitMetric(testee)
84+
}
85+
86+
@Test fun whenAdClickAndHasEventsThenEmitMetric() = runTest {
87+
givenDaysSinceInstalled(7)
88+
whenever(attributedMetricClient.getEventStats("ad_click", 7)).thenReturn(
89+
EventStats(
90+
daysWithEvents = 1,
91+
rollingAverage = 1.0,
92+
totalEvents = 1,
93+
),
94+
)
95+
96+
testee.onAdClick()
97+
98+
verify(attributedMetricClient).collectEvent("ad_click")
99+
verify(attributedMetricClient).emitMetric(testee)
100+
}
101+
102+
@Test fun whenDaysInstalledLessThanWindowThenIncludeDayAverageParameter() = runTest {
103+
givenDaysSinceInstalled(5)
104+
whenever(attributedMetricClient.getEventStats("ad_click", 5)).thenReturn(
105+
EventStats(
106+
daysWithEvents = 1,
107+
rollingAverage = 1.0,
108+
totalEvents = 1,
109+
),
110+
)
111+
112+
val params = testee.getMetricParameters()
113+
114+
assertEquals("5", params["dayAverage"])
115+
}
116+
117+
@Test fun whenDaysInstalledGreaterThanWindowThenOmitDayAverageParameter() = runTest {
118+
givenDaysSinceInstalled(8)
119+
whenever(attributedMetricClient.getEventStats("ad_click", 7)).thenReturn(
120+
EventStats(
121+
daysWithEvents = 1,
122+
rollingAverage = 1.0,
123+
totalEvents = 1,
124+
),
125+
)
126+
127+
val params = testee.getMetricParameters()
128+
129+
assertNull(params["dayAverage"])
130+
}
131+
132+
@Test fun whenGetMetricParametersThenReturnCorrectBucketValue() = runTest {
133+
// Map of average clicks to expected bucket value
134+
// clicks avg -> bucket
135+
val bucketRanges = mapOf(
136+
0.0 to 0,
137+
1.0 to 0,
138+
2.2 to 0,
139+
2.6 to 1,
140+
3.0 to 1,
141+
5.4 to 1,
142+
6.0 to 2,
143+
10.0 to 2,
144+
)
145+
146+
bucketRanges.forEach { (clicksAvg, expectedBucket) ->
147+
givenDaysSinceInstalled(8)
148+
whenever(attributedMetricClient.getEventStats("ad_click", 7)).thenReturn(
149+
EventStats(
150+
daysWithEvents = 1, // not relevant for this test
151+
rollingAverage = clicksAvg,
152+
totalEvents = 1, // not relevant for this test
153+
),
154+
)
155+
156+
val params = testee.getMetricParameters()
157+
158+
assertEquals(
159+
"For $clicksAvg clicks, should return bucket $expectedBucket",
160+
mapOf("count" to expectedBucket.toString()),
161+
params,
162+
)
163+
}
164+
}
165+
166+
@Test fun whenDaysInstalledThenReturnCorrectTag() = runTest {
167+
// Test different days
168+
// days installed -> expected tag
169+
val testCases = mapOf(
170+
0 to "0",
171+
1 to "1",
172+
7 to "7",
173+
30 to "30",
174+
)
175+
176+
testCases.forEach { (days, expectedTag) ->
177+
givenDaysSinceInstalled(days)
178+
179+
val tag = testee.getTag()
180+
181+
assertEquals(
182+
"For $days days installed, should return tag $expectedTag",
183+
expectedTag,
184+
tag,
185+
)
186+
}
187+
}
188+
189+
private fun givenDaysSinceInstalled(days: Int) {
190+
val etZone = ZoneId.of("America/New_York")
191+
val now = Instant.now()
192+
val nowInEt = now.atZone(etZone)
193+
val installInEt = nowInEt.minusDays(days.toLong())
194+
whenever(appInstall.getInstallationTimestamp()).thenReturn(installInEt.toInstant().toEpochMilli())
195+
}
196+
}

0 commit comments

Comments
 (0)