diff --git a/app/src/internal/java/com/duckduckgo/app/statistics/StatisticsInternalInfoView.kt b/app/src/internal/java/com/duckduckgo/app/statistics/StatisticsInternalInfoView.kt
index 764167bb2d62..a7f1dc5e808d 100644
--- a/app/src/internal/java/com/duckduckgo/app/statistics/StatisticsInternalInfoView.kt
+++ b/app/src/internal/java/com/duckduckgo/app/statistics/StatisticsInternalInfoView.kt
@@ -54,14 +54,21 @@ class StatisticsInternalInfoView @JvmOverloads constructor(
AndroidSupportInjection.inject(this)
super.onAttachedToWindow()
+ binding.retentionAtb.apply {
+ text = store.appRetentionAtb ?: "unknown"
+ }
+
+ binding.retentionAtbSave.setOnClickListener {
+ store.appRetentionAtb = binding.retentionAtb.text
+ Toast.makeText(this.context, "App Retention Atb updated", Toast.LENGTH_SHORT).show()
+ }
+
binding.searchAtb.apply {
text = store.searchRetentionAtb ?: "unknown"
}
binding.searchAtbSave.setOnClickListener {
- store.searchRetentionAtb?.let {
- store.searchRetentionAtb = binding.searchAtb.text
- }
+ store.searchRetentionAtb = binding.searchAtb.text
Toast.makeText(this.context, "Search Atb updated", Toast.LENGTH_SHORT).show()
}
diff --git a/app/src/internal/res/layout/view_statistics_attributed_metrics.xml b/app/src/internal/res/layout/view_statistics_attributed_metrics.xml
index c1f8c36b82f4..4217d51f9568 100644
--- a/app/src/internal/res/layout/view_statistics_attributed_metrics.xml
+++ b/app/src/internal/res/layout/view_statistics_attributed_metrics.xml
@@ -21,6 +21,22 @@
android:orientation="vertical"
android:padding="16dp">
+
+
+
+
FIRST_MONTH_PIXEL
+ else -> PAST_WEEK_PIXEL_NAME
+ }
+
+ override suspend fun getMetricParameters(): Map {
+ val stats = getEventStats()
+ val params = mutableMapOf(
+ "count" to getBucketValue(stats.rollingAverage.toInt()).toString(),
+ )
+ if (!hasCompleteDataWindow()) {
+ params["dayAverage"] = daysSinceInstalled().toString()
+ }
+ return params
+ }
+
+ override suspend fun getTag(): String {
+ // Daily metric, on first search of day
+ // rely on searchRetentionAtb as mirrors the metric trigger event
+ return statisticsDataStore.searchRetentionAtb
+ ?: "no-atb" // should not happen, but just in case
+ }
+
+ private fun getBucketValue(searches: Int): Int {
+ return SEARCH_BUCKETS.indexOfFirst { bucket -> searches <= bucket }.let { index ->
+ if (index == -1) SEARCH_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 suspend fun getEventStats(): EventStats {
+ val stats = if (hasCompleteDataWindow()) {
+ attributedMetricClient.getEventStats(EVENT_NAME, DAYS_WINDOW)
+ } else {
+ attributedMetricClient.getEventStats(
+ EVENT_NAME,
+ daysSinceInstalled(),
+ )
+ }
+
+ return stats
+ }
+
+ private fun hasCompleteDataWindow(): Boolean {
+ val daysSinceInstalled = daysSinceInstalled()
+ return daysSinceInstalled >= DAYS_WINDOW
+ }
+
+ private fun daysSinceInstalled(): Int {
+ return dateUtils.daysSince(appInstall.getInstallationTimestamp())
+ }
+}
diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/SearchDaysAttributedMetric.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/SearchDaysAttributedMetric.kt
new file mode 100644
index 000000000000..dd9d53215995
--- /dev/null
+++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/SearchDaysAttributedMetric.kt
@@ -0,0 +1,139 @@
+/*
+ * 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
+
+import com.duckduckgo.app.attributed.metrics.api.AttributedMetric
+import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient
+import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDateUtils
+import com.duckduckgo.app.di.AppCoroutineScope
+import com.duckduckgo.app.statistics.api.AtbLifecyclePlugin
+import com.duckduckgo.app.statistics.store.StatisticsDataStore
+import com.duckduckgo.browser.api.install.AppInstall
+import com.duckduckgo.common.utils.DispatcherProvider
+import com.duckduckgo.di.scopes.AppScope
+import com.squareup.anvil.annotations.ContributesMultibinding
+import dagger.SingleInstanceIn
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import logcat.logcat
+import javax.inject.Inject
+
+/**
+ * Search Days Attributed Metric
+ * Trigger: on app start
+ * Type: Daily pixel
+ * Report: Bucketed value, how many days user searched last 7d. Not sent if count is 0.
+ * Specs: https://app.asana.com/1/137249556945/project/1206716555947156/task/1211301604929609?focus=true
+ */
+@ContributesMultibinding(AppScope::class, AtbLifecyclePlugin::class)
+@ContributesMultibinding(AppScope::class, AttributedMetric::class)
+@SingleInstanceIn(AppScope::class)
+class RealSearchDaysAttributedMetric @Inject constructor(
+ @AppCoroutineScope private val appCoroutineScope: CoroutineScope,
+ private val dispatcherProvider: DispatcherProvider,
+ private val attributedMetricClient: AttributedMetricClient,
+ private val appInstall: AppInstall,
+ private val statisticsDataStore: StatisticsDataStore,
+ private val dateUtils: AttributedMetricsDateUtils,
+) : AttributedMetric, AtbLifecyclePlugin {
+
+ companion object {
+ private const val EVENT_NAME = "ddg_search_days"
+ private const val PIXEL_NAME = "user_active_past_week"
+ private const val DAYS_WINDOW = 7
+ private val DAYS_BUCKETS = arrayOf(
+ 2,
+ 4,
+ ) // TODO: default bucket, remote bucket implementation will happen in future PRs
+ }
+
+ override fun onAppRetentionAtbRefreshed(
+ oldAtb: String,
+ newAtb: String,
+ ) {
+ appCoroutineScope.launch(dispatcherProvider.io()) {
+ if (oldAtb == newAtb) {
+ logcat(tag = "AttributedMetrics") {
+ "SearchDays: Skip emitting atb not changed"
+ }
+ return@launch
+ }
+ if (shouldSendPixel().not()) {
+ logcat(tag = "AttributedMetrics") {
+ "SearchDays: Skip emitting, not enough data or no events"
+ }
+ return@launch
+ }
+ attributedMetricClient.emitMetric(this@RealSearchDaysAttributedMetric)
+ }
+ }
+
+ override fun onSearchRetentionAtbRefreshed(
+ oldAtb: String,
+ newAtb: String,
+ ) {
+ appCoroutineScope.launch(dispatcherProvider.io()) {
+ attributedMetricClient.collectEvent(EVENT_NAME)
+ }
+ }
+
+ override fun getPixelName(): String = PIXEL_NAME
+
+ override suspend fun getMetricParameters(): Map {
+ val daysSinceInstalled = daysSinceInstalled()
+ val hasCompleteDataWindow = daysSinceInstalled >= DAYS_WINDOW
+ val stats = attributedMetricClient.getEventStats(EVENT_NAME, DAYS_WINDOW)
+ val params = mutableMapOf(
+ "days" to getBucketValue(stats.daysWithEvents).toString(),
+ )
+ if (!hasCompleteDataWindow) {
+ params["daysSinceInstalled"] = daysSinceInstalled.toString()
+ }
+ return params
+ }
+
+ override suspend fun getTag(): String {
+ // Daily metric, on App start
+ // rely on appRetentionAtb as mirrors the metric trigger event
+ return statisticsDataStore.appRetentionAtb ?: "no-atb" // should not happen, but just in case
+ }
+
+ private fun getBucketValue(days: Int): Int {
+ return DAYS_BUCKETS.indexOfFirst { bucket -> days <= bucket }.let { index ->
+ if (index == -1) DAYS_BUCKETS.size else index
+ }
+ }
+
+ private suspend fun shouldSendPixel(): Boolean {
+ if (daysSinceInstalled() <= 0) {
+ // installation day, we don't emit
+ return false
+ }
+
+ val eventStats = attributedMetricClient.getEventStats(EVENT_NAME, DAYS_WINDOW)
+ if (eventStats.daysWithEvents == 0) {
+ // no events, nothing to emit
+ return false
+ }
+
+ return true
+ }
+
+ private fun daysSinceInstalled(): Int {
+ return dateUtils.daysSince(appInstall.getInstallationTimestamp())
+ }
+}
diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClient.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClient.kt
index 9cfff93cce2b..bb59766bcf47 100644
--- a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClient.kt
+++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClient.kt
@@ -45,11 +45,17 @@ class RealAttributedMetricClient @Inject constructor(
override fun collectEvent(eventName: String) {
appCoroutineScope.launch(dispatcherProvider.io()) {
- if (!metricsState.isActive()) return@launch
- logcat(tag = "AttributedMetrics") {
- "Collecting event $eventName"
+ if (!metricsState.isActive()) {
+ logcat(tag = "AttributedMetrics") {
+ "Discard collect event $eventName, client not active"
+ }
+ return@launch
+ }
+ eventRepository.collectEvent(eventName).also {
+ logcat(tag = "AttributedMetrics") {
+ "Collected event $eventName"
+ }
}
- eventRepository.collectEvent(eventName)
}
}
@@ -59,25 +65,36 @@ class RealAttributedMetricClient @Inject constructor(
): EventStats =
withContext(dispatcherProvider.io()) {
if (!metricsState.isActive()) {
+ logcat(tag = "AttributedMetrics") {
+ "Discard get stats for event $eventName, client not active"
+ }
return@withContext EventStats(daysWithEvents = 0, rollingAverage = 0.0, totalEvents = 0)
}
- logcat(tag = "AttributedMetrics") {
- "Calculating stats for event $eventName over $days days"
+ eventRepository.getEventStats(eventName, days).also {
+ logcat(tag = "AttributedMetrics") {
+ "Returning Stats for Event $eventName($days days): $it"
+ }
}
- eventRepository.getEventStats(eventName, days)
}
// 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()) return@launch
+ if (!metricsState.isActive()) {
+ logcat(tag = "AttributedMetrics") {
+ "Discard pixel, client not active"
+ }
+ return@launch
+ }
val pixelName = metric.getPixelName()
+ val params = metric.getMetricParameters()
val tag = metric.getTag()
- logcat(tag = "AttributedMetrics") {
- "Firing pixel for $pixelName"
- }
val pixelTag = "${pixelName}_$tag"
- pixel.fire(pixelName = pixelName, parameters = metric.getMetricParameters(), type = Unique(pixelTag))
+ pixel.fire(pixelName = pixelName, parameters = params, type = Unique(pixelTag)).also {
+ logcat(tag = "AttributedMetrics") {
+ "Fired pixel $pixelName with params $params"
+ }
+ }
}
}
}
diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/AttributedMetricsDateUtils.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/AttributedMetricsDateUtils.kt
index 02acdbc66352..8fdae5efbafd 100644
--- a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/AttributedMetricsDateUtils.kt
+++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/AttributedMetricsDateUtils.kt
@@ -18,7 +18,11 @@ package com.duckduckgo.app.attributed.metrics.store
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
-import java.time.*
+import java.time.Instant
+import java.time.LocalDate
+import java.time.LocalTime
+import java.time.ZoneId
+import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import javax.inject.Inject
@@ -73,6 +77,18 @@ interface AttributedMetricsDateUtils {
*/
fun daysSince(date: String): Int
+ /**
+ * Calculates the number of days between a given timestamp and the current date in Eastern Time.
+ * Day boundaries are determined using midnight ET.
+ *
+ * @param timestamp The reference timestamp in milliseconds since epoch (Unix timestamp)
+ * @return The number of days between the reference timestamp and current date.
+ * Positive if the reference timestamp is in the past,
+ * negative if it's in the future,
+ * zero if it's today.
+ */
+ fun daysSince(timestamp: Long): Int
+
/**
* Gets a date that is a specified number of days before the current date in Eastern Time.
* Day boundaries are determined using midnight ET.
@@ -97,6 +113,17 @@ class RealAttributedMetricsDateUtils @Inject constructor() : AttributedMetricsDa
return ChronoUnit.DAYS.between(initDate, getCurrentZonedDateTime()).toInt()
}
+ override fun daysSince(timestamp: Long): Int {
+ val etZone = ZoneId.of("America/New_York")
+ val installInstant = Instant.ofEpochMilli(timestamp)
+ val nowInstant = Instant.now()
+
+ val installInEt = installInstant.atZone(etZone)
+ val nowInEt = nowInstant.atZone(etZone)
+
+ return ChronoUnit.DAYS.between(installInEt.toLocalDate(), nowInEt.toLocalDate()).toInt()
+ }
+
override fun getDateMinusDays(days: Int): String = getCurrentZonedDateTime().minusDays(days.toLong()).format(DATE_FORMATTER)
private fun getCurrentZonedDateTime(): ZonedDateTime = ZonedDateTime.now(ET_ZONE)
diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventDao.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventDao.kt
index 12692b0fe8a8..a38349ba16e0 100644
--- a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventDao.kt
+++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventDao.kt
@@ -23,22 +23,25 @@ import androidx.room.Query
@Dao
interface EventDao {
- @Query("SELECT * FROM event_metrics WHERE eventName = :eventName AND day >= :startDay ORDER BY day DESC")
+ @Query("SELECT * FROM event_metrics WHERE eventName = :eventName AND day >= :startDay AND day <= :endDay ORDER BY day DESC")
suspend fun getEventsByNameAndTimeframe(
eventName: String,
startDay: String,
+ endDay: String,
): List
- @Query("SELECT COUNT(DISTINCT day) FROM event_metrics WHERE eventName = :eventName AND day >= :startDay")
+ @Query("SELECT COUNT(DISTINCT day) FROM event_metrics WHERE eventName = :eventName AND day >= :startDay AND day <= :endDay")
suspend fun getDaysWithEvents(
eventName: String,
startDay: String,
+ endDay: String,
): Int
- @Query("SELECT SUM(count) FROM event_metrics WHERE eventName = :eventName AND day >= :startDay")
+ @Query("SELECT SUM(count) FROM event_metrics WHERE eventName = :eventName AND day >= :startDay AND day <= :endDay")
suspend fun getTotalEvents(
eventName: String,
startDay: String,
+ endDay: String,
): Int
@Insert(onConflict = OnConflictStrategy.REPLACE)
diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventRepository.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventRepository.kt
index 83dac54c516f..7e41feeded50 100644
--- a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventRepository.kt
+++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventRepository.kt
@@ -57,9 +57,10 @@ class RealEventRepository @Inject constructor(
days: Int,
): EventStats {
val startDay = attributedMetricsDateUtils.getDateMinusDays(days)
+ val yesterday = attributedMetricsDateUtils.getDateMinusDays(1)
- val daysWithEvents = eventDao.getDaysWithEvents(eventName, startDay)
- val totalEvents = eventDao.getTotalEvents(eventName, startDay) ?: 0
+ val daysWithEvents = eventDao.getDaysWithEvents(eventName, startDay, yesterday)
+ val totalEvents = eventDao.getTotalEvents(eventName, startDay, yesterday) ?: 0
val rollingAverage = if (days > 0) totalEvents.toDouble() / days else 0.0
return EventStats(
diff --git a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/FakeAttributedMetricsDateUtils.kt b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/FakeAttributedMetricsDateUtils.kt
index fa8d30b4cc73..70993726dfce 100644
--- a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/FakeAttributedMetricsDateUtils.kt
+++ b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/FakeAttributedMetricsDateUtils.kt
@@ -17,6 +17,7 @@
package com.duckduckgo.app.attributed.metrics
import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDateUtils
+import java.time.Instant
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
@@ -30,6 +31,11 @@ class FakeAttributedMetricsDateUtils(var testDate: LocalDate) : AttributedMetric
return ChronoUnit.DAYS.between(initDate, getCurrentLocalDate()).toInt()
}
+ override fun daysSince(timestamp: Long): Int {
+ val installDate = Instant.ofEpochMilli(timestamp)
+ return ChronoUnit.DAYS.between(installDate, getCurrentLocalDate()).toInt()
+ }
+
override fun getDateMinusDays(days: Int): String = getCurrentLocalDate().minusDays(days.toLong()).format(DATE_FORMATTER)
private fun getCurrentLocalDate(): LocalDate = testDate
diff --git a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/RealSearchAttributedMetricTest.kt b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/RealSearchAttributedMetricTest.kt
new file mode 100644
index 000000000000..c1da43838230
--- /dev/null
+++ b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/RealSearchAttributedMetricTest.kt
@@ -0,0 +1,253 @@
+/*
+ * 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
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+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.statistics.store.StatisticsDataStore
+import com.duckduckgo.browser.api.install.AppInstall
+import com.duckduckgo.common.test.CoroutineTestRule
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+
+@RunWith(AndroidJUnit4::class)
+class RealSearchAttributedMetricTest {
+
+ @get:Rule
+ val coroutineRule = CoroutineTestRule()
+
+ private val attributedMetricClient: AttributedMetricClient = mock()
+ private val appInstall: AppInstall = mock()
+ private val statisticsDataStore: StatisticsDataStore = mock()
+ private val dateUtils: AttributedMetricsDateUtils = mock()
+
+ private lateinit var testee: RealSearchAttributedMetric
+
+ @Before
+ fun setup() {
+ testee = RealSearchAttributedMetric(
+ appCoroutineScope = coroutineRule.testScope,
+ dispatcherProvider = coroutineRule.testDispatcherProvider,
+ attributedMetricClient = attributedMetricClient,
+ appInstall = appInstall,
+ statisticsDataStore = statisticsDataStore,
+ dateUtils = dateUtils,
+ )
+ }
+
+ @Test
+ fun whenOnSearchThenCollectEventCalled() = runTest {
+ testee.onSearchRetentionAtbRefreshed("old", "new")
+
+ verify(attributedMetricClient).collectEvent("ddg_search")
+ }
+
+ @Test
+ fun whenOnSearchAndAtbNotChangedThenDoNotEmitMetric() = runTest {
+ testee.onSearchRetentionAtbRefreshed("same", "same")
+
+ verify(attributedMetricClient, never()).emitMetric(testee)
+ }
+
+ @Test
+ fun whenGetTagThenReturnSearchRetentionAtb() = runTest {
+ whenever(statisticsDataStore.searchRetentionAtb).thenReturn("v123-1")
+
+ assertEquals("v123-1", testee.getTag())
+ }
+
+ @Test
+ fun whenDaysSinceInstalledLessThan4WThenReturnFirstMonthPixelName() {
+ givenDaysSinceInstalled(15)
+
+ assertEquals("user_average_searches_past_week_first_month", testee.getPixelName())
+ }
+
+ @Test
+ fun whenDaysSinceInstalledMoreThan4WThenReturnRegularPixelName() {
+ givenDaysSinceInstalled(45)
+
+ assertEquals("user_average_searches_past_week", testee.getPixelName())
+ }
+
+ @Test
+ fun whenDaysSinceInstalledIsEndOf4WThenReturnFirstMonthPixelName() {
+ givenDaysSinceInstalled(28)
+
+ assertEquals("user_average_searches_past_week_first_month", testee.getPixelName())
+ }
+
+ @Test
+ fun whenFirstSearchOfDayIfInstallationDayThenDoNotEmitMetric() = runTest {
+ givenDaysSinceInstalled(0)
+
+ testee.onSearchRetentionAtbRefreshed("old", "new")
+
+ verify(attributedMetricClient, never()).emitMetric(testee)
+ }
+
+ @Test
+ fun whenFirstSearchOfDayIfRollingAverageIsZeroThenDoNotEmitMetric() = runTest {
+ givenDaysSinceInstalled(3)
+ whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn(
+ EventStats(
+ totalEvents = 0,
+ daysWithEvents = 0,
+ rollingAverage = 0.0,
+ ),
+ )
+
+ testee.onSearchRetentionAtbRefreshed("old", "new")
+
+ verify(attributedMetricClient, never()).emitMetric(testee)
+ }
+
+ @Test
+ fun whenFirstSearchOfDayIfRollingAverageIsNotZeroThenEmitMetric() = runTest {
+ givenDaysSinceInstalled(3)
+ whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn(
+ EventStats(
+ totalEvents = 16,
+ daysWithEvents = 3,
+ rollingAverage = 5.3,
+ ),
+ )
+
+ testee.onSearchRetentionAtbRefreshed("old", "new")
+
+ verify(attributedMetricClient).emitMetric(testee)
+ }
+
+ @Test
+ fun given7dAverageThenReturnCorrectAverageBucketInParams() = runTest {
+ givenDaysSinceInstalled(7)
+
+ // Map of 7d average to expected bucket
+ val searches7dAvgExpectedBuckets = mapOf(
+ 2.2 to 0,
+ 4.4 to 0,
+ 6.6 to 1,
+ 9.9 to 1,
+ 10.0 to 2,
+ 14.1 to 2,
+ )
+
+ searches7dAvgExpectedBuckets.forEach { (avg, bucket) ->
+ whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn(
+ EventStats(
+ totalEvents = 16,
+ daysWithEvents = 3,
+ rollingAverage = avg,
+ ),
+ )
+
+ val params = testee.getMetricParameters()
+
+ assertEquals(
+ mapOf("count" to bucket.toString()),
+ params,
+ )
+ }
+ }
+
+ @Test
+ fun getMetricParametersAndDaysSinceInstalledLessThan7ThenIncludeDayAverage() = runTest {
+ givenDaysSinceInstalled(5)
+ whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn(
+ EventStats(
+ totalEvents = 16,
+ daysWithEvents = 3,
+ rollingAverage = 5.3,
+ ),
+ )
+
+ val params = testee.getMetricParameters()
+
+ assertEquals("0", params["count"])
+ assertEquals("5", params["dayAverage"])
+ }
+
+ @Test
+ fun getMetricParametersAndDaysSinceInstalledMoreThan7ThenDoNotIncludeDaysSinceInstall() =
+ runTest {
+ givenDaysSinceInstalled(10)
+ whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn(
+ EventStats(
+ totalEvents = 16,
+ daysWithEvents = 3,
+ rollingAverage = 5.3,
+ ),
+ )
+
+ val params = testee.getMetricParameters()
+
+ assertEquals("0", params["count"])
+ assertNull(params["dayAverage"])
+ }
+
+ @Test
+ fun getMetricParametersAndDaysSinceInstalledLessThan7ThenCalculateStatsWithExistingWindow() =
+ runTest {
+ givenDaysSinceInstalled(3)
+ whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn(
+ EventStats(
+ totalEvents = 16,
+ daysWithEvents = 3,
+ rollingAverage = 5.3,
+ ),
+ )
+
+ testee.getMetricParameters()
+
+ verify(attributedMetricClient).getEventStats(eq("ddg_search"), eq(3))
+ }
+
+ @Test
+ fun getMetricParametersAndDaysSinceInstalledIsCompleteDataWindowThenCalculateStats7d() =
+ runTest {
+ givenDaysSinceInstalled(7)
+ whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn(
+ EventStats(
+ totalEvents = 16,
+ daysWithEvents = 3,
+ rollingAverage = 5.3,
+ ),
+ )
+
+ testee.getMetricParameters()
+
+ verify(attributedMetricClient).getEventStats(eq("ddg_search"), eq(7))
+ }
+
+ private fun givenDaysSinceInstalled(days: Int) {
+ whenever(appInstall.getInstallationTimestamp()).thenReturn(123L)
+ whenever(dateUtils.daysSince(123L)).thenReturn(days)
+ }
+}
diff --git a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/RealSearchDaysAttributedMetricTest.kt b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/RealSearchDaysAttributedMetricTest.kt
new file mode 100644
index 000000000000..f694ed6fa079
--- /dev/null
+++ b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/RealSearchDaysAttributedMetricTest.kt
@@ -0,0 +1,224 @@
+/*
+ * 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
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+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.statistics.store.StatisticsDataStore
+import com.duckduckgo.browser.api.install.AppInstall
+import com.duckduckgo.common.test.CoroutineTestRule
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+
+@RunWith(AndroidJUnit4::class)
+class RealSearchDaysAttributedMetricTest {
+
+ @get:Rule
+ val coroutineRule = CoroutineTestRule()
+
+ private val attributedMetricClient: AttributedMetricClient = mock()
+ private val appInstall: AppInstall = mock()
+ private val statisticsDataStore: StatisticsDataStore = mock()
+ private val dateUtils: AttributedMetricsDateUtils = mock()
+ private lateinit var testee: RealSearchDaysAttributedMetric
+
+ @Before
+ fun setup() {
+ testee = RealSearchDaysAttributedMetric(
+ appCoroutineScope = coroutineRule.testScope,
+ dispatcherProvider = coroutineRule.testDispatcherProvider,
+ attributedMetricClient = attributedMetricClient,
+ appInstall = appInstall,
+ statisticsDataStore = statisticsDataStore,
+ dateUtils = dateUtils,
+ )
+ }
+
+ @Test
+ fun whenOnFirstSearchThenCollectEventCalled() {
+ testee.onSearchRetentionAtbRefreshed("old", "new")
+
+ verify(attributedMetricClient).collectEvent("ddg_search_days")
+ }
+
+ @Test
+ fun whenOnEachSearchThenCollectEventCalled() {
+ testee.onSearchRetentionAtbRefreshed("same", "same")
+
+ verify(attributedMetricClient).collectEvent("ddg_search_days")
+ }
+
+ @Test
+ fun whenPixelNameRequestedThenReturnCorrectName() {
+ assertEquals("user_active_past_week", testee.getPixelName())
+ }
+
+ @Test
+ fun whenFirstSearchOfDayIfInstallationDayThenDoNotEmitMetric() = runTest {
+ givenDaysSinceInstalled(0)
+
+ testee.onAppRetentionAtbRefreshed("old", "new")
+
+ verify(attributedMetricClient, never()).emitMetric(testee)
+ }
+
+ @Test
+ fun whenFirstSearchOfDayIfNoDaysWithEventsThenDoNotEmitMetric() = runTest {
+ givenDaysSinceInstalled(3)
+ whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn(
+ EventStats(
+ totalEvents = 0,
+ daysWithEvents = 0,
+ rollingAverage = 0.0,
+ ),
+ )
+
+ testee.onAppRetentionAtbRefreshed("old", "new")
+
+ verify(attributedMetricClient, never()).emitMetric(testee)
+ }
+
+ @Test
+ fun whenFirstSearchOfDayIfHasDaysWithEventsThenEmitMetric() = runTest {
+ givenDaysSinceInstalled(3)
+ whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn(
+ EventStats(
+ totalEvents = 16,
+ daysWithEvents = 3,
+ rollingAverage = 5.3,
+ ),
+ )
+
+ testee.onAppRetentionAtbRefreshed("old", "new")
+
+ verify(attributedMetricClient).emitMetric(testee)
+ }
+
+ @Test
+ fun whenAtbNotChangedThenDoNotEmitMetric() = runTest {
+ givenDaysSinceInstalled(3)
+ whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn(
+ EventStats(
+ totalEvents = 16,
+ daysWithEvents = 3,
+ rollingAverage = 5.3,
+ ),
+ )
+
+ testee.onAppRetentionAtbRefreshed("same", "same")
+
+ verify(attributedMetricClient, never()).emitMetric(testee)
+ }
+
+ @Test
+ fun whenGetTagThenReturnAppRetentionAtb() = runTest {
+ whenever(statisticsDataStore.appRetentionAtb).thenReturn("v123-1")
+
+ assertEquals("v123-1", testee.getTag())
+ }
+
+ @Test
+ fun givenCompleteDataWindowThenReturnCorrectDaysBucketInParams() = runTest {
+ givenDaysSinceInstalled(7)
+
+ // Map of days with events to expected bucket
+ val daysWithEventsExpectedBuckets = mapOf(
+ 0 to 0,
+ 1 to 0,
+ 2 to 0,
+ 3 to 1,
+ 4 to 1,
+ 5 to 2,
+ 6 to 2,
+ 7 to 2,
+ )
+
+ daysWithEventsExpectedBuckets.forEach { (days, bucket) ->
+ whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn(
+ EventStats(
+ totalEvents = days * 5, // Not relevant for this test
+ daysWithEvents = days,
+ rollingAverage = days.toDouble(), // Not relevant for this test
+ ),
+ )
+
+ val params = testee.getMetricParameters()
+
+ assertEquals(
+ mapOf("days" to bucket.toString()),
+ params,
+ )
+ }
+ }
+
+ @Test
+ fun whenDaysSinceInstalledLessThan7ThenIncludeDaysSinceInstalled() = runTest {
+ givenDaysSinceInstalled(5)
+ whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn(
+ EventStats(
+ totalEvents = 25,
+ daysWithEvents = 5,
+ rollingAverage = 5.0,
+ ),
+ )
+
+ val params = testee.getMetricParameters()
+
+ assertEquals(
+ mapOf(
+ "days" to "2",
+ "daysSinceInstalled" to "5",
+ ),
+ params,
+ )
+ }
+
+ @Test
+ fun whenDaysSinceInstalledIs8ThenDoNotIncludeDaysSinceInstalled() = runTest {
+ givenDaysSinceInstalled(7)
+ whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn(
+ EventStats(
+ totalEvents = 25,
+ daysWithEvents = 5,
+ rollingAverage = 5.0,
+ ),
+ )
+
+ val params = testee.getMetricParameters()
+
+ assertEquals(
+ mapOf("days" to "2"),
+ params,
+ )
+ }
+
+ private fun givenDaysSinceInstalled(days: Int) {
+ whenever(appInstall.getInstallationTimestamp()).thenReturn(123L)
+ whenever(dateUtils.daysSince(123L)).thenReturn(days)
+ }
+}
diff --git a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/store/RealEventRepositoryTest.kt b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/store/RealEventRepositoryTest.kt
index d36aeaaeabbc..e0b02c1aec86 100644
--- a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/store/RealEventRepositoryTest.kt
+++ b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/store/RealEventRepositoryTest.kt
@@ -70,7 +70,7 @@ class RealEventRepositoryTest {
repository.collectEvent("test_event")
- val events = eventDao.getEventsByNameAndTimeframe("test_event", "2025-10-03")
+ val events = eventDao.getEventsByNameAndTimeframe("test_event", "2025-10-03", "2025-10-03")
assert(events.size == 1)
assert(events[0].count == 1)
assert(events[0].eventName == "test_event")
@@ -86,7 +86,7 @@ class RealEventRepositoryTest {
repository.collectEvent("test_event")
repository.collectEvent("test_event")
- val events = eventDao.getEventsByNameAndTimeframe("test_event", "2025-10-03")
+ val events = eventDao.getEventsByNameAndTimeframe("test_event", "2025-10-03", "2025-10-03")
assert(events.size == 1)
assert(events[0].count == 3)
}
@@ -104,19 +104,43 @@ class RealEventRepositoryTest {
}
@Test
- fun whenGetEventStatsThenCalculateCorrectly() =
+ fun whenGetEventStatsWithDataOnEveryDayThenCalculateCorrectlyUsingPreviousDaysWindow() =
runTest {
// Setup data for 3 days
- testDateProvider.testDate = LocalDate.of(2025, 10, 3)
+ testDateProvider.testDate = LocalDate.of(2025, 10, 8)
+ eventDao.insertEvent(EventEntity("test_event", count = 3, day = "2025-10-08"))
+ eventDao.insertEvent(EventEntity("test_event", count = 1, day = "2025-10-07"))
+ eventDao.insertEvent(EventEntity("test_event", count = 2, day = "2025-10-06"))
+ eventDao.insertEvent(EventEntity("test_event", count = 3, day = "2025-10-05"))
+ eventDao.insertEvent(EventEntity("test_event", count = 1, day = "2025-10-04"))
eventDao.insertEvent(EventEntity("test_event", count = 2, day = "2025-10-03"))
eventDao.insertEvent(EventEntity("test_event", count = 3, day = "2025-10-02"))
eventDao.insertEvent(EventEntity("test_event", count = 1, day = "2025-10-01"))
val stats = repository.getEventStats("test_event", days = 7)
- assert(stats.daysWithEvents == 3)
- assert(stats.totalEvents == 6)
- assert(stats.rollingAverage == 6.0 / 7.0)
+ assert(stats.daysWithEvents == 7)
+ assert(stats.totalEvents == 13)
+ assert(stats.rollingAverage == 13.0 / 7.0)
+ }
+
+ @Test
+ fun whenGetEventStatsWithMissingDaysDataThenCalculateCorrectlyUsingPreviousDaysWindow() =
+ runTest {
+ // Setup data for 3 days
+ testDateProvider.testDate = LocalDate.of(2025, 10, 8)
+ eventDao.insertEvent(EventEntity("test_event", count = 3, day = "2025-10-08"))
+ eventDao.insertEvent(EventEntity("test_event", count = 1, day = "2025-10-07"))
+ eventDao.insertEvent(EventEntity("test_event", count = 2, day = "2025-10-06"))
+ eventDao.insertEvent(EventEntity("test_event", count = 1, day = "2025-10-04"))
+ eventDao.insertEvent(EventEntity("test_event", count = 2, day = "2025-10-03"))
+ eventDao.insertEvent(EventEntity("test_event", count = 1, day = "2025-10-01"))
+
+ val stats = repository.getEventStats("test_event", days = 7)
+
+ assert(stats.daysWithEvents == 5)
+ assert(stats.totalEvents == 7)
+ assert(stats.rollingAverage == 7.0 / 7.0)
}
@Test
@@ -130,7 +154,7 @@ class RealEventRepositoryTest {
testDateProvider.testDate = LocalDate.of(2025, 10, 3)
repository.deleteOldEvents(olderThanDays = 5)
- val remainingEvents = eventDao.getEventsByNameAndTimeframe("test_event", "2025-09-03")
+ val remainingEvents = eventDao.getEventsByNameAndTimeframe("test_event", "2025-09-03", "2025-10-03")
assert(remainingEvents.size == 2)
assert(remainingEvents.none { it.day == "2025-09-03" })
}