Skip to content

Commit 55d4718

Browse files
authored
Update origin handling logic (Attributed Metrics) (#7156)
Task/Issue URL: https://app.asana.com/1/137249556945/project/488551667048375/task/1212002979565345?focus=true ### Description Updates logic to decide if we should add origin in our attributed metrics. This now relies on remote config. if enabled, we validate if origin includes specific substrings, otherwise we don't include. ### Steps to test this PR Update the `PrivacyFeatureName` `PRIVACY_REMOTE_CONFIG_URL` with `https://duckduckgo.github.io/privacy-configuration/pr-4110/v4/android-config.json` Use logcat filter `package:mine message~:"attributed_metric"` _Feature 1_ - [x] Fresh install (removing local folder) - [x] Launch and wait for Skip onboarding to appear. - [x] skip onboarding - [x] Go to internal Attribute Metrics - [x] Add search events, add ad clicks, add duck ai events - [x] update app retention atb and search atb with random values - [x] Update origin with `funnel_paid_test` - [x] put install date in the past, 1mo works - [x] restart (fire button) - [x] you should see some pixels from attributed metrics, they should contain `origin` _Feature 2_ - [x] Go to internal Attribute Metrics - [x] update app retention atb and search atb with NEW random values - [x] Update origin with `funnel_test` - [x] restart (fire button) - [x] you should see some pixels from attributed metrics, even if ignored, they should contain `install_date` now ### UI changes | Before | After | | ------ | ----- | !(Upload before screenshot)|(Upload after screenshot)|
1 parent cead15b commit 55d4718

File tree

6 files changed

+299
-5
lines changed

6 files changed

+299
-5
lines changed

attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/AttributedMetricsConfigFeature.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,7 @@ interface AttributedMetricsConfigFeature {
7070

7171
@Toggle.DefaultValue(DefaultFeatureValue.INTERNAL)
7272
fun syncDevices(): Toggle
73+
74+
@Toggle.DefaultValue(DefaultFeatureValue.INTERNAL)
75+
fun sendOriginParam(): Toggle
7376
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
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.impl
18+
19+
import com.duckduckgo.app.attributed.metrics.AttributedMetricsConfigFeature
20+
import com.duckduckgo.di.scopes.AppScope
21+
import com.squareup.anvil.annotations.ContributesBinding
22+
import com.squareup.moshi.JsonAdapter
23+
import com.squareup.moshi.Moshi
24+
import dagger.SingleInstanceIn
25+
import javax.inject.Inject
26+
27+
interface OriginParamManager {
28+
/**
29+
* Determines whether the origin parameter should be included to metric based on the remote config.
30+
*
31+
* @return true if the origin should be included in the metric, false if not
32+
*/
33+
fun shouldSendOrigin(origin: String?): Boolean
34+
}
35+
36+
@ContributesBinding(AppScope::class)
37+
@SingleInstanceIn(AppScope::class)
38+
class RealOriginParamManager @Inject constructor(
39+
private val attributedMetricsConfigFeature: AttributedMetricsConfigFeature,
40+
private val moshi: Moshi,
41+
) : OriginParamManager {
42+
private val sendOriginParamAdapter: JsonAdapter<SendOriginParamSettings> by lazy {
43+
moshi.adapter(SendOriginParamSettings::class.java)
44+
}
45+
46+
// Cache parsed substrings. It's expected this to be a short list and not change frequently.
47+
private val cachedSubstrings: List<String> by lazy {
48+
kotlin.runCatching {
49+
attributedMetricsConfigFeature.sendOriginParam().getSettings()
50+
?.let { sendOriginParamAdapter.fromJson(it) }
51+
?.originCampaignSubstrings
52+
}.getOrNull() ?: emptyList()
53+
}
54+
55+
override fun shouldSendOrigin(origin: String?): Boolean {
56+
// If toggle is disabled, don't send origin
57+
if (!attributedMetricsConfigFeature.sendOriginParam().isEnabled()) {
58+
return false
59+
}
60+
61+
// If origin is null or blank, can't send it
62+
if (origin.isNullOrBlank()) {
63+
return false
64+
}
65+
66+
// If no substrings configured, don't send origin
67+
if (cachedSubstrings.isEmpty()) {
68+
return false
69+
}
70+
71+
// Check if origin matches any of the configured substrings (case-insensitive)
72+
return cachedSubstrings.any { substring ->
73+
origin.contains(substring, ignoreCase = true)
74+
}
75+
}
76+
}

attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClient.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ class RealAttributedMetricClient @Inject constructor(
4747
private val appReferrer: AppReferrer,
4848
private val dateUtils: AttributedMetricsDateUtils,
4949
private val appInstall: AppInstall,
50+
private val originParamManager: OriginParamManager,
5051
) : AttributedMetricClient {
5152

5253
override fun collectEvent(eventName: String) {
@@ -99,7 +100,7 @@ class RealAttributedMetricClient @Inject constructor(
99100

100101
val origin = appReferrer.getOriginAttributeCampaign()
101102
val paramsMutableMap = params.toMutableMap()
102-
if (!origin.isNullOrBlank()) {
103+
if (!origin.isNullOrBlank() && originParamManager.shouldSendOrigin(origin)) {
103104
paramsMutableMap["origin"] = origin
104105
} else {
105106
paramsMutableMap["install_date"] = getInstallDate()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
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.impl
18+
19+
import com.squareup.moshi.Json
20+
21+
data class SendOriginParamSettings(
22+
@Json(name = "originCampaignSubstrings") val originCampaignSubstrings: List<String> = emptyList(),
23+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
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.impl
18+
19+
import com.duckduckgo.app.attributed.metrics.AttributedMetricsConfigFeature
20+
import com.duckduckgo.feature.toggles.api.Toggle
21+
import com.squareup.moshi.Moshi
22+
import org.junit.Assert.assertFalse
23+
import org.junit.Assert.assertTrue
24+
import org.junit.Before
25+
import org.junit.Test
26+
import org.mockito.kotlin.mock
27+
import org.mockito.kotlin.whenever
28+
29+
class OriginParamManagerTest {
30+
31+
private val mockAttributedMetricsConfigFeature: AttributedMetricsConfigFeature = mock()
32+
private val mockSendOriginParamToggle: Toggle = mock()
33+
private val moshi = Moshi.Builder().build()
34+
35+
private lateinit var testee: RealOriginParamManager
36+
37+
@Before
38+
fun setup() {
39+
whenever(mockAttributedMetricsConfigFeature.sendOriginParam()).thenReturn(mockSendOriginParamToggle)
40+
}
41+
42+
@Test
43+
fun whenToggleDisabledThenReturnsFalse() {
44+
whenever(mockSendOriginParamToggle.isEnabled()).thenReturn(false)
45+
testee = RealOriginParamManager(mockAttributedMetricsConfigFeature, moshi)
46+
47+
val result = testee.shouldSendOrigin("campaign_paid_test")
48+
49+
assertFalse(result)
50+
}
51+
52+
@Test
53+
fun whenOriginIsNullThenReturnsFalse() {
54+
whenever(mockSendOriginParamToggle.isEnabled()).thenReturn(true)
55+
whenever(mockSendOriginParamToggle.getSettings()).thenReturn("""{"originCampaignSubstrings":["paid"]}""")
56+
testee = RealOriginParamManager(mockAttributedMetricsConfigFeature, moshi)
57+
58+
val result = testee.shouldSendOrigin(null)
59+
60+
assertFalse(result)
61+
}
62+
63+
@Test
64+
fun whenOriginIsBlankThenReturnsFalse() {
65+
whenever(mockSendOriginParamToggle.isEnabled()).thenReturn(true)
66+
whenever(mockSendOriginParamToggle.getSettings()).thenReturn("""{"originCampaignSubstrings":["paid"]}""")
67+
testee = RealOriginParamManager(mockAttributedMetricsConfigFeature, moshi)
68+
69+
val result = testee.shouldSendOrigin(" ")
70+
71+
assertFalse(result)
72+
}
73+
74+
@Test
75+
fun whenSubstringListIsEmptyThenReturnsFalse() {
76+
whenever(mockSendOriginParamToggle.isEnabled()).thenReturn(true)
77+
whenever(mockSendOriginParamToggle.getSettings()).thenReturn("""{"originCampaignSubstrings":[]}""")
78+
testee = RealOriginParamManager(mockAttributedMetricsConfigFeature, moshi)
79+
80+
val result = testee.shouldSendOrigin("campaign_paid_test")
81+
82+
assertFalse(result)
83+
}
84+
85+
@Test
86+
fun whenOriginMatchesSubstringThenReturnsTrue() {
87+
whenever(mockSendOriginParamToggle.isEnabled()).thenReturn(true)
88+
whenever(mockSendOriginParamToggle.getSettings()).thenReturn("""{"originCampaignSubstrings":["paid"]}""")
89+
testee = RealOriginParamManager(mockAttributedMetricsConfigFeature, moshi)
90+
91+
val result = testee.shouldSendOrigin("campaign_paid_test")
92+
93+
assertTrue(result)
94+
}
95+
96+
@Test
97+
fun whenOriginDoesNotMatchSubstringThenReturnsFalse() {
98+
whenever(mockSendOriginParamToggle.isEnabled()).thenReturn(true)
99+
whenever(mockSendOriginParamToggle.getSettings()).thenReturn("""{"originCampaignSubstrings":["paid"]}""")
100+
testee = RealOriginParamManager(mockAttributedMetricsConfigFeature, moshi)
101+
102+
val result = testee.shouldSendOrigin("campaign_organic_test")
103+
104+
assertFalse(result)
105+
}
106+
107+
@Test
108+
fun whenOriginMatchesAnyOfMultipleSubstringsThenReturnsTrue() {
109+
whenever(mockSendOriginParamToggle.isEnabled()).thenReturn(true)
110+
whenever(mockSendOriginParamToggle.getSettings()).thenReturn("""{"originCampaignSubstrings":["paid","sponsored","affiliate"]}""")
111+
testee = RealOriginParamManager(mockAttributedMetricsConfigFeature, moshi)
112+
113+
val result = testee.shouldSendOrigin("campaign_sponsored_search")
114+
115+
assertTrue(result)
116+
}
117+
118+
@Test
119+
fun whenMatchingIsCaseInsensitiveThenReturnsTrue() {
120+
whenever(mockSendOriginParamToggle.isEnabled()).thenReturn(true)
121+
whenever(mockSendOriginParamToggle.getSettings()).thenReturn("""{"originCampaignSubstrings":["paid"]}""")
122+
testee = RealOriginParamManager(mockAttributedMetricsConfigFeature, moshi)
123+
124+
val resultUpperCase = testee.shouldSendOrigin("campaign_PAID_test")
125+
val resultMixedCase = testee.shouldSendOrigin("campaign_PaId_test")
126+
127+
assertTrue(resultUpperCase)
128+
assertTrue(resultMixedCase)
129+
}
130+
131+
@Test
132+
fun whenSettingsParsingFailsThenReturnsFalse() {
133+
whenever(mockSendOriginParamToggle.isEnabled()).thenReturn(true)
134+
whenever(mockSendOriginParamToggle.getSettings()).thenReturn("invalid json")
135+
testee = RealOriginParamManager(mockAttributedMetricsConfigFeature, moshi)
136+
137+
val result = testee.shouldSendOrigin("campaign_paid_test")
138+
139+
assertFalse(result)
140+
}
141+
142+
@Test
143+
fun whenSettingsIsNullThenReturnsFalse() {
144+
whenever(mockSendOriginParamToggle.isEnabled()).thenReturn(true)
145+
whenever(mockSendOriginParamToggle.getSettings()).thenReturn(null)
146+
testee = RealOriginParamManager(mockAttributedMetricsConfigFeature, moshi)
147+
148+
val result = testee.shouldSendOrigin("campaign_paid_test")
149+
150+
assertFalse(result)
151+
}
152+
153+
@Test
154+
fun whenOriginContainsSubstringWithinWordThenReturnsTrue() {
155+
whenever(mockSendOriginParamToggle.isEnabled()).thenReturn(true)
156+
whenever(mockSendOriginParamToggle.getSettings()).thenReturn("""{"originCampaignSubstrings":["paid"]}""")
157+
testee = RealOriginParamManager(mockAttributedMetricsConfigFeature, moshi)
158+
159+
val resultHipaid = testee.shouldSendOrigin("funnel_hipaid_us")
160+
val resultPaidctv = testee.shouldSendOrigin("funnel_paidctv_us")
161+
val resultPaid = testee.shouldSendOrigin("funnel_paid_us")
162+
163+
assertTrue(resultHipaid)
164+
assertTrue(resultPaidctv)
165+
assertTrue(resultPaid)
166+
}
167+
}

attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClientTest.kt

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ class RealAttributedMetricClientTest {
5151
private val appReferrer: AppReferrer = mock()
5252
private val appInstall: AppInstall = mock()
5353
private val dateUtils: AttributedMetricsDateUtils = mock()
54+
private val mockOriginParamManager: OriginParamManager = mock()
5455

5556
private lateinit var testee: RealAttributedMetricClient
5657

@@ -65,6 +66,7 @@ class RealAttributedMetricClientTest {
6566
appReferrer = appReferrer,
6667
dateUtils = dateUtils,
6768
appInstall = appInstall,
69+
originParamManager = mockOriginParamManager,
6870
)
6971
}
7072

@@ -109,27 +111,49 @@ class RealAttributedMetricClientTest {
109111
}
110112

111113
@Test
112-
fun whenEmitMetricAndClientActiveWithOriginThenMetricIsEmittedWithOrigin() = runTest {
114+
fun whenEmitMetricAndOriginParamManagerReturnsTrueThenMetricIsEmittedWithOrigin() = runTest {
113115
val testMetric = TestAttributedMetric()
116+
val origin = "campaign_paid_test"
114117
whenever(mockMetricsState.isActive()).thenReturn(true)
115118
whenever(mockMetricsState.canEmitMetrics()).thenReturn(true)
116-
whenever(appReferrer.getOriginAttributeCampaign()).thenReturn("campaign_origin")
119+
whenever(appReferrer.getOriginAttributeCampaign()).thenReturn(origin)
120+
whenever(mockOriginParamManager.shouldSendOrigin(origin)).thenReturn(true)
117121

118122
testee.emitMetric(testMetric)
119123

120124
verify(mockPixel).fire(
121125
pixelName = "test_pixel",
122-
parameters = mapOf("param" to "value", "origin" to "campaign_origin"),
126+
parameters = mapOf("param" to "value", "origin" to origin),
123127
type = Unique("test_pixel_test_tag"),
124128
)
125129
}
126130

127131
@Test
128-
fun whenEmitMetricAndClientActiveWithoutOriginThenMetricIsEmittedWithInstallDate() = runTest {
132+
fun whenEmitMetricAndOriginParamManagerReturnsFalseThenMetricIsEmittedWithInstallDate() = runTest {
133+
val testMetric = TestAttributedMetric()
134+
val origin = "campaign_organic"
135+
whenever(mockMetricsState.isActive()).thenReturn(true)
136+
whenever(mockMetricsState.canEmitMetrics()).thenReturn(true)
137+
whenever(appReferrer.getOriginAttributeCampaign()).thenReturn(origin)
138+
whenever(mockOriginParamManager.shouldSendOrigin(origin)).thenReturn(false)
139+
whenever(dateUtils.getDateFromTimestamp(any())).thenReturn("2025-01-01")
140+
141+
testee.emitMetric(testMetric)
142+
143+
verify(mockPixel).fire(
144+
pixelName = "test_pixel",
145+
parameters = mapOf("param" to "value", "install_date" to "2025-01-01"),
146+
type = Unique("test_pixel_test_tag"),
147+
)
148+
}
149+
150+
@Test
151+
fun whenEmitMetricAndOriginIsNullThenMetricIsEmittedWithInstallDate() = runTest {
129152
val testMetric = TestAttributedMetric()
130153
whenever(mockMetricsState.isActive()).thenReturn(true)
131154
whenever(mockMetricsState.canEmitMetrics()).thenReturn(true)
132155
whenever(appReferrer.getOriginAttributeCampaign()).thenReturn(null)
156+
whenever(mockOriginParamManager.shouldSendOrigin(null)).thenReturn(false)
133157
whenever(dateUtils.getDateFromTimestamp(any())).thenReturn("2025-01-01")
134158

135159
testee.emitMetric(testMetric)

0 commit comments

Comments
 (0)