Skip to content

Commit 893affe

Browse files
committed
implements duckaiusage attributed metric
1 parent 27f3c83 commit 893affe

File tree

7 files changed

+487
-3
lines changed

7 files changed

+487
-3
lines changed

attributed-metrics/attributed-metrics-internal/src/main/java/com/duckduckgo/app/attributed/metrics/internal/ui/AttributedMetricsDevSettingsActivity.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ class AttributedMetricsDevSettingsActivity : DuckDuckGoActivity() {
7979
binding.addAdClickTestEventsButton.setOnClickListener {
8080
addAdClickTestEvents()
8181
}
82+
binding.addAiPromptsTestEventsButton.setOnClickListener {
83+
addAiUsageTestEvents()
84+
}
8285
lifecycleScope.launch {
8386
binding.clientActive.setSecondaryText(if (attributedMetricsState.isActive()) "Yes" else "No")
8487
binding.returningUser.setSecondaryText(if (appBuildConfig.isAppReinstall()) "Yes" else "No")
@@ -105,6 +108,16 @@ class AttributedMetricsDevSettingsActivity : DuckDuckGoActivity() {
105108
Toast.makeText(this@AttributedMetricsDevSettingsActivity, "Test events added", Toast.LENGTH_SHORT).show()
106109
}
107110
}
111+
112+
private fun addAiUsageTestEvents() {
113+
lifecycleScope.launch {
114+
repeat(10) { daysAgo ->
115+
val date = dateUtils.getDateMinusDays(daysAgo)
116+
eventDao.insertEvent(EventEntity(eventName = "submit_prompt", count = 1, day = date))
117+
}
118+
Toast.makeText(this@AttributedMetricsDevSettingsActivity, "Test events added", Toast.LENGTH_SHORT).show()
119+
}
120+
}
108121
}
109122

110123
data object MainAttributedMetricsSettings : GlobalActivityStarter.ActivityParams {

attributed-metrics/attributed-metrics-internal/src/main/res/layout/activity_attributed_metrics_dev_settings.xml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@
5050
app:primaryText="Add Ad clicks Events"
5151
app:secondaryText="Adds 1 ad click events for last 10days" />
5252

53+
<com.duckduckgo.common.ui.view.listitem.TwoLineListItem
54+
android:id="@+id/addAiPromptsTestEventsButton"
55+
android:layout_width="match_parent"
56+
android:layout_height="wrap_content"
57+
android:layout_marginHorizontal="16dp"
58+
app:primaryText="Add Duck AI usage Events"
59+
app:secondaryText="Adds 1 Duck AI events for last 10days" />
60+
5361
<LinearLayout
5462
android:id="@+id/settingsContainer"
5563
android:layout_width="match_parent"

duckchat/duckchat-impl/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ dependencies {
4141
implementation project(':history-api')
4242
implementation project(':saved-sites-api')
4343
implementation project(':remote-messaging-api')
44+
implementation project(':attributed-metrics-api')
4445

4546
anvil project(path: ':anvil-compiler')
4647
implementation project(path: ':anvil-annotations')

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/helper/DuckChatJSHelper.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import com.duckduckgo.duckchat.impl.ChatState.HIDE
2222
import com.duckduckgo.duckchat.impl.ChatState.SHOW
2323
import com.duckduckgo.duckchat.impl.DuckChatInternal
2424
import com.duckduckgo.duckchat.impl.ReportMetric
25+
import com.duckduckgo.duckchat.impl.metric.DuckAiMetricCollector
2526
import com.duckduckgo.duckchat.impl.pixel.DuckChatPixels
2627
import com.duckduckgo.duckchat.impl.store.DuckChatDataStore
2728
import com.duckduckgo.js.messaging.api.JsCallbackData
@@ -45,6 +46,7 @@ class RealDuckChatJSHelper @Inject constructor(
4546
private val duckChat: DuckChatInternal,
4647
private val duckChatPixels: DuckChatPixels,
4748
private val dataStore: DuckChatDataStore,
49+
private val duckAiMetricCollector: DuckAiMetricCollector,
4850
) : DuckChatJSHelper {
4951
override suspend fun processJsCallbackMessage(
5052
featureName: String,
@@ -106,7 +108,12 @@ class RealDuckChatJSHelper @Inject constructor(
106108
REPORT_METRIC -> {
107109
ReportMetric
108110
.fromValue(data?.optString("metricName"))
109-
?.let { reportMetric -> duckChatPixels.sendReportMetricPixel(reportMetric) }
111+
?.let { reportMetric ->
112+
duckChatPixels.sendReportMetricPixel(reportMetric)
113+
if (reportMetric == ReportMetric.USER_DID_SUBMIT_PROMPT || reportMetric == ReportMetric.USER_DID_SUBMIT_FIRST_PROMPT) {
114+
duckAiMetricCollector.onMessageSent()
115+
}
116+
}
110117
null
111118
}
112119

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
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.duckchat.impl.metric
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.AttributedMetricConfig
22+
import com.duckduckgo.app.attributed.metrics.api.EventStats
23+
import com.duckduckgo.app.attributed.metrics.api.MetricBucket
24+
import com.duckduckgo.app.di.AppCoroutineScope
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.ContributesBinding
29+
import com.squareup.anvil.annotations.ContributesMultibinding
30+
import dagger.SingleInstanceIn
31+
import kotlinx.coroutines.CoroutineScope
32+
import kotlinx.coroutines.CoroutineStart.LAZY
33+
import kotlinx.coroutines.Deferred
34+
import kotlinx.coroutines.async
35+
import kotlinx.coroutines.launch
36+
import logcat.logcat
37+
import java.time.Instant
38+
import java.time.ZoneId
39+
import java.time.temporal.ChronoUnit
40+
import javax.inject.Inject
41+
import kotlin.math.roundToInt
42+
43+
interface DuckAiMetricCollector {
44+
fun onMessageSent()
45+
}
46+
47+
@ContributesMultibinding(AppScope::class, AttributedMetric::class)
48+
@ContributesBinding(AppScope::class, DuckAiMetricCollector::class)
49+
@SingleInstanceIn(AppScope::class)
50+
class DuckAiAttributedMetric @Inject constructor(
51+
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
52+
private val dispatcherProvider: DispatcherProvider,
53+
private val attributedMetricClient: AttributedMetricClient,
54+
private val appInstall: AppInstall,
55+
private val attributedMetricConfig: AttributedMetricConfig,
56+
) : AttributedMetric, DuckAiMetricCollector {
57+
58+
companion object {
59+
private const val EVENT_NAME = "submit_prompt"
60+
private const val PIXEL_NAME = "user_average_duck_ai_usage_past_week"
61+
private const val FEATURE_TOGGLE_NAME = "aiUsageAvg"
62+
private const val FEATURE_EMIT_TOGGLE_NAME = "canEmitAIUsageAvg"
63+
private const val DAYS_WINDOW = 7
64+
}
65+
66+
private val isEnabled: Deferred<Boolean> = appCoroutineScope.async(start = LAZY) {
67+
getToggle(FEATURE_TOGGLE_NAME)?.isEnabled() ?: false
68+
}
69+
70+
private val canEmit: Deferred<Boolean> = appCoroutineScope.async(start = LAZY) {
71+
getToggle(FEATURE_EMIT_TOGGLE_NAME)?.isEnabled() ?: false
72+
}
73+
74+
private val bucketConfig: Deferred<MetricBucket> = appCoroutineScope.async(start = LAZY) {
75+
attributedMetricConfig.getBucketConfiguration()[FEATURE_TOGGLE_NAME] ?: MetricBucket(
76+
buckets = listOf(5, 9),
77+
version = 0,
78+
)
79+
}
80+
81+
override fun getPixelName(): String = PIXEL_NAME
82+
83+
override suspend fun getMetricParameters(): Map<String, String> {
84+
val stats = getEventStats()
85+
val params = mutableMapOf(
86+
"count" to getBucketValue(stats.rollingAverage.roundToInt()).toString(),
87+
)
88+
if (!hasCompleteDataWindow()) {
89+
params["dayAverage"] = daysSinceInstalled().toString()
90+
}
91+
return params
92+
}
93+
94+
override suspend fun getTag(): String {
95+
return daysSinceInstalled().toString()
96+
}
97+
98+
override fun onMessageSent() {
99+
appCoroutineScope.launch(dispatcherProvider.io()) {
100+
if (!isEnabled.await()) return@launch
101+
attributedMetricClient.collectEvent(EVENT_NAME)
102+
if (shouldSendPixel().not()) {
103+
logcat(tag = "AttributedMetrics") {
104+
"DuckAiUsage: Skip emitting, not enough data or no events"
105+
}
106+
return@launch
107+
}
108+
109+
if (canEmit.await()) {
110+
attributedMetricClient.emitMetric(this@DuckAiAttributedMetric)
111+
}
112+
}
113+
}
114+
115+
private suspend fun getBucketValue(avg: Int): Int {
116+
val buckets = bucketConfig.await().buckets
117+
return buckets.indexOfFirst { bucket -> avg <= bucket }.let { index ->
118+
if (index == -1) buckets.size else index
119+
}
120+
}
121+
122+
private suspend fun shouldSendPixel(): Boolean {
123+
if (daysSinceInstalled() <= 0) {
124+
// installation day, we don't emit
125+
return false
126+
}
127+
128+
val eventStats = getEventStats()
129+
if (eventStats.daysWithEvents == 0 || eventStats.rollingAverage == 0.0) {
130+
// no events, nothing to emit
131+
return false
132+
}
133+
134+
return true
135+
}
136+
137+
private fun hasCompleteDataWindow(): Boolean {
138+
val daysSinceInstalled = daysSinceInstalled()
139+
return daysSinceInstalled >= DAYS_WINDOW
140+
}
141+
142+
private suspend fun getEventStats(): EventStats {
143+
val daysSinceInstall = daysSinceInstalled()
144+
val stats = if (daysSinceInstall >= DAYS_WINDOW) {
145+
attributedMetricClient.getEventStats(EVENT_NAME, DAYS_WINDOW)
146+
} else {
147+
attributedMetricClient.getEventStats(EVENT_NAME, daysSinceInstall)
148+
}
149+
150+
return stats
151+
}
152+
153+
private fun daysSinceInstalled(): Int {
154+
val etZone = ZoneId.of("America/New_York")
155+
val installInstant = Instant.ofEpochMilli(appInstall.getInstallationTimestamp())
156+
val nowInstant = Instant.now()
157+
158+
val installInEt = installInstant.atZone(etZone)
159+
val nowInEt = nowInstant.atZone(etZone)
160+
161+
return ChronoUnit.DAYS.between(installInEt.toLocalDate(), nowInEt.toLocalDate()).toInt()
162+
}
163+
164+
private suspend fun getToggle(toggleName: String) =
165+
attributedMetricConfig.metricsToggles().firstOrNull { toggle ->
166+
toggle.featureName().name == toggleName
167+
}
168+
}

0 commit comments

Comments
 (0)