diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/AttributedMetricsConfigFeature.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/AttributedMetricsConfigFeature.kt index b16979c63129..af51647dbc0f 100644 --- a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/AttributedMetricsConfigFeature.kt +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/AttributedMetricsConfigFeature.kt @@ -70,4 +70,7 @@ interface AttributedMetricsConfigFeature { @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) fun syncDevices(): Toggle + + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun sendOriginParam(): Toggle } diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/OriginParamManager.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/OriginParamManager.kt new file mode 100644 index 000000000000..7df6843e5ae2 --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/OriginParamManager.kt @@ -0,0 +1,76 @@ +/* + * 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.impl + +import com.duckduckgo.app.attributed.metrics.AttributedMetricsConfigFeature +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import dagger.SingleInstanceIn +import javax.inject.Inject + +interface OriginParamManager { + /** + * Determines whether the origin parameter should be included to metric based on the remote config. + * + * @return true if the origin should be included in the metric, false if not + */ + fun shouldSendOrigin(origin: String?): Boolean +} + +@ContributesBinding(AppScope::class) +@SingleInstanceIn(AppScope::class) +class RealOriginParamManager @Inject constructor( + private val attributedMetricsConfigFeature: AttributedMetricsConfigFeature, + private val moshi: Moshi, +) : OriginParamManager { + private val sendOriginParamAdapter: JsonAdapter by lazy { + moshi.adapter(SendOriginParamSettings::class.java) + } + + // Cache parsed substrings. It's expected this to be a short list and not change frequently. + private val cachedSubstrings: List by lazy { + kotlin.runCatching { + attributedMetricsConfigFeature.sendOriginParam().getSettings() + ?.let { sendOriginParamAdapter.fromJson(it) } + ?.originCampaignSubstrings + }.getOrNull() ?: emptyList() + } + + override fun shouldSendOrigin(origin: String?): Boolean { + // If toggle is disabled, don't send origin + if (!attributedMetricsConfigFeature.sendOriginParam().isEnabled()) { + return false + } + + // If origin is null or blank, can't send it + if (origin.isNullOrBlank()) { + return false + } + + // If no substrings configured, don't send origin + if (cachedSubstrings.isEmpty()) { + return false + } + + // Check if origin matches any of the configured substrings (case-insensitive) + return cachedSubstrings.any { substring -> + origin.contains(substring, ignoreCase = true) + } + } +} diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClient.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClient.kt index a9710823fcf5..75218607ef0a 100644 --- a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClient.kt +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClient.kt @@ -47,6 +47,7 @@ class RealAttributedMetricClient @Inject constructor( private val appReferrer: AppReferrer, private val dateUtils: AttributedMetricsDateUtils, private val appInstall: AppInstall, + private val originParamManager: OriginParamManager, ) : AttributedMetricClient { override fun collectEvent(eventName: String) { @@ -99,7 +100,7 @@ class RealAttributedMetricClient @Inject constructor( val origin = appReferrer.getOriginAttributeCampaign() val paramsMutableMap = params.toMutableMap() - if (!origin.isNullOrBlank()) { + if (!origin.isNullOrBlank() && originParamManager.shouldSendOrigin(origin)) { paramsMutableMap["origin"] = origin } else { paramsMutableMap["install_date"] = getInstallDate() diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/SendOriginParamSettings.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/SendOriginParamSettings.kt new file mode 100644 index 000000000000..5de554269e49 --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/SendOriginParamSettings.kt @@ -0,0 +1,23 @@ +/* + * 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.impl + +import com.squareup.moshi.Json + +data class SendOriginParamSettings( + @Json(name = "originCampaignSubstrings") val originCampaignSubstrings: List = emptyList(), +) diff --git a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/impl/OriginParamManagerTest.kt b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/impl/OriginParamManagerTest.kt new file mode 100644 index 000000000000..2cca1ded0ef0 --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/impl/OriginParamManagerTest.kt @@ -0,0 +1,167 @@ +/* + * 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.impl + +import com.duckduckgo.app.attributed.metrics.AttributedMetricsConfigFeature +import com.duckduckgo.feature.toggles.api.Toggle +import com.squareup.moshi.Moshi +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class OriginParamManagerTest { + + private val mockAttributedMetricsConfigFeature: AttributedMetricsConfigFeature = mock() + private val mockSendOriginParamToggle: Toggle = mock() + private val moshi = Moshi.Builder().build() + + private lateinit var testee: RealOriginParamManager + + @Before + fun setup() { + whenever(mockAttributedMetricsConfigFeature.sendOriginParam()).thenReturn(mockSendOriginParamToggle) + } + + @Test + fun whenToggleDisabledThenReturnsFalse() { + whenever(mockSendOriginParamToggle.isEnabled()).thenReturn(false) + testee = RealOriginParamManager(mockAttributedMetricsConfigFeature, moshi) + + val result = testee.shouldSendOrigin("campaign_paid_test") + + assertFalse(result) + } + + @Test + fun whenOriginIsNullThenReturnsFalse() { + whenever(mockSendOriginParamToggle.isEnabled()).thenReturn(true) + whenever(mockSendOriginParamToggle.getSettings()).thenReturn("""{"originCampaignSubstrings":["paid"]}""") + testee = RealOriginParamManager(mockAttributedMetricsConfigFeature, moshi) + + val result = testee.shouldSendOrigin(null) + + assertFalse(result) + } + + @Test + fun whenOriginIsBlankThenReturnsFalse() { + whenever(mockSendOriginParamToggle.isEnabled()).thenReturn(true) + whenever(mockSendOriginParamToggle.getSettings()).thenReturn("""{"originCampaignSubstrings":["paid"]}""") + testee = RealOriginParamManager(mockAttributedMetricsConfigFeature, moshi) + + val result = testee.shouldSendOrigin(" ") + + assertFalse(result) + } + + @Test + fun whenSubstringListIsEmptyThenReturnsFalse() { + whenever(mockSendOriginParamToggle.isEnabled()).thenReturn(true) + whenever(mockSendOriginParamToggle.getSettings()).thenReturn("""{"originCampaignSubstrings":[]}""") + testee = RealOriginParamManager(mockAttributedMetricsConfigFeature, moshi) + + val result = testee.shouldSendOrigin("campaign_paid_test") + + assertFalse(result) + } + + @Test + fun whenOriginMatchesSubstringThenReturnsTrue() { + whenever(mockSendOriginParamToggle.isEnabled()).thenReturn(true) + whenever(mockSendOriginParamToggle.getSettings()).thenReturn("""{"originCampaignSubstrings":["paid"]}""") + testee = RealOriginParamManager(mockAttributedMetricsConfigFeature, moshi) + + val result = testee.shouldSendOrigin("campaign_paid_test") + + assertTrue(result) + } + + @Test + fun whenOriginDoesNotMatchSubstringThenReturnsFalse() { + whenever(mockSendOriginParamToggle.isEnabled()).thenReturn(true) + whenever(mockSendOriginParamToggle.getSettings()).thenReturn("""{"originCampaignSubstrings":["paid"]}""") + testee = RealOriginParamManager(mockAttributedMetricsConfigFeature, moshi) + + val result = testee.shouldSendOrigin("campaign_organic_test") + + assertFalse(result) + } + + @Test + fun whenOriginMatchesAnyOfMultipleSubstringsThenReturnsTrue() { + whenever(mockSendOriginParamToggle.isEnabled()).thenReturn(true) + whenever(mockSendOriginParamToggle.getSettings()).thenReturn("""{"originCampaignSubstrings":["paid","sponsored","affiliate"]}""") + testee = RealOriginParamManager(mockAttributedMetricsConfigFeature, moshi) + + val result = testee.shouldSendOrigin("campaign_sponsored_search") + + assertTrue(result) + } + + @Test + fun whenMatchingIsCaseInsensitiveThenReturnsTrue() { + whenever(mockSendOriginParamToggle.isEnabled()).thenReturn(true) + whenever(mockSendOriginParamToggle.getSettings()).thenReturn("""{"originCampaignSubstrings":["paid"]}""") + testee = RealOriginParamManager(mockAttributedMetricsConfigFeature, moshi) + + val resultUpperCase = testee.shouldSendOrigin("campaign_PAID_test") + val resultMixedCase = testee.shouldSendOrigin("campaign_PaId_test") + + assertTrue(resultUpperCase) + assertTrue(resultMixedCase) + } + + @Test + fun whenSettingsParsingFailsThenReturnsFalse() { + whenever(mockSendOriginParamToggle.isEnabled()).thenReturn(true) + whenever(mockSendOriginParamToggle.getSettings()).thenReturn("invalid json") + testee = RealOriginParamManager(mockAttributedMetricsConfigFeature, moshi) + + val result = testee.shouldSendOrigin("campaign_paid_test") + + assertFalse(result) + } + + @Test + fun whenSettingsIsNullThenReturnsFalse() { + whenever(mockSendOriginParamToggle.isEnabled()).thenReturn(true) + whenever(mockSendOriginParamToggle.getSettings()).thenReturn(null) + testee = RealOriginParamManager(mockAttributedMetricsConfigFeature, moshi) + + val result = testee.shouldSendOrigin("campaign_paid_test") + + assertFalse(result) + } + + @Test + fun whenOriginContainsSubstringWithinWordThenReturnsTrue() { + whenever(mockSendOriginParamToggle.isEnabled()).thenReturn(true) + whenever(mockSendOriginParamToggle.getSettings()).thenReturn("""{"originCampaignSubstrings":["paid"]}""") + testee = RealOriginParamManager(mockAttributedMetricsConfigFeature, moshi) + + val resultHipaid = testee.shouldSendOrigin("funnel_hipaid_us") + val resultPaidctv = testee.shouldSendOrigin("funnel_paidctv_us") + val resultPaid = testee.shouldSendOrigin("funnel_paid_us") + + assertTrue(resultHipaid) + assertTrue(resultPaidctv) + assertTrue(resultPaid) + } +} diff --git a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClientTest.kt b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClientTest.kt index 0e572ad4809c..ee9e21ad8884 100644 --- a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClientTest.kt +++ b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClientTest.kt @@ -51,6 +51,7 @@ class RealAttributedMetricClientTest { private val appReferrer: AppReferrer = mock() private val appInstall: AppInstall = mock() private val dateUtils: AttributedMetricsDateUtils = mock() + private val mockOriginParamManager: OriginParamManager = mock() private lateinit var testee: RealAttributedMetricClient @@ -65,6 +66,7 @@ class RealAttributedMetricClientTest { appReferrer = appReferrer, dateUtils = dateUtils, appInstall = appInstall, + originParamManager = mockOriginParamManager, ) } @@ -109,27 +111,49 @@ class RealAttributedMetricClientTest { } @Test - fun whenEmitMetricAndClientActiveWithOriginThenMetricIsEmittedWithOrigin() = runTest { + fun whenEmitMetricAndOriginParamManagerReturnsTrueThenMetricIsEmittedWithOrigin() = runTest { val testMetric = TestAttributedMetric() + val origin = "campaign_paid_test" whenever(mockMetricsState.isActive()).thenReturn(true) whenever(mockMetricsState.canEmitMetrics()).thenReturn(true) - whenever(appReferrer.getOriginAttributeCampaign()).thenReturn("campaign_origin") + whenever(appReferrer.getOriginAttributeCampaign()).thenReturn(origin) + whenever(mockOriginParamManager.shouldSendOrigin(origin)).thenReturn(true) testee.emitMetric(testMetric) verify(mockPixel).fire( pixelName = "test_pixel", - parameters = mapOf("param" to "value", "origin" to "campaign_origin"), + parameters = mapOf("param" to "value", "origin" to origin), type = Unique("test_pixel_test_tag"), ) } @Test - fun whenEmitMetricAndClientActiveWithoutOriginThenMetricIsEmittedWithInstallDate() = runTest { + fun whenEmitMetricAndOriginParamManagerReturnsFalseThenMetricIsEmittedWithInstallDate() = runTest { + val testMetric = TestAttributedMetric() + val origin = "campaign_organic" + whenever(mockMetricsState.isActive()).thenReturn(true) + whenever(mockMetricsState.canEmitMetrics()).thenReturn(true) + whenever(appReferrer.getOriginAttributeCampaign()).thenReturn(origin) + whenever(mockOriginParamManager.shouldSendOrigin(origin)).thenReturn(false) + whenever(dateUtils.getDateFromTimestamp(any())).thenReturn("2025-01-01") + + testee.emitMetric(testMetric) + + verify(mockPixel).fire( + pixelName = "test_pixel", + parameters = mapOf("param" to "value", "install_date" to "2025-01-01"), + type = Unique("test_pixel_test_tag"), + ) + } + + @Test + fun whenEmitMetricAndOriginIsNullThenMetricIsEmittedWithInstallDate() = runTest { val testMetric = TestAttributedMetric() whenever(mockMetricsState.isActive()).thenReturn(true) whenever(mockMetricsState.canEmitMetrics()).thenReturn(true) whenever(appReferrer.getOriginAttributeCampaign()).thenReturn(null) + whenever(mockOriginParamManager.shouldSendOrigin(null)).thenReturn(false) whenever(dateUtils.getDateFromTimestamp(any())).thenReturn("2025-01-01") testee.emitMetric(testMetric)