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
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ class RealAdClickAttributedMetric @Inject constructor(

companion object {
private const val EVENT_NAME = "ad_click"
private const val PIXEL_NAME = "user_average_ad_clicks_past_week"
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
Expand Down Expand Up @@ -108,6 +108,7 @@ class RealAdClickAttributedMetric @Inject constructor(
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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ class RealAdClickAttributedMetricTest {
)
whenever(attributedMetricConfig.getBucketConfiguration()).thenReturn(
mapOf(
"user_average_ad_clicks_past_week" to MetricBucket(
"attributed_metric_average_ad_clicks_past_week" to MetricBucket(
buckets = listOf(2, 5),
version = 0,
),
Expand All @@ -79,7 +79,7 @@ class RealAdClickAttributedMetricTest {
}

@Test fun whenPixelNameRequestedThenReturnCorrectName() {
assertEquals("user_average_ad_clicks_past_week", testee.getPixelName())
assertEquals("attributed_metric_average_ad_clicks_past_week", testee.getPixelName())
}

@Test fun whenAdClickAndDaysInstalledIsZeroThenDoNotEmitMetric() = runTest {
Expand Down Expand Up @@ -222,12 +222,12 @@ class RealAdClickAttributedMetricTest {
),
)

val params = testee.getMetricParameters()
val count = testee.getMetricParameters()["count"]

assertEquals(
"For $clicksAvg clicks, should return bucket $expectedBucket",
mapOf("count" to expectedBucket.toString()),
params,
expectedBucket.toString(),
count,
)
}
}
Expand Down Expand Up @@ -255,6 +255,21 @@ class RealAdClickAttributedMetricTest {
}
}

@Test fun whenGetMetricParametersThenReturnVersion() = runTest {
givenDaysSinceInstalled(7)
whenever(attributedMetricClient.getEventStats("ad_click", 7)).thenReturn(
EventStats(
daysWithEvents = 1,
rollingAverage = 1.0,
totalEvents = 1,
),
)

val version = testee.getMetricParameters()["version"]

assertEquals("0", version)
}

private fun givenDaysSinceInstalled(days: Int) {
val etZone = ZoneId.of("America/New_York")
val now = Instant.now()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ class AppReferenceSharePreferences @Inject constructor(
}
}

override fun getOriginAttributeCampaign(): String? = utmOriginAttributeCampaign

override var campaignSuffix: String?
get() = preferences.getString(KEY_CAMPAIGN_SUFFIX, null)
set(value) = preferences.edit(true) { putString(KEY_CAMPAIGN_SUFFIX, value) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,4 @@ interface AttributedMetricsConfigFeature {

@Toggle.DefaultValue(DefaultFeatureValue.INTERNAL)
fun syncDevices(): Toggle

@Toggle.DefaultValue(DefaultFeatureValue.INTERNAL)
fun canEmitSyncDevices(): Toggle
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import androidx.lifecycle.LifecycleOwner
import com.duckduckgo.app.attributed.metrics.AttributedMetricsConfigFeature
import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDataStore
import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDateUtils
import com.duckduckgo.app.attributed.metrics.store.EventRepository
import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver
import com.duckduckgo.app.statistics.api.AtbLifecyclePlugin
Expand Down Expand Up @@ -66,6 +67,7 @@ class RealAttributedMetricsState @Inject constructor(
private val attributedMetricsConfigFeature: AttributedMetricsConfigFeature,
private val appBuildConfig: AppBuildConfig,
private val attributedMetricsDateUtils: AttributedMetricsDateUtils,
private val eventRepository: EventRepository,
) : AttributedMetricsState, MainProcessLifecycleObserver, AtbLifecyclePlugin {

override fun onCreate(owner: LifecycleOwner) {
Expand Down Expand Up @@ -125,6 +127,8 @@ class RealAttributedMetricsState @Inject constructor(
return
}

if (dataStore.isActive().not()) return // if already inactive, no need to check further

val daysSinceInit = attributedMetricsDateUtils.daysSince(initDate)
val isWithinPeriod = daysSinceInit <= COLLECTION_PERIOD_DAYS
val newClientActiveState = isWithinPeriod && dataStore.isActive()
Expand All @@ -133,6 +137,10 @@ class RealAttributedMetricsState @Inject constructor(
"Updating client state to $newClientActiveState result of -> within period? $isWithinPeriod, client active? ${dataStore.isActive()}"
}
dataStore.setActive(newClientActiveState)
if (!isWithinPeriod) {
eventRepository.deleteAllEvents()
}

logClientStatus()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,13 @@ package com.duckduckgo.app.attributed.metrics.impl
import com.duckduckgo.app.attributed.metrics.api.AttributedMetric
import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient
import com.duckduckgo.app.attributed.metrics.api.EventStats
import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDateUtils
import com.duckduckgo.app.attributed.metrics.store.EventRepository
import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.app.statistics.pixels.Pixel
import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Unique
import com.duckduckgo.browser.api.install.AppInstall
import com.duckduckgo.browser.api.referrer.AppReferrer
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
Expand All @@ -41,6 +44,9 @@ class RealAttributedMetricClient @Inject constructor(
private val eventRepository: EventRepository,
private val pixel: Pixel,
private val metricsState: AttributedMetricsState,
private val appReferrer: AppReferrer,
private val dateUtils: AttributedMetricsDateUtils,
private val appInstall: AppInstall,
) : AttributedMetricClient {

override fun collectEvent(eventName: String) {
Expand Down Expand Up @@ -77,7 +83,6 @@ class RealAttributedMetricClient @Inject constructor(
}
}

// TODO: Pending adding default attributed metrics and removing default prefix from pixel names
override fun emitMetric(metric: AttributedMetric) {
appCoroutineScope.launch(dispatcherProvider.io()) {
if (!metricsState.isActive() || !metricsState.canEmitMetrics()) {
Expand All @@ -91,11 +96,23 @@ class RealAttributedMetricClient @Inject constructor(
val params = metric.getMetricParameters()
val tag = metric.getTag()
val pixelTag = "${pixelName}_$tag"
pixel.fire(pixelName = pixelName, parameters = params, type = Unique(pixelTag)).also {

val origin = appReferrer.getOriginAttributeCampaign()
val paramsMutableMap = params.toMutableMap()
if (!origin.isNullOrBlank()) {
paramsMutableMap["origin"] = origin
} else {
paramsMutableMap["install_date"] = getInstallDate()
}
pixel.fire(pixelName = pixelName, parameters = paramsMutableMap, type = Unique(pixelTag)).also {
logcat(tag = "AttributedMetrics") {
"Fired pixel $pixelName with params $params"
"Fired pixel $pixelName with params $paramsMutableMap"
}
}
}
}

private fun getInstallDate(): String {
return dateUtils.getDateFromTimestamp(appInstall.getInstallationTimestamp())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*
* 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.app.attributed.metrics.pixels

import com.duckduckgo.common.utils.device.DeviceInfo
import com.duckduckgo.common.utils.plugins.pixel.PixelInterceptorPlugin
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesMultibinding
import logcat.logcat
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject

@ContributesMultibinding(
scope = AppScope::class,
boundType = PixelInterceptorPlugin::class,
)
class AttributedMetricPixelInterceptor @Inject constructor() : Interceptor, PixelInterceptorPlugin {

override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder()
var url = chain.request().url
val pixel = chain.request().url.pathSegments.last()
if (pixel.startsWith(ATTRIBUTED_METRICS_PIXEL_PREFIX)) {
url = url.toUrl().toString().replace("android_${DeviceInfo.FormFactor.PHONE.description}", "android").toHttpUrl()
logcat(tag = "AttributedMetrics") {
"Pixel renamed to: $url"
}
}
return chain.proceed(request.url(url).build())
}

override fun getInterceptor() = this

companion object {
const val ATTRIBUTED_METRICS_PIXEL_PREFIX = "attributed_metric"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* 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.app.attributed.metrics.pixels

import com.duckduckgo.common.utils.plugins.pixel.PixelParamRemovalPlugin
import com.duckduckgo.common.utils.plugins.pixel.PixelParamRemovalPlugin.PixelParameter
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesMultibinding

@ContributesMultibinding(
scope = AppScope::class,
boundType = PixelParamRemovalPlugin::class,
)
object AttributedMetricPixelRemovalInterceptor : PixelParamRemovalPlugin {
override fun names(): List<Pair<String, Set<PixelParameter>>> {
return listOf(
ATTRIBUTED_METRICS_PIXEL_PREFIX to PixelParameter.removeAll(),
)
}

private const val ATTRIBUTED_METRICS_PIXEL_PREFIX = "attributed_metric"
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ class RetentionMonthAttributedMetric @Inject constructor(
) : AttributedMetric, AtbLifecyclePlugin {

companion object {
private const val PIXEL_NAME_FIRST_MONTH = "user_retention_month"
private const val PIXEL_NAME_FIRST_MONTH = "attributed_metric_retention_month"
private const val DAYS_IN_4_WEEKS = 28 // we consider 1 month after 4 weeks
private const val MONTH_DAY_THRESHOLD = DAYS_IN_4_WEEKS + 1
private const val START_MONTH_THRESHOLD = 2
Expand Down Expand Up @@ -103,7 +103,11 @@ class RetentionMonthAttributedMetric @Inject constructor(
val month = getMonthSinceInstall()
if (month < START_MONTH_THRESHOLD) return emptyMap()

return mutableMapOf("count" to bucketMonth(month).toString())
val params = mutableMapOf(
"count" to bucketMonth(month).toString(),
"version" to bucketConfig.await().version.toString(),
)
return params
}

override suspend fun getTag(): String {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ class RetentionWeekAttributedMetric @Inject constructor(
) : AttributedMetric, AtbLifecyclePlugin {

companion object {
private const val PIXEL_NAME_FIRST_WEEK = "user_retention_week"
private const val PIXEL_NAME_FIRST_WEEK = "attributed_metric_retention_week"
private const val FEATURE_TOGGLE_NAME = "retention"
private const val FEATURE_EMIT_TOGGLE_NAME = "canEmitRetention"
}
Expand Down Expand Up @@ -98,7 +98,12 @@ class RetentionWeekAttributedMetric @Inject constructor(
override suspend fun getMetricParameters(): Map<String, String> {
val week = getWeekSinceInstall()
if (week == -1) return emptyMap()
return mutableMapOf("count" to bucketValue(getWeekSinceInstall()).toString())

val params = mutableMapOf(
"count" to bucketValue(getWeekSinceInstall()).toString(),
"version" to bucketConfig.await().version.toString(),
)
return params
}

override suspend fun getTag(): String {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ class SearchAttributedMetric @Inject constructor(

companion object {
private const val EVENT_NAME = "ddg_search"
private const val FIRST_MONTH_PIXEL = "user_average_searches_past_week_first_month"
private const val PAST_WEEK_PIXEL_NAME = "user_average_searches_past_week"
private const val FIRST_MONTH_PIXEL = "attributed_metric_average_searches_past_week_first_month"
private const val PAST_WEEK_PIXEL_NAME = "attributed_metric_average_searches_past_week"
private const val DAYS_WINDOW = 7
private const val FIRST_MONTH_DAY_THRESHOLD = 28 // we consider 1 month after 4 weeks
private const val FEATURE_TOGGLE_NAME = "searchCountAvg"
Expand Down Expand Up @@ -126,6 +126,7 @@ class SearchAttributedMetric @Inject constructor(
val stats = getEventStats()
val params = mutableMapOf(
"count" to getBucketValue(stats.rollingAverage.roundToInt()).toString(),
"version" to getBucketConfig().version.toString(),
)
if (!hasCompleteDataWindow()) {
params["dayAverage"] = daysSinceInstalled().toString()
Expand All @@ -141,10 +142,7 @@ class SearchAttributedMetric @Inject constructor(
}

private suspend fun getBucketValue(searches: Int): Int {
val buckets = when (daysSinceInstalled()) {
in 0..FIRST_MONTH_DAY_THRESHOLD -> bucketConfigFirstMonth.await().buckets
else -> bucketConfigPastWeek.await().buckets
}
val buckets = getBucketConfig().buckets
return buckets.indexOfFirst { bucket -> searches <= bucket }.let { index ->
if (index == -1) buckets.size else index
}
Expand Down Expand Up @@ -177,6 +175,11 @@ class SearchAttributedMetric @Inject constructor(
return stats
}

private suspend fun getBucketConfig() = when (daysSinceInstalled()) {
in 0..FIRST_MONTH_DAY_THRESHOLD -> bucketConfigFirstMonth.await()
else -> bucketConfigPastWeek.await()
}

private fun hasCompleteDataWindow(): Boolean {
val daysSinceInstalled = daysSinceInstalled()
return daysSinceInstalled >= DAYS_WINDOW
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ class SearchDaysAttributedMetric @Inject constructor(

companion object {
private const val EVENT_NAME = "ddg_search_days"
private const val PIXEL_NAME = "user_active_past_week"
private const val PIXEL_NAME = "attributed_metric_active_past_week"
private const val DAYS_WINDOW = 7
private const val FEATURE_TOGGLE_NAME = "searchDaysAvg"
private const val FEATURE_EMIT_TOGGLE_NAME = "canEmitSearchDaysAvg"
Expand Down Expand Up @@ -124,6 +124,7 @@ class SearchDaysAttributedMetric @Inject constructor(
val stats = attributedMetricClient.getEventStats(EVENT_NAME, DAYS_WINDOW)
val params = mutableMapOf(
"days" to getBucketValue(stats.daysWithEvents).toString(),
"version" to bucketConfiguration.await().version.toString(),
)
if (!hasCompleteDataWindow) {
params["daysSinceInstalled"] = daysSinceInstalled.toString()
Expand Down
Loading
Loading