Skip to content

Commit a8eb8a7

Browse files
committed
Adding search related attributed metrics
1 parent b0525e6 commit a8eb8a7

File tree

14 files changed

+904
-29
lines changed

14 files changed

+904
-29
lines changed

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

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,14 +54,21 @@ class StatisticsInternalInfoView @JvmOverloads constructor(
5454
AndroidSupportInjection.inject(this)
5555
super.onAttachedToWindow()
5656

57+
binding.retentionAtb.apply {
58+
text = store.appRetentionAtb ?: "unknown"
59+
}
60+
61+
binding.retentionAtbSave.setOnClickListener {
62+
store.appRetentionAtb = binding.retentionAtb.text
63+
Toast.makeText(this.context, "App Retention Atb updated", Toast.LENGTH_SHORT).show()
64+
}
65+
5766
binding.searchAtb.apply {
5867
text = store.searchRetentionAtb ?: "unknown"
5968
}
6069

6170
binding.searchAtbSave.setOnClickListener {
62-
store.searchRetentionAtb?.let {
63-
store.searchRetentionAtb = binding.searchAtb.text
64-
}
71+
store.searchRetentionAtb = binding.searchAtb.text
6572
Toast.makeText(this.context, "Search Atb updated", Toast.LENGTH_SHORT).show()
6673
}
6774

app/src/internal/res/layout/view_statistics_attributed_metrics.xml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,22 @@
2121
android:orientation="vertical"
2222
android:padding="16dp">
2323

24+
<com.duckduckgo.common.ui.view.text.DaxTextInput
25+
android:id="@+id/retentionAtb"
26+
android:layout_width="match_parent"
27+
android:layout_height="wrap_content"
28+
android:layout_marginHorizontal="16dp"
29+
android:hint="App Retention Atb"
30+
app:endIcon="@drawable/ic_copy_24" />
31+
32+
<com.duckduckgo.common.ui.view.button.DaxButtonPrimary
33+
android:id="@+id/retentionAtbSave"
34+
android:layout_width="wrap_content"
35+
android:layout_height="wrap_content"
36+
android:layout_marginHorizontal="16dp"
37+
android:layout_gravity="end"
38+
android:text="Save" />
39+
2440
<com.duckduckgo.common.ui.view.text.DaxTextInput
2541
android:id="@+id/searchAtb"
2642
android:layout_width="match_parent"

app/src/main/java/com/duckduckgo/app/global/install/AppInstallRepository.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,11 @@ package com.duckduckgo.app.global.install
1818

1919
import com.duckduckgo.browser.api.install.AppInstall
2020
import com.duckduckgo.di.scopes.AppScope
21+
import com.squareup.anvil.annotations.ContributesBinding
2122
import dagger.SingleInstanceIn
2223
import javax.inject.Inject
2324

25+
@ContributesBinding(AppScope::class)
2426
@SingleInstanceIn(AppScope::class)
2527
class AppInstallRepository @Inject constructor(
2628
private val appInstallStore: AppInstallStore,

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: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
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.attributed.metrics.store.AttributedMetricsDateUtils
23+
import com.duckduckgo.app.di.AppCoroutineScope
24+
import com.duckduckgo.app.statistics.api.AtbLifecyclePlugin
25+
import com.duckduckgo.app.statistics.store.StatisticsDataStore
26+
import com.duckduckgo.browser.api.install.AppInstall
27+
import com.duckduckgo.common.utils.DispatcherProvider
28+
import com.duckduckgo.di.scopes.AppScope
29+
import com.squareup.anvil.annotations.ContributesMultibinding
30+
import dagger.SingleInstanceIn
31+
import kotlinx.coroutines.CoroutineScope
32+
import kotlinx.coroutines.launch
33+
import logcat.logcat
34+
import javax.inject.Inject
35+
36+
/**
37+
* Search Count 7d avg Attributed Metric
38+
* Trigger: on first search of day
39+
* Type: Daily pixel
40+
* Report: 7d rolling average of searches (bucketed value). Not sent if count is 0.
41+
* Specs: https://app.asana.com/1/137249556945/project/1206716555947156/task/1211313432282643?focus=true
42+
*/
43+
@ContributesMultibinding(AppScope::class, AtbLifecyclePlugin::class)
44+
@ContributesMultibinding(AppScope::class, AttributedMetric::class)
45+
@SingleInstanceIn(AppScope::class)
46+
class RealSearchAttributedMetric @Inject constructor(
47+
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
48+
private val dispatcherProvider: DispatcherProvider,
49+
private val attributedMetricClient: AttributedMetricClient,
50+
private val appInstall: AppInstall,
51+
private val statisticsDataStore: StatisticsDataStore,
52+
private val dateUtils: AttributedMetricsDateUtils,
53+
) : AttributedMetric, AtbLifecyclePlugin {
54+
55+
companion object {
56+
private const val EVENT_NAME = "ddg_search"
57+
private const val FIRST_MONTH_PIXEL = "user_average_searches_past_week_first_month"
58+
private const val PAST_WEEK_PIXEL_NAME = "user_average_searches_past_week"
59+
private const val DAYS_WINDOW = 7
60+
private const val FIRST_MONTH_DAY_THRESHOLD = 28 // we consider 1 month after 4 weeks
61+
private val SEARCH_BUCKETS = arrayOf(
62+
5,
63+
9,
64+
) // TODO: default bucket, remote bucket implementation will happen in future PRs
65+
}
66+
67+
override fun onSearchRetentionAtbRefreshed(
68+
oldAtb: String,
69+
newAtb: String,
70+
) {
71+
appCoroutineScope.launch(dispatcherProvider.io()) {
72+
attributedMetricClient.collectEvent(EVENT_NAME)
73+
74+
if (oldAtb == newAtb) {
75+
logcat(tag = "AttributedMetrics") {
76+
"SearchCount7d: Skip emitting, atb not changed"
77+
}
78+
return@launch
79+
}
80+
if (shouldSendPixel().not()) {
81+
logcat(tag = "AttributedMetrics") {
82+
"SearchCount7d: Skip emitting, not enough data or no events"
83+
}
84+
return@launch
85+
}
86+
attributedMetricClient.emitMetric(this@RealSearchAttributedMetric)
87+
}
88+
}
89+
90+
override fun getPixelName(): String = when (daysSinceInstalled()) {
91+
in 0..FIRST_MONTH_DAY_THRESHOLD -> FIRST_MONTH_PIXEL
92+
else -> PAST_WEEK_PIXEL_NAME
93+
}
94+
95+
override suspend fun getMetricParameters(): Map<String, String> {
96+
val stats = getEventStats()
97+
val params = mutableMapOf(
98+
"count" to getBucketValue(stats.rollingAverage.toInt()).toString(),
99+
)
100+
if (!hasCompleteDataWindow()) {
101+
params["dayAverage"] = daysSinceInstalled().toString()
102+
}
103+
return params
104+
}
105+
106+
override suspend fun getTag(): String {
107+
// Daily metric, on first search of day
108+
// rely on searchRetentionAtb as mirrors the metric trigger event
109+
return statisticsDataStore.searchRetentionAtb
110+
?: "no-atb" // should not happen, but just in case
111+
}
112+
113+
private fun getBucketValue(searches: Int): Int {
114+
return SEARCH_BUCKETS.indexOfFirst { bucket -> searches <= bucket }.let { index ->
115+
if (index == -1) SEARCH_BUCKETS.size else index
116+
}
117+
}
118+
119+
private suspend fun shouldSendPixel(): Boolean {
120+
if (daysSinceInstalled() <= 0) {
121+
// installation day, we don't emit
122+
return false
123+
}
124+
125+
val eventStats = getEventStats()
126+
if (eventStats.daysWithEvents == 0 || eventStats.rollingAverage == 0.0) {
127+
// no events, nothing to emit
128+
return false
129+
}
130+
131+
return true
132+
}
133+
134+
private suspend fun getEventStats(): EventStats {
135+
val stats = if (hasCompleteDataWindow()) {
136+
attributedMetricClient.getEventStats(EVENT_NAME, DAYS_WINDOW)
137+
} else {
138+
attributedMetricClient.getEventStats(
139+
EVENT_NAME,
140+
daysSinceInstalled(),
141+
)
142+
}
143+
144+
return stats
145+
}
146+
147+
private fun hasCompleteDataWindow(): Boolean {
148+
val daysSinceInstalled = daysSinceInstalled()
149+
return daysSinceInstalled >= DAYS_WINDOW
150+
}
151+
152+
private fun daysSinceInstalled(): Int {
153+
return dateUtils.daysSince(appInstall.getInstallationTimestamp())
154+
}
155+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
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.store.AttributedMetricsDateUtils
22+
import com.duckduckgo.app.di.AppCoroutineScope
23+
import com.duckduckgo.app.statistics.api.AtbLifecyclePlugin
24+
import com.duckduckgo.app.statistics.store.StatisticsDataStore
25+
import com.duckduckgo.browser.api.install.AppInstall
26+
import com.duckduckgo.common.utils.DispatcherProvider
27+
import com.duckduckgo.di.scopes.AppScope
28+
import com.squareup.anvil.annotations.ContributesMultibinding
29+
import dagger.SingleInstanceIn
30+
import kotlinx.coroutines.CoroutineScope
31+
import kotlinx.coroutines.launch
32+
import logcat.logcat
33+
import javax.inject.Inject
34+
35+
/**
36+
* Search Days Attributed Metric
37+
* Trigger: on app start
38+
* Type: Daily pixel
39+
* Report: Bucketed value, how many days user searched last 7d. Not sent if count is 0.
40+
* Specs: https://app.asana.com/1/137249556945/project/1206716555947156/task/1211301604929609?focus=true
41+
*/
42+
@ContributesMultibinding(AppScope::class, AtbLifecyclePlugin::class)
43+
@ContributesMultibinding(AppScope::class, AttributedMetric::class)
44+
@SingleInstanceIn(AppScope::class)
45+
class RealSearchDaysAttributedMetric @Inject constructor(
46+
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
47+
private val dispatcherProvider: DispatcherProvider,
48+
private val attributedMetricClient: AttributedMetricClient,
49+
private val appInstall: AppInstall,
50+
private val statisticsDataStore: StatisticsDataStore,
51+
private val dateUtils: AttributedMetricsDateUtils,
52+
) : AttributedMetric, AtbLifecyclePlugin {
53+
54+
companion object {
55+
private const val EVENT_NAME = "ddg_search_days"
56+
private const val PIXEL_NAME = "user_active_past_week"
57+
private const val DAYS_WINDOW = 7
58+
private val DAYS_BUCKETS = arrayOf(
59+
2,
60+
4,
61+
) // TODO: default bucket, remote bucket implementation will happen in future PRs
62+
}
63+
64+
override fun onAppRetentionAtbRefreshed(
65+
oldAtb: String,
66+
newAtb: String,
67+
) {
68+
appCoroutineScope.launch(dispatcherProvider.io()) {
69+
if (oldAtb == newAtb) {
70+
logcat(tag = "AttributedMetrics") {
71+
"SearchDays: Skip emitting atb not changed"
72+
}
73+
return@launch
74+
}
75+
if (shouldSendPixel().not()) {
76+
logcat(tag = "AttributedMetrics") {
77+
"SearchDays: Skip emitting, not enough data or no events"
78+
}
79+
return@launch
80+
}
81+
attributedMetricClient.emitMetric(this@RealSearchDaysAttributedMetric)
82+
}
83+
}
84+
85+
override fun onSearchRetentionAtbRefreshed(
86+
oldAtb: String,
87+
newAtb: String,
88+
) {
89+
appCoroutineScope.launch(dispatcherProvider.io()) {
90+
attributedMetricClient.collectEvent(EVENT_NAME)
91+
}
92+
}
93+
94+
override fun getPixelName(): String = PIXEL_NAME
95+
96+
override suspend fun getMetricParameters(): Map<String, String> {
97+
val daysSinceInstalled = daysSinceInstalled()
98+
val hasCompleteDataWindow = daysSinceInstalled >= DAYS_WINDOW
99+
val stats = attributedMetricClient.getEventStats(EVENT_NAME, DAYS_WINDOW)
100+
val params = mutableMapOf(
101+
"days" to getBucketValue(stats.daysWithEvents).toString(),
102+
)
103+
if (!hasCompleteDataWindow) {
104+
params["daysSinceInstalled"] = daysSinceInstalled.toString()
105+
}
106+
return params
107+
}
108+
109+
override suspend fun getTag(): String {
110+
// Daily metric, on App start
111+
// rely on appRetentionAtb as mirrors the metric trigger event
112+
return statisticsDataStore.appRetentionAtb ?: "no-atb" // should not happen, but just in case
113+
}
114+
115+
private fun getBucketValue(days: Int): Int {
116+
return DAYS_BUCKETS.indexOfFirst { bucket -> days <= bucket }.let { index ->
117+
if (index == -1) DAYS_BUCKETS.size else index
118+
}
119+
}
120+
121+
private suspend fun shouldSendPixel(): Boolean {
122+
if (daysSinceInstalled() <= 0) {
123+
// installation day, we don't emit
124+
return false
125+
}
126+
127+
val eventStats = attributedMetricClient.getEventStats(EVENT_NAME, DAYS_WINDOW)
128+
if (eventStats.daysWithEvents == 0) {
129+
// no events, nothing to emit
130+
return false
131+
}
132+
133+
return true
134+
}
135+
136+
private fun daysSinceInstalled(): Int {
137+
return dateUtils.daysSince(appInstall.getInstallationTimestamp())
138+
}
139+
}

0 commit comments

Comments
 (0)