Skip to content

Commit ec70268

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

File tree

9 files changed

+851
-27
lines changed

9 files changed

+851
-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

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.browser.search
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+
}
Lines changed: 107 additions & 0 deletions
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.browser.search
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+
}

0 commit comments

Comments
 (0)