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..db7f378e00b7 100644 --- a/app/src/internal/java/com/duckduckgo/app/statistics/StatisticsInternalInfoView.kt +++ b/app/src/internal/java/com/duckduckgo/app/statistics/StatisticsInternalInfoView.kt @@ -59,9 +59,7 @@ class StatisticsInternalInfoView @JvmOverloads constructor( } 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/main/java/com/duckduckgo/app/browser/search/SearchAttributedMetric.kt b/app/src/main/java/com/duckduckgo/app/browser/search/SearchAttributedMetric.kt new file mode 100644 index 000000000000..363fe2938210 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/search/SearchAttributedMetric.kt @@ -0,0 +1,127 @@ +/* + * 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.browser.search + +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.di.AppCoroutineScope +import com.duckduckgo.app.statistics.api.AtbLifecyclePlugin +import com.duckduckgo.browser.api.UserBrowserProperties +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 + +@ContributesMultibinding(AppScope::class, AtbLifecyclePlugin::class) +@ContributesMultibinding(AppScope::class, AttributedMetric::class) +@SingleInstanceIn(AppScope::class) +class RealSearchAttributedMetric @Inject constructor( + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider, + private val attributedMetricClient: AttributedMetricClient, + private val userBrowserProperties: UserBrowserProperties, +) : AttributedMetric, AtbLifecyclePlugin { + + 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 DAYS_WINDOW = 7 + private const val FIRST_MONTH_DAY_THRESHOLD = 28 // we consider 1 month after 4 weeks + private val SEARCH_BUCKETS = arrayOf(5, 9) // TODO: default bucket, remote bucket implementation will happen in future PRs + } + + override fun onSearchRetentionAtbRefreshed( + oldAtb: String, + newAtb: String, + ) { + appCoroutineScope.launch(dispatcherProvider.io()) { + attributedMetricClient.collectEvent(EVENT_NAME) + + if (oldAtb == newAtb) { + logcat(tag = "AttributedMetrics") { + "SearchCount7d: Skip emitting, atb not changed" + } + return@launch + } + if (shouldSendPixel().not()) { + logcat(tag = "AttributedMetrics") { + "SearchCount7d: Skip emitting, not enough data or no events" + } + return@launch + } + attributedMetricClient.emitMetric(this@RealSearchAttributedMetric) + } + } + + override fun getPixelName(): String = when (userBrowserProperties.daysSinceInstalled()) { + in 0..FIRST_MONTH_DAY_THRESHOLD -> 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"] = userBrowserProperties.daysSinceInstalled().toString() + } + return params + } + + 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 (userBrowserProperties.daysSinceInstalled() == 0L) { + // 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, userBrowserProperties.daysSinceInstalled().toInt()) + } + + return stats + } + + private fun hasCompleteDataWindow(): Boolean { + val daysSinceInstalled = userBrowserProperties.daysSinceInstalled().toInt() + return daysSinceInstalled >= DAYS_WINDOW + } +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/search/SearchDaysAttributedMetric.kt b/app/src/main/java/com/duckduckgo/app/browser/search/SearchDaysAttributedMetric.kt new file mode 100644 index 000000000000..c88259704e1d --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/search/SearchDaysAttributedMetric.kt @@ -0,0 +1,107 @@ +/* + * 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.browser.search + +import com.duckduckgo.app.attributed.metrics.api.AttributedMetric +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.app.statistics.api.AtbLifecyclePlugin +import com.duckduckgo.browser.api.UserBrowserProperties +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 + +@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 userBrowserProperties: UserBrowserProperties, +) : 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 onSearchRetentionAtbRefreshed( + oldAtb: String, + newAtb: String, + ) { + appCoroutineScope.launch(dispatcherProvider.io()) { + attributedMetricClient.collectEvent(EVENT_NAME) + 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 getPixelName(): String = PIXEL_NAME + + override suspend fun getMetricParameters(): Map { + val daysSinceInstalled = userBrowserProperties.daysSinceInstalled().toInt() + 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 + } + + 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 (userBrowserProperties.daysSinceInstalled() == 0L) { + // 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 + } +} diff --git a/app/src/test/java/com/duckduckgo/app/browser/search/RealSearchAttributedMetricTest.kt b/app/src/test/java/com/duckduckgo/app/browser/search/RealSearchAttributedMetricTest.kt new file mode 100644 index 000000000000..208bfa69f8a4 --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/browser/search/RealSearchAttributedMetricTest.kt @@ -0,0 +1,299 @@ +/* + * 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.browser.search + +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.browser.api.UserBrowserProperties +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 userBrowserProperties: UserBrowserProperties = mock() + + private lateinit var testee: RealSearchAttributedMetric + + @Before + fun setup() { + testee = RealSearchAttributedMetric( + appCoroutineScope = coroutineRule.testScope, + dispatcherProvider = coroutineRule.testDispatcherProvider, + attributedMetricClient = attributedMetricClient, + userBrowserProperties = userBrowserProperties, + ) + } + + @Test + fun whenOnSearchThenCollectEventCalled() { + testee.onSearchRetentionAtbRefreshed("old", "new") + + verify(attributedMetricClient).collectEvent("ddg_search") + } + + @Test + fun whenDaysSinceInstalledLessThan4WThenReturnFirstMonthPixelName() { + whenever(userBrowserProperties.daysSinceInstalled()).thenReturn(15) + + assertEquals("user_average_searches_past_week_first_month", testee.getPixelName()) + } + + @Test + fun whenDaysSinceInstalledMoreThan4WThenReturnRegularPixelName() { + whenever(userBrowserProperties.daysSinceInstalled()).thenReturn(45) + + assertEquals("user_average_searches_past_week", testee.getPixelName()) + } + + @Test + fun whenDaysSinceInstalledIsEndOf4WThenReturnFirstMonthPixelName() { + whenever(userBrowserProperties.daysSinceInstalled()).thenReturn(28) + + assertEquals("user_average_searches_past_week_first_month", testee.getPixelName()) + } + + @Test + fun whenFirstSearchOfDayIfInstallationDayThenDoNotEmitMetric() = runTest { + whenever(userBrowserProperties.daysSinceInstalled()).thenReturn(0) + + testee.onSearchRetentionAtbRefreshed("old", "new") + + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test + fun whenFirstSearchOfDayIfRollingAverageIsZeroThenDoNotEmitMetric() = runTest { + whenever(userBrowserProperties.daysSinceInstalled()).thenReturn(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 { + whenever(userBrowserProperties.daysSinceInstalled()).thenReturn(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 whenRollingAverageIs4ThenReturnBucket0() = runTest { + givenCompleteDataWindow() + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 16, + daysWithEvents = 3, + rollingAverage = 4.0, + ), + ) + + val params = testee.getMetricParameters() + + assertEquals("0", params["count"]) + assertNull(params["dayAverage"]) + } + + @Test + fun whenRollingAverageIs5ThenReturnBucket0() = runTest { + givenCompleteDataWindow() + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 16, + daysWithEvents = 3, + rollingAverage = 5.0, + ), + ) + + val params = testee.getMetricParameters() + + assertEquals("0", params["count"]) + assertNull(params["dayAverage"]) + } + + @Test + fun whenRollingAverageIs6ThenReturnBucket1() = runTest { + givenCompleteDataWindow() + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 16, + daysWithEvents = 3, + rollingAverage = 6.0, + ), + ) + + val params = testee.getMetricParameters() + + assertEquals("1", params["count"]) + assertNull(params["dayAverage"]) + } + + @Test + fun whenRollingAverageIs9ThenReturnBucket1() = runTest { + givenCompleteDataWindow() + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 16, + daysWithEvents = 3, + rollingAverage = 9.0, + ), + ) + + val params = testee.getMetricParameters() + + assertEquals("1", params["count"]) + assertNull(params["dayAverage"]) + } + + @Test + fun whenRollingAverageIs10ThenReturnBucket2() = runTest { + givenCompleteDataWindow() + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 16, + daysWithEvents = 3, + rollingAverage = 10.0, + ), + ) + + val params = testee.getMetricParameters() + + assertEquals("2", params["count"]) + assertNull(params["dayAverage"]) + } + + @Test + fun getMetricParametersAndDaysSinceInstalledLessThan7ThenIncludeDayAverage() = runTest { + whenever(userBrowserProperties.daysSinceInstalled()).thenReturn(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 { + whenever(userBrowserProperties.daysSinceInstalled()).thenReturn(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 getMetricParametersAndDaysSinceInstalledIsCompleteDataWindowThenDoNotIncludeDaysSinceInstall() = runTest { + givenCompleteDataWindow() + 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 { + whenever(userBrowserProperties.daysSinceInstalled()).thenReturn(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 { + givenCompleteDataWindow() + 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 givenCompleteDataWindow() { + whenever(userBrowserProperties.daysSinceInstalled()).thenReturn(7) + } +} diff --git a/app/src/test/java/com/duckduckgo/app/browser/search/RealSearchDaysAttributedMetricTest.kt b/app/src/test/java/com/duckduckgo/app/browser/search/RealSearchDaysAttributedMetricTest.kt new file mode 100644 index 000000000000..dc0cfab66d3c --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/browser/search/RealSearchDaysAttributedMetricTest.kt @@ -0,0 +1,248 @@ +/* + * 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.browser.search + +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.browser.api.UserBrowserProperties +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.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 userBrowserProperties: UserBrowserProperties = mock() + private lateinit var testee: RealSearchDaysAttributedMetric + + @Before + fun setup() { + testee = RealSearchDaysAttributedMetric( + appCoroutineScope = coroutineRule.testScope, + dispatcherProvider = coroutineRule.testDispatcherProvider, + attributedMetricClient = attributedMetricClient, + userBrowserProperties = userBrowserProperties, + ) + } + + @Test + fun whenOnSearchThenCollectEventCalled() { + testee.onSearchRetentionAtbRefreshed("old", "new") + + verify(attributedMetricClient).collectEvent("ddg_search_days") + } + + @Test + fun whenPixelNameRequestedThenReturnCorrectName() { + assertEquals("user_active_past_week", testee.getPixelName()) + } + + @Test + fun whenFirstSearchOfDayIfInstallationDayThenDoNotEmitMetric() = runTest { + whenever(userBrowserProperties.daysSinceInstalled()).thenReturn(0) + + testee.onSearchRetentionAtbRefreshed("old", "new") + + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test + fun whenFirstSearchOfDayIfNoDaysWithEventsThenDoNotEmitMetric() = runTest { + whenever(userBrowserProperties.daysSinceInstalled()).thenReturn(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 whenFirstSearchOfDayIfHasDaysWithEventsThenEmitMetric() = runTest { + whenever(userBrowserProperties.daysSinceInstalled()).thenReturn(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 whenAtbNotChangedThenDoNotEmitMetric() = runTest { + whenever(userBrowserProperties.daysSinceInstalled()).thenReturn(3) + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 16, + daysWithEvents = 3, + rollingAverage = 5.3, + ), + ) + + testee.onSearchRetentionAtbRefreshed("same", "same") + + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test + fun whenDaysWithEventsIs0ThenReturnBucket0() = runTest { + givenCompleteDataWindow() + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 0, + daysWithEvents = 0, + rollingAverage = 0.0, + ), + ) + + val params = testee.getMetricParameters() + + assertEquals("0", params["days"]) + assertNull(params["daysSinceInstalled"]) + } + + @Test + fun whenDaysWithEventsIs2ThenReturnBucket0() = runTest { + givenCompleteDataWindow() + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 10, + daysWithEvents = 2, + rollingAverage = 5.0, + ), + ) + + val params = testee.getMetricParameters() + + assertEquals("0", params["days"]) + assertNull(params["daysSinceInstalled"]) + } + + @Test + fun whenDaysWithEventsIs3ThenReturnBucket1() = runTest { + givenCompleteDataWindow() + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 15, + daysWithEvents = 3, + rollingAverage = 5.3, + ), + ) + + val params = testee.getMetricParameters() + + assertEquals("1", params["days"]) + assertNull(params["daysSinceInstalled"]) + } + + @Test + fun whenDaysWithEventsIs4ThenReturnBucket1() = runTest { + givenCompleteDataWindow() + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 20, + daysWithEvents = 4, + rollingAverage = 5.0, + ), + ) + + val params = testee.getMetricParameters() + + assertEquals("1", params["days"]) + assertNull(params["daysSinceInstalled"]) + } + + @Test + fun whenDaysWithEventsIs5ThenReturnBucket2() = runTest { + givenCompleteDataWindow() + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 25, + daysWithEvents = 5, + rollingAverage = 5.0, + ), + ) + + val params = testee.getMetricParameters() + + assertEquals("2", params["days"]) + assertNull(params["daysSinceInstalled"]) + } + + @Test + fun whenDaysSinceInstalledLessThan7ThenIncludeDaysSinceInstalled() = runTest { + whenever(userBrowserProperties.daysSinceInstalled()).thenReturn(5) + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 25, + daysWithEvents = 5, + rollingAverage = 5.0, + ), + ) + + val params = testee.getMetricParameters() + + assertEquals("2", params["days"]) + assertEquals("5", params["daysSinceInstalled"]) + } + + @Test + fun whenDaysSinceInstalledIs8ThenDoNotIncludeDaysSinceInstalled() = runTest { + givenCompleteDataWindow() + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 25, + daysWithEvents = 5, + rollingAverage = 5.0, + ), + ) + + val params = testee.getMetricParameters() + + assertEquals("2", params["days"]) + assertNull(params["daysSinceInstalled"]) + } + + private fun givenCompleteDataWindow() { + whenever(userBrowserProperties.daysSinceInstalled()).thenReturn(7) + } +} 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 9b9ce426244c..64dabb5d9c44 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 @@ -44,11 +44,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) } } @@ -58,23 +64,34 @@ 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() - logcat(tag = "AttributedMetrics") { - "Firing pixel for $pixelName" + val params = metric.getMetricParameters() + pixel.fire(pixelName = pixelName, parameters = params).also { + logcat(tag = "AttributedMetrics") { + "Fired pixel $pixelName with params $params" + } } - pixel.fire(pixelName = pixelName, parameters = metric.getMetricParameters()) } } } 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..c9b018878fa5 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/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" }) }