Skip to content

Commit

Permalink
Send enrollment pixel when cohort is assigned (#5139)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
marcosholgado authored Oct 16, 2024
1 parent e27e11d commit af63a0f
Show file tree
Hide file tree
Showing 12 changed files with 232 additions and 2 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ class ContributesRemoteFeatureCodeGenerator : CodeGenerator {
)
.build(),
)
.addParameter("callback", featureTogglesCallback.asClassName(module))
.addParameter("appBuildConfig", appBuildConfig.asClassName(module))
.addParameter("variantManager", variantManager.asClassName(module))
.addCode(
Expand All @@ -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()
Expand Down Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
5 changes: 4 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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. " +
Expand Down
1 change: 1 addition & 0 deletions feature-toggles/feature-toggles-api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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:_"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Method, Toggle>()
Expand All @@ -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 }
Expand All @@ -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) {
Expand All @@ -80,6 +84,7 @@ class FeatureToggles private constructor(
appVariantProvider = appVariantProvider,
localeProvider = localeProvider,
forceDefaultVariant = forceDefaultVariant,
callback = this.callback,
)
}
}
Expand Down Expand Up @@ -127,6 +132,7 @@ class FeatureToggles private constructor(
appVariantProvider = appVariantProvider,
localeProvider = localeProvider,
forceDefaultVariant = forceDefaultVariant,
callback = callback,
).also { featureToggleCache[method] = it }
}
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
2 changes: 2 additions & 0 deletions feature-toggles/feature-toggles-impl/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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"),
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"),
}
Original file line number Diff line number Diff line change
@@ -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))
}
}
1 change: 1 addition & 0 deletions feature-toggles/feature-toggles-internal-api/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/build
36 changes: 36 additions & 0 deletions feature-toggles/feature-toggles-internal-api/build.gradle
Original file line number Diff line number Diff line change
@@ -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 {

}

Original file line number Diff line number Diff line change
@@ -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)
}

0 comments on commit af63a0f

Please sign in to comment.