From af63a0f135b4bfec5cc4b4e17e13ebce59eac52d Mon Sep 17 00:00:00 2001 From: Marcos Date: Wed, 16 Oct 2024 18:00:40 +0100 Subject: [PATCH] Send enrollment pixel when cohort is assigned (#5139) Task/Issue URL: https://app.asana.com/0/1125189844152671/1208490557030192/f ### Description This PR sends an enrollment pixel whenever a cohort is assigned ### Steps to test this PR See task --- .../ContributesRemoteFeatureCodeGenerator.kt | 3 + app/build.gradle | 1 + build.gradle | 5 +- .../feature-toggles-api/build.gradle | 1 + .../feature/toggles/api/FeatureToggles.kt | 13 +++- .../feature-toggles-impl/build.gradle | 2 + .../impl/RealFeatureTogglesCallback.kt | 55 ++++++++++++++++ .../feature/toggles/api/FeatureTogglesTest.kt | 62 +++++++++++++++++++ .../impl/RealFeatureTogglesCallbackTest.kt | 26 ++++++++ .../feature-toggles-internal-api/.gitignore | 1 + .../feature-toggles-internal-api/build.gradle | 36 +++++++++++ .../internal/api/FeatureTogglesCallback.kt | 29 +++++++++ 12 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 feature-toggles/feature-toggles-impl/src/main/java/com/duckduckgo/feature/toggles/impl/RealFeatureTogglesCallback.kt create mode 100644 feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/impl/RealFeatureTogglesCallbackTest.kt create mode 100644 feature-toggles/feature-toggles-internal-api/.gitignore create mode 100644 feature-toggles/feature-toggles-internal-api/build.gradle create mode 100644 feature-toggles/feature-toggles-internal-api/src/main/java/com/duckduckgo/feature/toggles/internal/api/FeatureTogglesCallback.kt diff --git a/anvil/anvil-compiler/src/main/java/com/duckduckgo/anvil/compiler/ContributesRemoteFeatureCodeGenerator.kt b/anvil/anvil-compiler/src/main/java/com/duckduckgo/anvil/compiler/ContributesRemoteFeatureCodeGenerator.kt index b6eff40eaa76..24816de89729 100644 --- a/anvil/anvil-compiler/src/main/java/com/duckduckgo/anvil/compiler/ContributesRemoteFeatureCodeGenerator.kt +++ b/anvil/anvil-compiler/src/main/java/com/duckduckgo/anvil/compiler/ContributesRemoteFeatureCodeGenerator.kt @@ -107,6 +107,7 @@ class ContributesRemoteFeatureCodeGenerator : CodeGenerator { ) .build(), ) + .addParameter("callback", featureTogglesCallback.asClassName(module)) .addParameter("appBuildConfig", appBuildConfig.asClassName(module)) .addParameter("variantManager", variantManager.asClassName(module)) .addCode( @@ -118,6 +119,7 @@ class ContributesRemoteFeatureCodeGenerator : CodeGenerator { .featureName(%S) .appVariantProvider({ appBuildConfig.variantName }) .localeProvider({ appBuildConfig.deviceLocale }) + .callback(callback) // save empty variants will force the default variant to be set .forceDefaultVariantProvider({ variantManager.updateVariants(emptyList()) }) .build() @@ -1151,6 +1153,7 @@ class ContributesRemoteFeatureCodeGenerator : CodeGenerator { private val context = FqName("android.content.Context") private val privacyFeaturePlugin = FqName("com.duckduckgo.privacy.config.api.PrivacyFeaturePlugin") private val appBuildConfig = FqName("com.duckduckgo.appbuildconfig.api.AppBuildConfig") + private val featureTogglesCallback = FqName("com.duckduckgo.feature.toggles.internal.api.FeatureTogglesCallback") private val variantManager = FqName("com.duckduckgo.experiments.api.VariantManager") private val buildFlavorInternal = FqName("com.duckduckgo.appbuildconfig.api.BuildFlavor.INTERNAL") private val moshi = FqName("com.squareup.moshi.Moshi") diff --git a/app/build.gradle b/app/build.gradle index ce055fefffb7..6f6ee7f950df 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -259,6 +259,7 @@ dependencies { internalImplementation project(':vpn-internal') implementation project(':feature-toggles-api') + implementation project(':feature-toggles-internal-api') testImplementation project(':feature-toggles-test') implementation project(':feature-toggles-impl') internalImplementation project(":feature-toggles-internal") diff --git a/build.gradle b/build.gradle index de22b7abbb11..27eebf63bd0b 100644 --- a/build.gradle +++ b/build.gradle @@ -141,7 +141,10 @@ subprojects { && dependencyPath != ":feature-toggles-api" && dependencyPath != ":navigation-api" ) { - if (projectPath.endsWith(":feature-toggles-api") && dependencyPath == ":experiments-api") { + if (projectPath.endsWith(":feature-toggles-api") && + (dependencyPath == ":experiments-api" + || dependencyPath == ":feature-toggles-internal-api") + ) { // noop } else { throw new GradleException("Invalid dependency $projectPath -> $dependencyPath. " + diff --git a/feature-toggles/feature-toggles-api/build.gradle b/feature-toggles/feature-toggles-api/build.gradle index 5066a34f528c..d05b632138cc 100644 --- a/feature-toggles/feature-toggles-api/build.gradle +++ b/feature-toggles/feature-toggles-api/build.gradle @@ -32,6 +32,7 @@ kotlin { dependencies { api project(":experiments-api") + api project(":feature-toggles-internal-api") implementation Google.dagger implementation "org.apache.commons:commons-math3:_" diff --git a/feature-toggles/feature-toggles-api/src/main/java/com/duckduckgo/feature/toggles/api/FeatureToggles.kt b/feature-toggles/feature-toggles-api/src/main/java/com/duckduckgo/feature/toggles/api/FeatureToggles.kt index a5fdde041581..a87cc73abf23 100644 --- a/feature-toggles/feature-toggles-api/src/main/java/com/duckduckgo/feature/toggles/api/FeatureToggles.kt +++ b/feature-toggles/feature-toggles-api/src/main/java/com/duckduckgo/feature/toggles/api/FeatureToggles.kt @@ -21,6 +21,7 @@ import com.duckduckgo.feature.toggles.api.Toggle.State import com.duckduckgo.feature.toggles.api.Toggle.State.Cohort import com.duckduckgo.feature.toggles.api.Toggle.State.Cohort.Companion.AnyCohort.ANY_COHORT import com.duckduckgo.feature.toggles.api.Toggle.State.CohortName +import com.duckduckgo.feature.toggles.internal.api.FeatureTogglesCallback import java.lang.IllegalArgumentException import java.lang.IllegalStateException import java.lang.reflect.Method @@ -40,6 +41,7 @@ class FeatureToggles private constructor( private val appVariantProvider: () -> String?, private val localeProvider: () -> Locale?, private val forceDefaultVariant: () -> Unit, + private val callback: FeatureTogglesCallback?, ) { private val featureToggleCache = mutableMapOf() @@ -52,6 +54,7 @@ class FeatureToggles private constructor( private var appVariantProvider: () -> String? = { "" }, private var localeProvider: () -> Locale? = { Locale.getDefault() }, private var forceDefaultVariant: () -> Unit = { /** noop **/ }, + private var callback: FeatureTogglesCallback? = null, ) { fun store(store: Toggle.Store) = apply { this.store = store } @@ -61,6 +64,7 @@ class FeatureToggles private constructor( fun appVariantProvider(variantName: () -> String?) = apply { this.appVariantProvider = variantName } fun localeProvider(locale: () -> Locale?) = apply { this.localeProvider = locale } fun forceDefaultVariantProvider(forceDefaultVariant: () -> Unit) = apply { this.forceDefaultVariant = forceDefaultVariant } + fun callback(callback: FeatureTogglesCallback) = apply { this.callback = callback } fun build(): FeatureToggles { val missing = StringBuilder() if (this.store == null) { @@ -80,6 +84,7 @@ class FeatureToggles private constructor( appVariantProvider = appVariantProvider, localeProvider = localeProvider, forceDefaultVariant = forceDefaultVariant, + callback = this.callback, ) } } @@ -127,6 +132,7 @@ class FeatureToggles private constructor( appVariantProvider = appVariantProvider, localeProvider = localeProvider, forceDefaultVariant = forceDefaultVariant, + callback = callback, ).also { featureToggleCache[method] = it } } } @@ -292,6 +298,7 @@ internal class ToggleImpl constructor( private val appVariantProvider: () -> String?, private val localeProvider: () -> Locale?, private val forceDefaultVariant: () -> Unit, + private val callback: FeatureTogglesCallback?, ) : Toggle { private fun Toggle.State.isVariantTreated(variant: String?): Boolean { @@ -323,7 +330,11 @@ internal class ToggleImpl constructor( return store.get(key)?.let { state -> // we assign cohorts if it hasn't been assigned before or if the cohort was removed from the remote config val updatedState = if (state.assignedCohort == null || !state.cohorts.map { it.name }.contains(state.assignedCohort.name)) { - state.copy(assignedCohort = assignCohortRandomly(state.cohorts, state.targets)) + state.copy(assignedCohort = assignCohortRandomly(state.cohorts, state.targets)).also { + it.assignedCohort?.let { cohort -> + callback?.onCohortAssigned(this.featureName().name, cohort.name, cohort.enrollmentDateET!!) + } + } } else { state } diff --git a/feature-toggles/feature-toggles-impl/build.gradle b/feature-toggles/feature-toggles-impl/build.gradle index 19a2a8696bee..af148c2847ea 100644 --- a/feature-toggles/feature-toggles-impl/build.gradle +++ b/feature-toggles/feature-toggles-impl/build.gradle @@ -29,6 +29,8 @@ dependencies { implementation project(':di') implementation project(':common-utils') implementation project(':feature-toggles-api') + implementation project(':feature-toggles-internal-api') + implementation project(":statistics-api") implementation AndroidX.appCompat implementation Google.android.material diff --git a/feature-toggles/feature-toggles-impl/src/main/java/com/duckduckgo/feature/toggles/impl/RealFeatureTogglesCallback.kt b/feature-toggles/feature-toggles-impl/src/main/java/com/duckduckgo/feature/toggles/impl/RealFeatureTogglesCallback.kt new file mode 100644 index 000000000000..7d2fdf780273 --- /dev/null +++ b/feature-toggles/feature-toggles-impl/src/main/java/com/duckduckgo/feature/toggles/impl/RealFeatureTogglesCallback.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2024 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.feature.toggles.impl + +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.feature.toggles.impl.FeatureTogglesPixelName.EXPERIMENT_ENROLLMENT +import com.duckduckgo.feature.toggles.internal.api.FeatureTogglesCallback +import com.squareup.anvil.annotations.ContributesBinding +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import javax.inject.Inject +import okio.ByteString.Companion.encode + +@ContributesBinding(AppScope::class) +class RealFeatureTogglesCallback @Inject constructor( + private val pixel: Pixel, +) : FeatureTogglesCallback { + override fun onCohortAssigned( + experimentName: String, + cohortName: String, + enrollmentDate: String, + ) { + val parsedDate = ZonedDateTime.parse(enrollmentDate).format(DateTimeFormatter.ISO_LOCAL_DATE) + val params = mapOf("enrollmentDate" to parsedDate) + val pixelName = getPixelName(experimentName, cohortName) + val tag = "${pixelName}_$params".encode().md5().hex() + pixel.fire(pixelName = pixelName, parameters = params, type = Pixel.PixelType.Unique(tag = tag)) + } + + private fun getPixelName( + experimentName: String, + cohortName: String, + ): String { + return "${EXPERIMENT_ENROLLMENT.pixelName}_${experimentName}_$cohortName" + } +} + +internal enum class FeatureTogglesPixelName(override val pixelName: String) : Pixel.PixelName { + EXPERIMENT_ENROLLMENT("experiment_enroll"), +} diff --git a/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/api/FeatureTogglesTest.kt b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/api/FeatureTogglesTest.kt index 23577acfd558..cdcb9173a7c6 100644 --- a/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/api/FeatureTogglesTest.kt +++ b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/api/FeatureTogglesTest.kt @@ -17,9 +17,13 @@ package com.duckduckgo.feature.toggles.api import com.duckduckgo.appbuildconfig.api.BuildFlavor +import com.duckduckgo.feature.toggles.api.Cohorts.CONTROL +import com.duckduckgo.feature.toggles.api.Cohorts.TREATMENT import com.duckduckgo.feature.toggles.api.Toggle.FeatureName import com.duckduckgo.feature.toggles.api.Toggle.State +import com.duckduckgo.feature.toggles.api.Toggle.State.CohortName import com.duckduckgo.feature.toggles.api.Toggle.State.Target +import com.duckduckgo.feature.toggles.internal.api.FeatureTogglesCallback import java.lang.IllegalStateException import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -30,23 +34,27 @@ import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import org.mockito.kotlin.times class FeatureTogglesTest { private lateinit var feature: TestFeature private lateinit var provider: FakeProvider private lateinit var toggleStore: FakeToggleStore + private lateinit var callback: FakeFeatureTogglesCallback @Before fun setup() { provider = FakeProvider() toggleStore = FakeToggleStore() + callback = FakeFeatureTogglesCallback() feature = FeatureToggles.Builder() .store(toggleStore) .appVariantProvider { provider.variantKey } .appVersionProvider { provider.version } .flavorNameProvider { provider.flavorName } .forceDefaultVariantProvider { provider.variantKey = "" } + .callback(callback) .featureName("test") .build() .create(TestFeature::class.java) @@ -517,6 +525,37 @@ class FeatureTogglesTest { toggleStore.set("test_enabledByDefault", state.copy(enable = false)) assertFalse(feature.enabledByDefault().isEnabled()) } + + @Test + fun whenAssigningCohortOnCohortAssignedCallbackCalled() { + val state = Toggle.State( + remoteEnableState = true, + enable = true, + cohorts = listOf( + Toggle.State.Cohort(name = "control", weight = 0, enrollmentDateET = null), + Toggle.State.Cohort(name = "treatment", weight = 1, enrollmentDateET = null), + ), + ) + + // check callback not called yet + assertNull(callback.experimentName) + assertNull(callback.cohortName) + assertNull(callback.enrollmentDate) + assertEquals(0, callback.times) + + // Use directly the store because setRawStoredState() populates the local state when the remote state is null + toggleStore.set("test_enabledByDefault", state) + + // isEnabled triggers callback on first assignment + assertTrue(feature.enabledByDefault().isEnabled(TREATMENT)) + assertEquals(1, callback.times) + assertEquals("enabledByDefault", callback.experimentName) + assertEquals("treatment", callback.cohortName) + assertNotNull(callback.enrollmentDate) + + assertFalse(feature.enabledByDefault().isEnabled(CONTROL)) + assertEquals(1, callback.times) + } } interface TestFeature { @@ -565,3 +604,26 @@ private class FakeProvider { var flavorName = "" var variantKey: String? = null } + +private class FakeFeatureTogglesCallback : FeatureTogglesCallback { + var experimentName: String? = null + var cohortName: String? = null + var enrollmentDate: String? = null + var times = 0 + + override fun onCohortAssigned( + experimentName: String, + cohortName: String, + enrollmentDate: String, + ) { + this.experimentName = experimentName + this.cohortName = cohortName + this.enrollmentDate = enrollmentDate + times++ + } +} + +private enum class Cohorts(override val cohortName: String) : CohortName { + CONTROL("control"), + TREATMENT("treatment"), +} diff --git a/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/impl/RealFeatureTogglesCallbackTest.kt b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/impl/RealFeatureTogglesCallbackTest.kt new file mode 100644 index 000000000000..a8e1a5578daa --- /dev/null +++ b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/impl/RealFeatureTogglesCallbackTest.kt @@ -0,0 +1,26 @@ +package com.duckduckgo.feature.toggles.impl + +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Unique +import okio.ByteString.Companion.encode +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.kotlin.verify + +class RealFeatureTogglesCallbackTest { + private val pixel: Pixel = mock() + private val callback = RealFeatureTogglesCallback(pixel) + + @Test + fun `test pixel is sent with correct parameters`() { + val pixelName = "experiment_enroll_experimentName_cohortName" + val params = mapOf("enrollmentDate" to "2024-10-15") + val tag = "${pixelName}_$params".encode().md5().hex() + callback.onCohortAssigned( + experimentName = "experimentName", + cohortName = "cohortName", + enrollmentDate = "2024-10-15T00:00-04:00[America/New_York]", + ) + verify(pixel).fire(pixelName = pixelName, parameters = params, type = Unique(tag)) + } +} diff --git a/feature-toggles/feature-toggles-internal-api/.gitignore b/feature-toggles/feature-toggles-internal-api/.gitignore new file mode 100644 index 000000000000..42afabfd2abe --- /dev/null +++ b/feature-toggles/feature-toggles-internal-api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-toggles/feature-toggles-internal-api/build.gradle b/feature-toggles/feature-toggles-internal-api/build.gradle new file mode 100644 index 000000000000..09e465a031a2 --- /dev/null +++ b/feature-toggles/feature-toggles-internal-api/build.gradle @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2024 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. + */ + +plugins { + id 'java-library' + id 'kotlin' +} + +apply from: "$rootProject.projectDir/code-formatting.gradle" + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + jvmToolchain(17) +} + +dependencies { + +} + diff --git a/feature-toggles/feature-toggles-internal-api/src/main/java/com/duckduckgo/feature/toggles/internal/api/FeatureTogglesCallback.kt b/feature-toggles/feature-toggles-internal-api/src/main/java/com/duckduckgo/feature/toggles/internal/api/FeatureTogglesCallback.kt new file mode 100644 index 000000000000..087c0e253d11 --- /dev/null +++ b/feature-toggles/feature-toggles-internal-api/src/main/java/com/duckduckgo/feature/toggles/internal/api/FeatureTogglesCallback.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2024 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.feature.toggles.internal.api + +/** + * This interface exists to facilitate the implementation of ToggleImpl which contains logic inside an api module. + * This is an internal implementation to thread the need between toggles-api and toggles-impl and should NEVER + * be used publicly. + */ +interface FeatureTogglesCallback { + + /** + * This method is called whenever a cohort is assigned to the FeatureToggle + */ + fun onCohortAssigned(experimentName: String, cohortName: String, enrollmentDate: String) +}