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" }) }