Skip to content

Commit 673c16c

Browse files
committed
Adding search related attributed metrics
1 parent 6a6bd27 commit 673c16c

File tree

10 files changed

+852
-27
lines changed

10 files changed

+852
-27
lines changed

app/src/internal/java/com/duckduckgo/app/statistics/StatisticsInternalInfoView.kt

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,7 @@ class StatisticsInternalInfoView @JvmOverloads constructor(
5959
}
6060

6161
binding.searchAtbSave.setOnClickListener {
62-
store.searchRetentionAtb?.let {
63-
store.searchRetentionAtb = binding.searchAtb.text
64-
}
62+
store.searchRetentionAtb = binding.searchAtb.text
6563
Toast.makeText(this.context, "Search Atb updated", Toast.LENGTH_SHORT).show()
6664
}
6765

attributed-metrics/attributed-metrics-impl/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ dependencies {
3232
implementation project(path: ':di')
3333
implementation project(path: ':app-build-config-api')
3434
implementation project(path: ':statistics-api')
35+
implementation project(path: ':browser-api')
3536

3637
implementation KotlinX.coroutines.core
3738
implementation KotlinX.coroutines.android
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.app.attributed.metrics
18+
19+
import com.duckduckgo.app.attributed.metrics.api.AttributedMetric
20+
import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient
21+
import com.duckduckgo.app.attributed.metrics.api.EventStats
22+
import com.duckduckgo.app.di.AppCoroutineScope
23+
import com.duckduckgo.app.statistics.api.AtbLifecyclePlugin
24+
import com.duckduckgo.browser.api.UserBrowserProperties
25+
import com.duckduckgo.common.utils.DispatcherProvider
26+
import com.duckduckgo.di.scopes.AppScope
27+
import com.squareup.anvil.annotations.ContributesMultibinding
28+
import dagger.SingleInstanceIn
29+
import kotlinx.coroutines.CoroutineScope
30+
import kotlinx.coroutines.launch
31+
import logcat.logcat
32+
import javax.inject.Inject
33+
34+
@ContributesMultibinding(AppScope::class, AtbLifecyclePlugin::class)
35+
@ContributesMultibinding(AppScope::class, AttributedMetric::class)
36+
@SingleInstanceIn(AppScope::class)
37+
class RealSearchAttributedMetric @Inject constructor(
38+
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
39+
private val dispatcherProvider: DispatcherProvider,
40+
private val attributedMetricClient: AttributedMetricClient,
41+
private val userBrowserProperties: UserBrowserProperties,
42+
) : AttributedMetric, AtbLifecyclePlugin {
43+
44+
companion object {
45+
private const val EVENT_NAME = "ddg_search"
46+
private const val FIRST_MONTH_PIXEL = "user_average_searches_past_week_first_month"
47+
private const val PAST_WEEK_PIXEL_NAME = "user_average_searches_past_week"
48+
private const val DAYS_WINDOW = 7
49+
private const val FIRST_MONTH_DAY_THRESHOLD = 28 // we consider 1 month after 4 weeks
50+
private val SEARCH_BUCKETS = arrayOf(5, 9) // TODO: default bucket, remote bucket implementation will happen in future PRs
51+
}
52+
53+
override fun onSearchRetentionAtbRefreshed(
54+
oldAtb: String,
55+
newAtb: String,
56+
) {
57+
appCoroutineScope.launch(dispatcherProvider.io()) {
58+
attributedMetricClient.collectEvent(EVENT_NAME)
59+
60+
if (oldAtb == newAtb) {
61+
logcat(tag = "AttributedMetrics") {
62+
"SearchCount7d: Skip emitting, atb not changed"
63+
}
64+
return@launch
65+
}
66+
if (shouldSendPixel().not()) {
67+
logcat(tag = "AttributedMetrics") {
68+
"SearchCount7d: Skip emitting, not enough data or no events"
69+
}
70+
return@launch
71+
}
72+
attributedMetricClient.emitMetric(this@RealSearchAttributedMetric)
73+
}
74+
}
75+
76+
override fun getPixelName(): String = when (userBrowserProperties.daysSinceInstalled()) {
77+
in 0..FIRST_MONTH_DAY_THRESHOLD -> FIRST_MONTH_PIXEL
78+
else -> PAST_WEEK_PIXEL_NAME
79+
}
80+
81+
override suspend fun getMetricParameters(): Map<String, String> {
82+
val stats = getEventStats()
83+
val params = mutableMapOf(
84+
"count" to getBucketValue(stats.rollingAverage.toInt()).toString(),
85+
)
86+
if (!hasCompleteDataWindow()) {
87+
params["dayAverage"] = userBrowserProperties.daysSinceInstalled().toString()
88+
}
89+
return params
90+
}
91+
92+
private fun getBucketValue(searches: Int): Int {
93+
return SEARCH_BUCKETS.indexOfFirst { bucket -> searches <= bucket }.let { index ->
94+
if (index == -1) SEARCH_BUCKETS.size else index
95+
}
96+
}
97+
98+
private suspend fun shouldSendPixel(): Boolean {
99+
if (userBrowserProperties.daysSinceInstalled() == 0L) {
100+
// installation day, we don't emit
101+
return false
102+
}
103+
104+
val eventStats = getEventStats()
105+
if (eventStats.daysWithEvents == 0 || eventStats.rollingAverage == 0.0) {
106+
// no events, nothing to emit
107+
return false
108+
}
109+
110+
return true
111+
}
112+
113+
private suspend fun getEventStats(): EventStats {
114+
val stats = if (hasCompleteDataWindow()) {
115+
attributedMetricClient.getEventStats(EVENT_NAME, DAYS_WINDOW)
116+
} else {
117+
attributedMetricClient.getEventStats(EVENT_NAME, userBrowserProperties.daysSinceInstalled().toInt())
118+
}
119+
120+
return stats
121+
}
122+
123+
private fun hasCompleteDataWindow(): Boolean {
124+
val daysSinceInstalled = userBrowserProperties.daysSinceInstalled().toInt()
125+
return daysSinceInstalled >= DAYS_WINDOW
126+
}
127+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.app.attributed.metrics
18+
19+
import com.duckduckgo.app.attributed.metrics.api.AttributedMetric
20+
import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient
21+
import com.duckduckgo.app.di.AppCoroutineScope
22+
import com.duckduckgo.app.statistics.api.AtbLifecyclePlugin
23+
import com.duckduckgo.browser.api.UserBrowserProperties
24+
import com.duckduckgo.common.utils.DispatcherProvider
25+
import com.duckduckgo.di.scopes.AppScope
26+
import com.squareup.anvil.annotations.ContributesMultibinding
27+
import dagger.SingleInstanceIn
28+
import kotlinx.coroutines.CoroutineScope
29+
import kotlinx.coroutines.launch
30+
import logcat.logcat
31+
import javax.inject.Inject
32+
33+
@ContributesMultibinding(AppScope::class, AtbLifecyclePlugin::class)
34+
@ContributesMultibinding(AppScope::class, AttributedMetric::class)
35+
@SingleInstanceIn(AppScope::class)
36+
class RealSearchDaysAttributedMetric @Inject constructor(
37+
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
38+
private val dispatcherProvider: DispatcherProvider,
39+
private val attributedMetricClient: AttributedMetricClient,
40+
private val userBrowserProperties: UserBrowserProperties,
41+
) : AttributedMetric, AtbLifecyclePlugin {
42+
43+
companion object {
44+
private const val EVENT_NAME = "ddg_search_days"
45+
private const val PIXEL_NAME = "user_active_past_week"
46+
private const val DAYS_WINDOW = 7
47+
private val DAYS_BUCKETS = arrayOf(2, 4) // TODO: default bucket, remote bucket implementation will happen in future PRs
48+
}
49+
50+
override fun onSearchRetentionAtbRefreshed(
51+
oldAtb: String,
52+
newAtb: String,
53+
) {
54+
appCoroutineScope.launch(dispatcherProvider.io()) {
55+
attributedMetricClient.collectEvent(EVENT_NAME)
56+
if (oldAtb == newAtb) {
57+
logcat(tag = "AttributedMetrics") {
58+
"SearchDays: Skip emitting atb not changed"
59+
}
60+
return@launch
61+
}
62+
if (shouldSendPixel().not()) {
63+
logcat(tag = "AttributedMetrics") {
64+
"SearchDays: Skip emitting, not enough data or no events"
65+
}
66+
return@launch
67+
}
68+
attributedMetricClient.emitMetric(this@RealSearchDaysAttributedMetric)
69+
}
70+
}
71+
72+
override fun getPixelName(): String = PIXEL_NAME
73+
74+
override suspend fun getMetricParameters(): Map<String, String> {
75+
val daysSinceInstalled = userBrowserProperties.daysSinceInstalled().toInt()
76+
val hasCompleteDataWindow = daysSinceInstalled >= DAYS_WINDOW
77+
val stats = attributedMetricClient.getEventStats(EVENT_NAME, DAYS_WINDOW)
78+
val params = mutableMapOf(
79+
"days" to getBucketValue(stats.daysWithEvents).toString(),
80+
)
81+
if (!hasCompleteDataWindow) {
82+
params["daysSinceInstalled"] = daysSinceInstalled.toString()
83+
}
84+
return params
85+
}
86+
87+
private fun getBucketValue(days: Int): Int {
88+
return DAYS_BUCKETS.indexOfFirst { bucket -> days <= bucket }.let { index ->
89+
if (index == -1) DAYS_BUCKETS.size else index
90+
}
91+
}
92+
93+
private suspend fun shouldSendPixel(): Boolean {
94+
if (userBrowserProperties.daysSinceInstalled() == 0L) {
95+
// installation day, we don't emit
96+
return false
97+
}
98+
99+
val eventStats = attributedMetricClient.getEventStats(EVENT_NAME, DAYS_WINDOW)
100+
if (eventStats.daysWithEvents == 0) {
101+
// no events, nothing to emit
102+
return false
103+
}
104+
105+
return true
106+
}
107+
}

attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClient.kt

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,17 @@ class RealAttributedMetricClient @Inject constructor(
4444

4545
override fun collectEvent(eventName: String) {
4646
appCoroutineScope.launch(dispatcherProvider.io()) {
47-
if (!metricsState.isActive()) return@launch
48-
logcat(tag = "AttributedMetrics") {
49-
"Collecting event $eventName"
47+
if (!metricsState.isActive()) {
48+
logcat(tag = "AttributedMetrics") {
49+
"Discard collect event $eventName, client not active"
50+
}
51+
return@launch
52+
}
53+
eventRepository.collectEvent(eventName).also {
54+
logcat(tag = "AttributedMetrics") {
55+
"Collected event $eventName"
56+
}
5057
}
51-
eventRepository.collectEvent(eventName)
5258
}
5359
}
5460

@@ -58,23 +64,34 @@ class RealAttributedMetricClient @Inject constructor(
5864
): EventStats =
5965
withContext(dispatcherProvider.io()) {
6066
if (!metricsState.isActive()) {
67+
logcat(tag = "AttributedMetrics") {
68+
"Discard get stats for event $eventName, client not active"
69+
}
6170
return@withContext EventStats(daysWithEvents = 0, rollingAverage = 0.0, totalEvents = 0)
6271
}
63-
logcat(tag = "AttributedMetrics") {
64-
"Calculating stats for event $eventName over $days days"
72+
eventRepository.getEventStats(eventName, days).also {
73+
logcat(tag = "AttributedMetrics") {
74+
"Returning Stats for Event $eventName($days days): $it"
75+
}
6576
}
66-
eventRepository.getEventStats(eventName, days)
6777
}
6878

6979
// TODO: Pending adding default attributed metrics and removing default prefix from pixel names
7080
override fun emitMetric(metric: AttributedMetric) {
7181
appCoroutineScope.launch(dispatcherProvider.io()) {
72-
if (!metricsState.isActive()) return@launch
82+
if (!metricsState.isActive()) {
83+
logcat(tag = "AttributedMetrics") {
84+
"Discard pixel, client not active"
85+
}
86+
return@launch
87+
}
7388
val pixelName = metric.getPixelName()
74-
logcat(tag = "AttributedMetrics") {
75-
"Firing pixel for $pixelName"
89+
val params = metric.getMetricParameters()
90+
pixel.fire(pixelName = pixelName, parameters = params).also {
91+
logcat(tag = "AttributedMetrics") {
92+
"Fired pixel $pixelName with params $params"
93+
}
7694
}
77-
pixel.fire(pixelName = pixelName, parameters = metric.getMetricParameters())
7895
}
7996
}
8097
}

attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventDao.kt

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,22 +23,25 @@ import androidx.room.Query
2323

2424
@Dao
2525
interface EventDao {
26-
@Query("SELECT * FROM event_metrics WHERE eventName = :eventName AND day >= :startDay ORDER BY day DESC")
26+
@Query("SELECT * FROM event_metrics WHERE eventName = :eventName AND day >= :startDay AND day <= :endDay ORDER BY day DESC")
2727
suspend fun getEventsByNameAndTimeframe(
2828
eventName: String,
2929
startDay: String,
30+
endDay: String,
3031
): List<EventEntity>
3132

32-
@Query("SELECT COUNT(DISTINCT day) FROM event_metrics WHERE eventName = :eventName AND day >= :startDay")
33+
@Query("SELECT COUNT(DISTINCT day) FROM event_metrics WHERE eventName = :eventName AND day >= :startDay AND day <= :endDay")
3334
suspend fun getDaysWithEvents(
3435
eventName: String,
3536
startDay: String,
37+
endDay: String,
3638
): Int
3739

38-
@Query("SELECT SUM(count) FROM event_metrics WHERE eventName = :eventName AND day >= :startDay")
40+
@Query("SELECT SUM(count) FROM event_metrics WHERE eventName = :eventName AND day >= :startDay AND day <= :endDay")
3941
suspend fun getTotalEvents(
4042
eventName: String,
4143
startDay: String,
44+
endDay: String,
4245
): Int
4346

4447
@Insert(onConflict = OnConflictStrategy.REPLACE)

attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventRepository.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,10 @@ class RealEventRepository @Inject constructor(
5757
days: Int,
5858
): EventStats {
5959
val startDay = attributedMetricsDateUtils.getDateMinusDays(days)
60+
val yesterDay = attributedMetricsDateUtils.getDateMinusDays(1)
6061

61-
val daysWithEvents = eventDao.getDaysWithEvents(eventName, startDay)
62-
val totalEvents = eventDao.getTotalEvents(eventName, startDay) ?: 0
62+
val daysWithEvents = eventDao.getDaysWithEvents(eventName, startDay, yesterDay)
63+
val totalEvents = eventDao.getTotalEvents(eventName, startDay, yesterDay) ?: 0
6364
val rollingAverage = if (days > 0) totalEvents.toDouble() / days else 0.0
6465

6566
return EventStats(

0 commit comments

Comments
 (0)