Skip to content
Merged
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 @@ -70,4 +70,7 @@ interface AttributedMetricsConfigFeature {

@Toggle.DefaultValue(DefaultFeatureValue.INTERNAL)
fun syncDevices(): Toggle

@Toggle.DefaultValue(DefaultFeatureValue.INTERNAL)
fun sendOriginParam(): Toggle
}
Original file line number Diff line number Diff line change
@@ -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<SendOriginParamSettings> 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<String> 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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> = emptyList(),
)
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -65,6 +66,7 @@ class RealAttributedMetricClientTest {
appReferrer = appReferrer,
dateUtils = dateUtils,
appInstall = appInstall,
originParamManager = mockOriginParamManager,
)
}

Expand Down Expand Up @@ -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)
Expand Down
Loading