Skip to content
2 changes: 2 additions & 0 deletions ad-click/ad-click-impl/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ dependencies {
implementation project(path: ':privacy-config-api')
implementation project(path: ':feature-toggles-api')
implementation project(path: ':app-build-config-api')
implementation project(path: ':attributed-metrics-api')

implementation AndroidX.core.ktx

Expand Down Expand Up @@ -97,6 +98,7 @@ dependencies {
testImplementation Testing.robolectric

testImplementation project(path: ':common-test')
testImplementation project(path: ':feature-toggles-test')

coreLibraryDesugaring Android.tools.desugarJdkLibs
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package com.duckduckgo.adclick.impl

import com.duckduckgo.adclick.api.AdClickManager
import com.duckduckgo.adclick.impl.metrics.AdClickCollector
import com.duckduckgo.adclick.impl.pixels.AdClickPixelName
import com.duckduckgo.adclick.impl.pixels.AdClickPixels
import com.duckduckgo.app.browser.UriString
Expand All @@ -33,6 +34,7 @@ class DuckDuckGoAdClickManager @Inject constructor(
private val adClickData: AdClickData,
private val adClickAttribution: AdClickAttribution,
private val adClickPixels: AdClickPixels,
private val adClickCollector: AdClickCollector,
) : AdClickManager {

private val publicSuffixDatabase = PublicSuffixDatabase()
Expand Down Expand Up @@ -223,6 +225,7 @@ class DuckDuckGoAdClickManager @Inject constructor(
exemptionDeadline = System.currentTimeMillis() + adClickAttribution.getTotalExpirationMillis(),
),
)
adClickCollector.onAdClick()
adClickPixels.fireAdClickDetectedPixel(
savedAdDomain = savedAdDomain,
urlAdDomain = urlAdDomain,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/*
* Copyright (c) 2025 DuckDuckGo
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.duckduckgo.adclick.impl.metrics

import com.duckduckgo.app.attributed.metrics.api.AttributedMetric
import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient
import com.duckduckgo.app.attributed.metrics.api.AttributedMetricConfig
import com.duckduckgo.app.attributed.metrics.api.EventStats
import com.duckduckgo.app.attributed.metrics.api.MetricBucket
import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.browser.api.install.AppInstall
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import com.squareup.anvil.annotations.ContributesMultibinding
import dagger.SingleInstanceIn
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart.LAZY
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import logcat.logcat
import java.time.Instant
import java.time.ZoneId
import java.time.temporal.ChronoUnit
import javax.inject.Inject
import kotlin.math.roundToInt

interface AdClickCollector {
fun onAdClick()
}

/**
* Ad clicks 7d avg Attributed Metric
* Trigger: on first Ad click of day
* Type: Daily pixel
* Report: 7d rolling average of ad clicks (bucketed value). Not sent if count is 0.
* Specs: https://app.asana.com/1/137249556945/project/1206716555947156/task/1211301604929610?focus=true
*/
@ContributesMultibinding(AppScope::class, AttributedMetric::class)
@ContributesBinding(AppScope::class, AdClickCollector::class)
@SingleInstanceIn(AppScope::class)
class RealAdClickAttributedMetric @Inject constructor(
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
private val dispatcherProvider: DispatcherProvider,
private val attributedMetricClient: AttributedMetricClient,
private val appInstall: AppInstall,
private val attributedMetricConfig: AttributedMetricConfig,
) : AttributedMetric, AdClickCollector {

companion object {
private const val EVENT_NAME = "ad_click"
private const val PIXEL_NAME = "attributed_metric_average_ad_clicks_past_week"
private const val FEATURE_TOGGLE_NAME = "adClickCountAvg"
private const val FEATURE_EMIT_TOGGLE_NAME = "canEmitAdClickCountAvg"
private const val DAYS_WINDOW = 7
}

private val isEnabled: Deferred<Boolean> = appCoroutineScope.async(start = LAZY) {
getToggle(FEATURE_TOGGLE_NAME)?.isEnabled() ?: false
}

private val canEmit: Deferred<Boolean> = appCoroutineScope.async(start = LAZY) {
getToggle(FEATURE_EMIT_TOGGLE_NAME)?.isEnabled() ?: false
}

private val bucketConfig: Deferred<MetricBucket> = appCoroutineScope.async(start = LAZY) {
attributedMetricConfig.getBucketConfiguration()[PIXEL_NAME] ?: MetricBucket(
buckets = listOf(2, 5),
version = 0,
)
}

override fun onAdClick() {
appCoroutineScope.launch(dispatcherProvider.io()) {
if (!isEnabled.await()) return@launch
attributedMetricClient.collectEvent(EVENT_NAME)
if (shouldSendPixel().not()) {
logcat(tag = "AttributedMetrics") {
"AdClickCount7d: Skip emitting, not enough data or no events"
}
return@launch
}

if (canEmit.await()) {
attributedMetricClient.emitMetric(this@RealAdClickAttributedMetric)
}
}
}

override fun getPixelName(): String = PIXEL_NAME

override suspend fun getMetricParameters(): Map<String, String> {
val stats = getEventStats()
val params = mutableMapOf(
"count" to getBucketValue(stats.rollingAverage.roundToInt()).toString(),
"version" to bucketConfig.await().version.toString(),
)
if (!hasCompleteDataWindow()) {
params["dayAverage"] = daysSinceInstalled().toString()
}
return params
}

override suspend fun getTag(): String {
return daysSinceInstalled().toString()
}

private suspend fun getBucketValue(avg: Int): Int {
val buckets = bucketConfig.await().buckets
return buckets.indexOfFirst { bucket -> avg <= bucket }.let { index ->
if (index == -1) buckets.size else index
}
}

private suspend fun shouldSendPixel(): Boolean {
if (daysSinceInstalled() <= 0) {
// installation day, we don't emit
return false
}

val eventStats = getEventStats()
if (eventStats.daysWithEvents == 0 || eventStats.rollingAverage == 0.0) {
// no events, nothing to emit
return false
}

return true
}

private fun hasCompleteDataWindow(): Boolean {
val daysSinceInstalled = daysSinceInstalled()
return daysSinceInstalled >= DAYS_WINDOW
}

private suspend fun getEventStats(): EventStats {
val daysSinceInstall = daysSinceInstalled()
val stats = if (daysSinceInstall >= DAYS_WINDOW) {
attributedMetricClient.getEventStats(EVENT_NAME, DAYS_WINDOW)
} else {
attributedMetricClient.getEventStats(EVENT_NAME, daysSinceInstall)
}

return stats
}

private fun daysSinceInstalled(): Int {
val etZone = ZoneId.of("America/New_York")
val installInstant = Instant.ofEpochMilli(appInstall.getInstallationTimestamp())
val nowInstant = Instant.now()

val installInEt = installInstant.atZone(etZone)
val nowInEt = nowInstant.atZone(etZone)

return ChronoUnit.DAYS.between(installInEt.toLocalDate(), nowInEt.toLocalDate()).toInt()
}

private suspend fun getToggle(toggleName: String) =
attributedMetricConfig.metricsToggles().firstOrNull { toggle ->
toggle.featureName().name == toggleName
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package com.duckduckgo.adclick.impl

import androidx.test.ext.junit.runners.AndroidJUnit4
import com.duckduckgo.adclick.api.AdClickManager
import com.duckduckgo.adclick.impl.metrics.AdClickCollector
import com.duckduckgo.adclick.impl.pixels.AdClickPixelName
import com.duckduckgo.adclick.impl.pixels.AdClickPixels
import org.junit.Assert.assertFalse
Expand All @@ -40,11 +41,12 @@ class DuckDuckGoAdClickManagerTest {
private val mockAdClickData: AdClickData = mock()
private val mockAdClickAttribution: AdClickAttribution = mock()
private val mockAdClickPixels: AdClickPixels = mock()
private val mockAdClickCollector: AdClickCollector = mock()
private lateinit var testee: AdClickManager

@Before
fun before() {
testee = DuckDuckGoAdClickManager(mockAdClickData, mockAdClickAttribution, mockAdClickPixels)
testee = DuckDuckGoAdClickManager(mockAdClickData, mockAdClickAttribution, mockAdClickPixels, mockAdClickCollector)
}

@Test
Expand Down
Loading
Loading