Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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