Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,21 @@ class StatisticsInternalInfoView @JvmOverloads constructor(
AndroidSupportInjection.inject(this)
super.onAttachedToWindow()

binding.retentionAtb.apply {
text = store.appRetentionAtb ?: "unknown"
}

binding.retentionAtbSave.setOnClickListener {
store.appRetentionAtb = binding.retentionAtb.text
Toast.makeText(this.context, "App Retention Atb updated", Toast.LENGTH_SHORT).show()
}

binding.searchAtb.apply {
text = store.searchRetentionAtb ?: "unknown"
}

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()
}

Expand Down
16 changes: 16 additions & 0 deletions app/src/internal/res/layout/view_statistics_attributed_metrics.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,22 @@
android:orientation="vertical"
android:padding="16dp">

<com.duckduckgo.common.ui.view.text.DaxTextInput
android:id="@+id/retentionAtb"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:hint="App Retention Atb"
app:endIcon="@drawable/ic_copy_24" />

<com.duckduckgo.common.ui.view.button.DaxButtonPrimary
android:id="@+id/retentionAtbSave"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_gravity="end"
android:text="Save" />

<com.duckduckgo.common.ui.view.text.DaxTextInput
android:id="@+id/searchAtb"
android:layout_width="match_parent"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ package com.duckduckgo.app.global.install

import com.duckduckgo.browser.api.install.AppInstall
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import dagger.SingleInstanceIn
import javax.inject.Inject

@ContributesBinding(AppScope::class)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missed this bit on a different PR. "Unrelated"

@SingleInstanceIn(AppScope::class)
class AppInstallRepository @Inject constructor(
private val appInstallStore: AppInstallStore,
Expand Down
1 change: 1 addition & 0 deletions attributed-metrics/attributed-metrics-impl/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ dependencies {
implementation project(path: ':di')
implementation project(path: ':app-build-config-api')
implementation project(path: ':statistics-api')
implementation project(path: ':browser-api')

implementation KotlinX.coroutines.core
implementation KotlinX.coroutines.android
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/*
* 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.attributed.metrics

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.attributed.metrics.store.AttributedMetricsDateUtils
import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.app.statistics.api.AtbLifecyclePlugin
import com.duckduckgo.app.statistics.store.StatisticsDataStore
import com.duckduckgo.browser.api.install.AppInstall
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

/**
* Search Count 7d avg Attributed Metric
* Trigger: on first search of day
* Type: Daily pixel
* Report: 7d rolling average of searches (bucketed value). Not sent if count is 0.
* Specs: https://app.asana.com/1/137249556945/project/1206716555947156/task/1211313432282643?focus=true
*/
@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 appInstall: AppInstall,
private val statisticsDataStore: StatisticsDataStore,
private val dateUtils: AttributedMetricsDateUtils,
) : 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 (daysSinceInstalled()) {
in 0..FIRST_MONTH_DAY_THRESHOLD -> FIRST_MONTH_PIXEL
else -> PAST_WEEK_PIXEL_NAME
}

override suspend fun getMetricParameters(): Map<String, String> {
val stats = getEventStats()
val params = mutableMapOf(
"count" to getBucketValue(stats.rollingAverage.toInt()).toString(),
)
if (!hasCompleteDataWindow()) {
params["dayAverage"] = daysSinceInstalled().toString()
}
return params
}

override suspend fun getTag(): String {
// Daily metric, on first search of day
// rely on searchRetentionAtb as mirrors the metric trigger event
return statisticsDataStore.searchRetentionAtb
?: "no-atb" // should not happen, but just in case
}

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 (daysSinceInstalled() <= 0) {
// 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,
daysSinceInstalled(),
)
}

return stats
}

private fun hasCompleteDataWindow(): Boolean {
val daysSinceInstalled = daysSinceInstalled()
return daysSinceInstalled >= DAYS_WINDOW
}

private fun daysSinceInstalled(): Int {
return dateUtils.daysSince(appInstall.getInstallationTimestamp())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/*
* 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.attributed.metrics

import com.duckduckgo.app.attributed.metrics.api.AttributedMetric
import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient
import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDateUtils
import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.app.statistics.api.AtbLifecyclePlugin
import com.duckduckgo.app.statistics.store.StatisticsDataStore
import com.duckduckgo.browser.api.install.AppInstall
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

/**
* Search Days Attributed Metric
* Trigger: on app start
* Type: Daily pixel
* Report: Bucketed value, how many days user searched last 7d. Not sent if count is 0.
* Specs: https://app.asana.com/1/137249556945/project/1206716555947156/task/1211301604929609?focus=true
*/
@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 appInstall: AppInstall,
private val statisticsDataStore: StatisticsDataStore,
private val dateUtils: AttributedMetricsDateUtils,
) : 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 onAppRetentionAtbRefreshed(
oldAtb: String,
newAtb: String,
) {
appCoroutineScope.launch(dispatcherProvider.io()) {
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 onSearchRetentionAtbRefreshed(
oldAtb: String,
newAtb: String,
) {
appCoroutineScope.launch(dispatcherProvider.io()) {
attributedMetricClient.collectEvent(EVENT_NAME)
}
}

override fun getPixelName(): String = PIXEL_NAME

override suspend fun getMetricParameters(): Map<String, String> {
val daysSinceInstalled = daysSinceInstalled()
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
}

override suspend fun getTag(): String {
// Daily metric, on App start
// rely on appRetentionAtb as mirrors the metric trigger event
return statisticsDataStore.appRetentionAtb ?: "no-atb" // should not happen, but just in case
}

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 (daysSinceInstalled() <= 0) {
// 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
}

private fun daysSinceInstalled(): Int {
return dateUtils.daysSince(appInstall.getInstallationTimestamp())
}
}
Loading
Loading