Skip to content

Commit 3db2951

Browse files
authored
add Input Screen session usage metric (#6738)
Task/Issue URL: https://app.asana.com/1/137249556945/project/1208671518894266/task/1211245493245694?focus=true ### Description Adds an Input Screen session summary pixel. ### Steps to test this PR - [x] Go to Settings -> AI Features and enable the experimental address bar. - [x] Go back to browser and click on the omnibar. - [x] Perform search. - [x] Click on the omnibar. - [x] Switch to Duck.ai and submit a prompt. - [x] Click on the omnibar. - [x] Perform search. - [x] Switch to another app. - [x] Come back to the browser. - [x] Verify `m_aichat_experimental_omnibar_session_summary` is sent with `searches_in_session=2` and `prompts_in_session=1`. - [x] Perform search. - [x] Switch to another app. - [x] Come back to the browser. - [x] Verify `m_aichat_experimental_omnibar_session_summary` is sent with `searches_in_session=1` and `prompts_in_session=0`. - [x] Go to Settings -> AI Features and disable the experimental address bar. - [x] Go back to browser and click on the omnibar. - [x] Perform search. - [x] Switch to another app. - [x] Come back to the browser. - [x] Verify `m_aichat_experimental_omnibar_session_summary` **is not** sent.
1 parent 29ca141 commit 3db2951

File tree

5 files changed

+322
-0
lines changed

5 files changed

+322
-0
lines changed
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
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.inputscreen.ui.metrics.usage
18+
19+
import androidx.lifecycle.LifecycleOwner
20+
import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver
21+
import com.duckduckgo.app.statistics.pixels.Pixel
22+
import com.duckduckgo.di.scopes.AppScope
23+
import com.duckduckgo.duckchat.api.DuckAiFeatureState
24+
import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName
25+
import com.squareup.anvil.annotations.ContributesBinding
26+
import com.squareup.anvil.annotations.ContributesMultibinding
27+
import dagger.SingleInstanceIn
28+
import java.util.concurrent.atomic.AtomicInteger
29+
import javax.inject.Inject
30+
31+
interface InputScreenSessionUsageMetric {
32+
fun onSearchSubmitted()
33+
fun onPromptSubmitted()
34+
}
35+
36+
@ContributesMultibinding(
37+
scope = AppScope::class,
38+
boundType = MainProcessLifecycleObserver::class,
39+
)
40+
@ContributesBinding(
41+
scope = AppScope::class,
42+
boundType = InputScreenSessionUsageMetric::class,
43+
)
44+
@SingleInstanceIn(scope = AppScope::class)
45+
class InputScreenSessionUsageMetricImpl @Inject constructor(
46+
private val pixel: Pixel,
47+
private val duckAiFeatureState: DuckAiFeatureState,
48+
) : MainProcessLifecycleObserver, InputScreenSessionUsageMetric {
49+
50+
private companion object {
51+
private const val PIXEL_PARAM_SEARCH_COUNT = "searches_in_session"
52+
private const val PIXEL_PARAM_PROMPT_COUNT = "prompts_in_session"
53+
}
54+
55+
private val searchCounter: AtomicInteger = AtomicInteger()
56+
private val promptCounter: AtomicInteger = AtomicInteger()
57+
58+
override fun onStop(owner: LifecycleOwner) {
59+
if (!duckAiFeatureState.showInputScreen.value) {
60+
return
61+
}
62+
val searchCount = searchCounter.getAndSet(0)
63+
val promptCount = promptCounter.getAndSet(0)
64+
val params = mapOf(
65+
PIXEL_PARAM_SEARCH_COUNT to searchCount.toString(),
66+
PIXEL_PARAM_PROMPT_COUNT to promptCount.toString(),
67+
)
68+
pixel.enqueueFire(
69+
pixel = DuckChatPixelName.DUCK_CHAT_EXPERIMENTAL_OMNIBAR_SESSION_SUMMARY,
70+
parameters = params,
71+
)
72+
}
73+
74+
override fun onSearchSubmitted() {
75+
searchCounter.incrementAndGet()
76+
}
77+
78+
override fun onPromptSubmitted() {
79+
promptCounter.incrementAndGet()
80+
}
81+
}

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/inputscreen/ui/viewmodel/InputScreenViewModel.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import com.duckduckgo.duckchat.impl.inputscreen.ui.command.InputFieldCommand
4646
import com.duckduckgo.duckchat.impl.inputscreen.ui.command.SearchCommand
4747
import com.duckduckgo.duckchat.impl.inputscreen.ui.command.SearchCommand.ShowRemoveSearchSuggestionDialog
4848
import com.duckduckgo.duckchat.impl.inputscreen.ui.metrics.discovery.InputScreenDiscoveryFunnel
49+
import com.duckduckgo.duckchat.impl.inputscreen.ui.metrics.usage.InputScreenSessionUsageMetric
4950
import com.duckduckgo.duckchat.impl.inputscreen.ui.session.InputScreenSessionStore
5051
import com.duckduckgo.duckchat.impl.inputscreen.ui.state.AutoCompleteScrollState
5152
import com.duckduckgo.duckchat.impl.inputscreen.ui.state.InputFieldState
@@ -117,6 +118,7 @@ class InputScreenViewModel @AssistedInject constructor(
117118
private val pixel: Pixel,
118119
private val sessionStore: InputScreenSessionStore,
119120
private val inputScreenDiscoveryFunnel: InputScreenDiscoveryFunnel,
121+
private val inputScreenSessionUsageMetric: InputScreenSessionUsageMetric,
120122
) : ViewModel() {
121123

122124
private var hasUserSeenHistoryIAM = false
@@ -361,6 +363,7 @@ class InputScreenViewModel @AssistedInject constructor(
361363
pixel.fire(DUCK_CHAT_EXPERIMENTAL_OMNIBAR_QUERY_SUBMITTED)
362364
pixel.fire(DUCK_CHAT_EXPERIMENTAL_OMNIBAR_QUERY_SUBMITTED_DAILY, type = Daily())
363365
inputScreenDiscoveryFunnel.onSearchSubmitted()
366+
inputScreenSessionUsageMetric.onSearchSubmitted()
364367

365368
viewModelScope.launch {
366369
sessionStore.setHasUsedSearchMode(true)
@@ -378,6 +381,7 @@ class InputScreenViewModel @AssistedInject constructor(
378381
pixel.fire(DUCK_CHAT_EXPERIMENTAL_OMNIBAR_PROMPT_SUBMITTED)
379382
pixel.fire(DUCK_CHAT_EXPERIMENTAL_OMNIBAR_PROMPT_SUBMITTED_DAILY, type = Daily())
380383
inputScreenDiscoveryFunnel.onPromptSubmitted()
384+
inputScreenSessionUsageMetric.onPromptSubmitted()
381385

382386
viewModelScope.launch {
383387
sessionStore.setHasUsedChatMode(true)

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/pixel/DuckChatPixels.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_EXPERIMENT
5151
import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_EXPERIMENTAL_OMNIBAR_QUERY_SUBMITTED_DAILY
5252
import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_EXPERIMENTAL_OMNIBAR_SESSION_BOTH_MODES
5353
import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_EXPERIMENTAL_OMNIBAR_SESSION_BOTH_MODES_DAILY
54+
import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_EXPERIMENTAL_OMNIBAR_SESSION_SUMMARY
5455
import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_EXPERIMENTAL_OMNIBAR_SHOWN
5556
import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_IS_ENABLED_DAILY
5657
import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName.DUCK_CHAT_MENU_SETTING_OFF
@@ -149,6 +150,7 @@ enum class DuckChatPixelName(override val pixelName: String) : Pixel.PixelName {
149150
DUCK_CHAT_EXPERIMENTAL_OMNIBAR_MODE_SWITCHED("m_aichat_experimental_omnibar_mode_switched"),
150151
DUCK_CHAT_EXPERIMENTAL_OMNIBAR_SESSION_BOTH_MODES("m_aichat_experimental_omnibar_session_both_modes_count"),
151152
DUCK_CHAT_EXPERIMENTAL_OMNIBAR_SESSION_BOTH_MODES_DAILY("m_aichat_experimental_omnibar_session_both_modes_daily"),
153+
DUCK_CHAT_EXPERIMENTAL_OMNIBAR_SESSION_SUMMARY("m_aichat_experimental_omnibar_session_summary"),
152154
DUCK_CHAT_EXPERIMENTAL_LEGACY_OMNIBAR_SHOWN("m_aichat_legacy_omnibar_shown"),
153155
DUCK_CHAT_EXPERIMENTAL_LEGACY_OMNIBAR_QUERY_SUBMITTED("m_aichat_legacy_omnibar_query_submitted_count"),
154156
DUCK_CHAT_EXPERIMENTAL_LEGACY_OMNIBAR_QUERY_SUBMITTED_DAILY("m_aichat_legacy_omnibar_query_submitted_daily"),
@@ -208,6 +210,7 @@ class DuckChatParamRemovalPlugin @Inject constructor() : PixelParamRemovalPlugin
208210
DUCK_CHAT_EXPERIMENTAL_OMNIBAR_MODE_SWITCHED.pixelName to PixelParameter.removeAtb(),
209211
DUCK_CHAT_EXPERIMENTAL_OMNIBAR_SESSION_BOTH_MODES.pixelName to PixelParameter.removeAtb(),
210212
DUCK_CHAT_EXPERIMENTAL_OMNIBAR_SESSION_BOTH_MODES_DAILY.pixelName to PixelParameter.removeAtb(),
213+
DUCK_CHAT_EXPERIMENTAL_OMNIBAR_SESSION_SUMMARY.pixelName to PixelParameter.removeAtb(),
211214
DUCK_CHAT_EXPERIMENTAL_LEGACY_OMNIBAR_SHOWN.pixelName to PixelParameter.removeAtb(),
212215
DUCK_CHAT_EXPERIMENTAL_LEGACY_OMNIBAR_QUERY_SUBMITTED.pixelName to PixelParameter.removeAtb(),
213216
DUCK_CHAT_EXPERIMENTAL_LEGACY_OMNIBAR_QUERY_SUBMITTED_DAILY.pixelName to PixelParameter.removeAtb(),
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
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.inputscreen.ui.metrics.usage
18+
19+
import androidx.lifecycle.LifecycleOwner
20+
import com.duckduckgo.app.statistics.pixels.Pixel
21+
import com.duckduckgo.duckchat.api.DuckAiFeatureState
22+
import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName
23+
import kotlinx.coroutines.flow.MutableStateFlow
24+
import org.junit.Before
25+
import org.junit.Test
26+
import org.mockito.kotlin.mock
27+
import org.mockito.kotlin.verify
28+
import org.mockito.kotlin.verifyNoInteractions
29+
import org.mockito.kotlin.whenever
30+
31+
class InputScreenSessionUsageMetricTest {
32+
33+
private val pixel: Pixel = mock()
34+
private val duckAiFeatureState: DuckAiFeatureState = mock()
35+
private val lifecycleOwner: LifecycleOwner = mock()
36+
private val showInputScreenFlow = MutableStateFlow(true)
37+
38+
private lateinit var testee: InputScreenSessionUsageMetricImpl
39+
40+
@Before
41+
fun setup() {
42+
whenever(duckAiFeatureState.showInputScreen).thenReturn(showInputScreenFlow)
43+
testee = InputScreenSessionUsageMetricImpl(pixel, duckAiFeatureState)
44+
}
45+
46+
@Test
47+
fun `when onStop called with feature enabled and no searches or prompts then pixel fired with zero counts`() {
48+
showInputScreenFlow.value = true
49+
50+
testee.onStop(lifecycleOwner)
51+
52+
val expectedParams = mapOf(
53+
"searches_in_session" to "0",
54+
"prompts_in_session" to "0",
55+
)
56+
verify(pixel).enqueueFire(
57+
pixel = DuckChatPixelName.DUCK_CHAT_EXPERIMENTAL_OMNIBAR_SESSION_SUMMARY,
58+
parameters = expectedParams,
59+
)
60+
}
61+
62+
@Test
63+
fun `when onStop called with feature disabled then no pixel fired`() {
64+
showInputScreenFlow.value = false
65+
66+
testee.onSearchSubmitted()
67+
testee.onPromptSubmitted()
68+
testee.onStop(lifecycleOwner)
69+
70+
verifyNoInteractions(pixel)
71+
}
72+
73+
@Test
74+
fun `when onSearchSubmitted called once and feature enabled then counter increments`() {
75+
showInputScreenFlow.value = true
76+
77+
testee.onSearchSubmitted()
78+
testee.onStop(lifecycleOwner)
79+
80+
val expectedParams = mapOf(
81+
"searches_in_session" to "1",
82+
"prompts_in_session" to "0",
83+
)
84+
verify(pixel).enqueueFire(
85+
pixel = DuckChatPixelName.DUCK_CHAT_EXPERIMENTAL_OMNIBAR_SESSION_SUMMARY,
86+
parameters = expectedParams,
87+
)
88+
}
89+
90+
@Test
91+
fun `when onPromptSubmitted called once and feature enabled then counter increments`() {
92+
showInputScreenFlow.value = true
93+
94+
testee.onPromptSubmitted()
95+
testee.onStop(lifecycleOwner)
96+
97+
val expectedParams = mapOf(
98+
"searches_in_session" to "0",
99+
"prompts_in_session" to "1",
100+
)
101+
verify(pixel).enqueueFire(
102+
pixel = DuckChatPixelName.DUCK_CHAT_EXPERIMENTAL_OMNIBAR_SESSION_SUMMARY,
103+
parameters = expectedParams,
104+
)
105+
}
106+
107+
@Test
108+
fun `when multiple searches and prompts submitted and feature enabled then counters increment correctly`() {
109+
showInputScreenFlow.value = true
110+
111+
testee.onSearchSubmitted()
112+
testee.onSearchSubmitted()
113+
testee.onSearchSubmitted()
114+
testee.onPromptSubmitted()
115+
testee.onPromptSubmitted()
116+
117+
testee.onStop(lifecycleOwner)
118+
119+
val expectedParams = mapOf(
120+
"searches_in_session" to "3",
121+
"prompts_in_session" to "2",
122+
)
123+
verify(pixel).enqueueFire(
124+
pixel = DuckChatPixelName.DUCK_CHAT_EXPERIMENTAL_OMNIBAR_SESSION_SUMMARY,
125+
parameters = expectedParams,
126+
)
127+
}
128+
129+
@Test
130+
fun `when onStop called multiple times with feature enabled then counters reset after each call`() {
131+
showInputScreenFlow.value = true
132+
133+
// First session
134+
testee.onSearchSubmitted()
135+
testee.onPromptSubmitted()
136+
testee.onStop(lifecycleOwner)
137+
138+
val firstSessionParams = mapOf(
139+
"searches_in_session" to "1",
140+
"prompts_in_session" to "1",
141+
)
142+
verify(pixel).enqueueFire(
143+
pixel = DuckChatPixelName.DUCK_CHAT_EXPERIMENTAL_OMNIBAR_SESSION_SUMMARY,
144+
parameters = firstSessionParams,
145+
)
146+
147+
// Second session - counters should be reset
148+
testee.onSearchSubmitted()
149+
testee.onStop(lifecycleOwner)
150+
151+
val secondSessionParams = mapOf(
152+
"searches_in_session" to "1",
153+
"prompts_in_session" to "0",
154+
)
155+
verify(pixel).enqueueFire(
156+
pixel = DuckChatPixelName.DUCK_CHAT_EXPERIMENTAL_OMNIBAR_SESSION_SUMMARY,
157+
parameters = secondSessionParams,
158+
)
159+
}
160+
161+
@Test
162+
fun `when counters increment but onStop never called then no pixel fired`() {
163+
showInputScreenFlow.value = true
164+
165+
testee.onSearchSubmitted()
166+
testee.onPromptSubmitted()
167+
168+
verifyNoInteractions(pixel)
169+
}
170+
171+
@Test
172+
fun `when thread safety is required then atomic counters handle concurrent access`() {
173+
showInputScreenFlow.value = true
174+
175+
// Simulate concurrent access
176+
val threads = mutableListOf<Thread>()
177+
178+
repeat(10) { threadIndex ->
179+
val thread = Thread {
180+
repeat(5) {
181+
if (threadIndex % 2 == 0) {
182+
testee.onSearchSubmitted()
183+
} else {
184+
testee.onPromptSubmitted()
185+
}
186+
}
187+
}
188+
threads.add(thread)
189+
thread.start()
190+
}
191+
192+
// Wait for all threads to complete
193+
threads.forEach { it.join() }
194+
195+
testee.onStop(lifecycleOwner)
196+
197+
// Should have 25 searches (5 even threads * 5 calls each) and 25 prompts (5 odd threads * 5 calls each)
198+
val expectedParams = mapOf(
199+
"searches_in_session" to "25",
200+
"prompts_in_session" to "25",
201+
)
202+
verify(pixel).enqueueFire(
203+
pixel = DuckChatPixelName.DUCK_CHAT_EXPERIMENTAL_OMNIBAR_SESSION_SUMMARY,
204+
parameters = expectedParams,
205+
)
206+
}
207+
}

0 commit comments

Comments
 (0)