Skip to content

Commit 6a65b73

Browse files
committed
Adding search related attributed metrics
1 parent 2106b08 commit 6a65b73

File tree

5 files changed

+783
-2
lines changed

5 files changed

+783
-2
lines changed
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
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 RealSearchAttributedMetric @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"
45+
private const val FIRST_MONTH_PIXEL = "user_average_searches_past_week_first_month"
46+
private const val PAST_WEEK_PIXEL_NAME = "user_average_searches_past_week"
47+
private const val DAYS_WINDOW = 7
48+
private const val FIRST_MONTH_DAY_THRESHOLD = 28 // we consider 1 month after 4 weeks
49+
private val SEARCH_BUCKETS = arrayOf(5, 9) // TODO: default bucket, remote bucket implementation will happen in future PRs
50+
}
51+
52+
override fun onSearchRetentionAtbRefreshed(
53+
oldAtb: String,
54+
newAtb: String,
55+
) {
56+
appCoroutineScope.launch(dispatcherProvider.io()) {
57+
attributedMetricClient.collectEvent(EVENT_NAME)
58+
59+
logcat(tag = "AttributedMetrics") {
60+
"Check if should send search attributed metric"
61+
}
62+
if (oldAtb == newAtb) {
63+
logcat(tag = "AttributedMetrics") {
64+
"Skip emitting search attributed metric, atb not changed"
65+
}
66+
return@launch
67+
}
68+
if (shouldSendPixel().not()) {
69+
logcat(tag = "AttributedMetrics") {
70+
"Skip emitting search attributed metric, not enough data or no events"
71+
}
72+
return@launch
73+
}
74+
logcat(tag = "AttributedMetrics") {
75+
"Emit search attributed metric"
76+
}
77+
attributedMetricClient.emitMetric(this@RealSearchAttributedMetric)
78+
}
79+
}
80+
81+
override fun getPixelName(): String = when (userBrowserProperties.daysSinceInstalled()) {
82+
in 0..FIRST_MONTH_DAY_THRESHOLD -> FIRST_MONTH_PIXEL
83+
else -> PAST_WEEK_PIXEL_NAME
84+
}
85+
86+
override suspend fun getMetricParameters(): Map<String, String> {
87+
val stats = attributedMetricClient.getEventStats(EVENT_NAME, DAYS_WINDOW)
88+
val daysOfDataAvailable = userBrowserProperties.daysSinceInstalled().toInt() - 1
89+
val hasCompleteDataWindow = DAYS_WINDOW <= daysOfDataAvailable
90+
return mutableMapOf(
91+
"count" to getBucketValue(stats.rollingAverage.toInt()).toString(),
92+
).apply {
93+
if (!hasCompleteDataWindow) {
94+
put("dayAverage", daysOfDataAvailable.toString())
95+
}
96+
}
97+
}
98+
99+
private fun getBucketValue(searches: Int): Int {
100+
return SEARCH_BUCKETS.indexOfFirst { bucket -> searches <= bucket }.let { index ->
101+
if (index == -1) SEARCH_BUCKETS.size else index
102+
}
103+
}
104+
105+
private suspend fun shouldSendPixel(): Boolean {
106+
if (userBrowserProperties.daysSinceInstalled() == 0L) {
107+
// installation day, we don't emit
108+
return false
109+
}
110+
111+
val eventStats = attributedMetricClient.getEventStats(EVENT_NAME, DAYS_WINDOW)
112+
if (eventStats.daysWithEvents == 0 || eventStats.rollingAverage.toInt() == 0) {
113+
// no events, nothing to emit
114+
return false
115+
}
116+
117+
return true
118+
}
119+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
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+
57+
logcat(tag = "AttributedMetrics") {
58+
"Check if should send ${this@RealSearchDaysAttributedMetric.javaClass.name} attributed metric"
59+
}
60+
if (oldAtb == newAtb) {
61+
logcat(tag = "AttributedMetrics") {
62+
"Skip emitting ${this@RealSearchDaysAttributedMetric.javaClass.name}, atb not changed"
63+
}
64+
return@launch
65+
}
66+
if (shouldSendPixel().not()) {
67+
logcat(tag = "AttributedMetrics") {
68+
"Skip emitting ${this@RealSearchDaysAttributedMetric.javaClass.name}, not enough data or no events"
69+
}
70+
return@launch
71+
}
72+
logcat(tag = "AttributedMetrics") {
73+
"try emit ${this@RealSearchDaysAttributedMetric.javaClass.name}"
74+
}
75+
attributedMetricClient.emitMetric(this@RealSearchDaysAttributedMetric)
76+
}
77+
}
78+
79+
override fun getPixelName(): String = PIXEL_NAME
80+
81+
override suspend fun getMetricParameters(): Map<String, String> {
82+
val stats = attributedMetricClient.getEventStats(EVENT_NAME, DAYS_WINDOW)
83+
val daysSinceInstalled = userBrowserProperties.daysSinceInstalled()
84+
val params = mutableMapOf(
85+
"days" to getBucketValue(stats.daysWithEvents).toString(),
86+
)
87+
if (daysSinceInstalled < DAYS_WINDOW + 1) {
88+
params["daysSinceInstalled"] = daysSinceInstalled.toString()
89+
}
90+
return params
91+
}
92+
93+
private fun getBucketValue(days: Int): Int {
94+
return DAYS_BUCKETS.indexOfFirst { bucket -> days <= bucket }.let { index ->
95+
if (index == -1) DAYS_BUCKETS.size else index
96+
}
97+
}
98+
99+
private suspend fun shouldSendPixel(): Boolean {
100+
if (userBrowserProperties.daysSinceInstalled() == 0L) {
101+
// installation day, we don't emit
102+
return false
103+
}
104+
105+
val eventStats = attributedMetricClient.getEventStats(EVENT_NAME, DAYS_WINDOW)
106+
if (eventStats.daysWithEvents == 0) {
107+
// no events, nothing to emit
108+
return false
109+
}
110+
111+
return true
112+
}
113+
}

0 commit comments

Comments
 (0)