Add an experiment to demontrate the Feature API

This is not visible in production, but only debug. It shows three variables
being used to change the settings screen (title, icon and title-punctuation).
upstream-sync
James Hugman 3 years ago committed by Christian Sadilek
parent 4851bf7545
commit ab678a21ff

@ -0,0 +1,236 @@
{
"data": [{
"slug": "feature-text-variables-validation-android",
"appId": "org.mozilla.fenix",
"appName": "fenix",
"channel": "nightly",
"branches": [{
"slug": "control",
"ratio": 100,
"feature": {
"value": {},
"enabled": true,
"featureId": "nimbus-validation"
}
},
{
"slug": "a1",
"ratio": 0,
"feature": {
"value": {
"settings-title": "settings_title",
"settings-title-punctuation": "…"
},
"enabled": true,
"featureId": "nimbus-validation"
}
},
{
"slug": "a2",
"ratio": 0,
"feature": {
"value": {
"settings-title": "preferences_category_general",
"settings-title-punctuation": "!"
},
"enabled": true,
"featureId": "nimbus-validation"
}
}
],
"outcomes": [],
"arguments": {},
"probeSets": [],
"startDate": null,
"targeting": "true",
"featureIds": [
"nimbus-validation"
],
"application": "org.mozilla.firefox_beta",
"bucketConfig": {
"count": 0,
"start": 0,
"total": 10000,
"namespace": "nimbus-validation-2",
"randomizationUnit": "nimbus_id"
},
"schemaVersion": "1.5.0",
"userFacingName": "Nimbus Text Variables Validation",
"referenceBranch": "control",
"proposedDuration": 14,
"isEnrollmentPaused": false,
"proposedEnrollment": 7,
"userFacingDescription": "Demonstration experiment to make trivial visible changes to text in Settings",
"last_modified": 1621443780172
},
{
"slug": "feature-icon-variables-validation-android",
"appId": "org.mozilla.fenix",
"appName": "fenix",
"channel": "nightly",
"branches": [{
"slug": "control",
"ratio": 100,
"feature": {
"value": {},
"enabled": true,
"featureId": "nimbus-validation"
}
},
{
"slug": "treatment",
"ratio": 0,
"feature": {
"value": {
"settings-title": "Fancy Settings",
"settings-icon": "ic_edit"
},
"enabled": true,
"featureId": "nimbus-validation"
}
}
],
"outcomes": [],
"arguments": {},
"probeSets": [],
"startDate": null,
"targeting": "true",
"featureIds": [
"nimbus-validation"
],
"application": "org.mozilla.firefox_beta",
"bucketConfig": {
"count": 0,
"start": 0,
"total": 10000,
"namespace": "nimbus-validation-2",
"randomizationUnit": "nimbus_id"
},
"schemaVersion": "1.5.0",
"userFacingName": "Nimbus Icon Variables Validation",
"referenceBranch": "control",
"proposedDuration": 14,
"isEnrollmentPaused": false,
"proposedEnrollment": 7,
"userFacingDescription": "Demonstration experiment to make trivial visible changes to icons in Settings",
"last_modified": 1621443780172
},
{
"slug": "feature-text-variables-validation-ios",
"appId": "org.mozilla.ios.Fennec",
"appName": "firefox_ios",
"channel": "nightly",
"branches": [{
"slug": "control",
"ratio": 100,
"feature": {
"value": {},
"enabled": true,
"featureId": "nimbus-validation"
}
},
{
"slug": "a1",
"ratio": 0,
"feature": {
"value": {
"settings-title": "Menu/Menu.OpenSettingsAction.Title",
"settings-title-punctuation": "…"
},
"enabled": true,
"featureId": "nimbus-validation"
}
},
{
"slug": "a2",
"ratio": 0,
"feature": {
"value": {
"settings-title": "Settings.General.SectionName",
"settings-title-punctuation": "!"
},
"enabled": true,
"featureId": "nimbus-validation"
}
}
],
"outcomes": [],
"arguments": {},
"probeSets": [],
"startDate": null,
"targeting": "true",
"featureIds": [
"nimbus-validation"
],
"application": "org.mozilla.ios.Fennec",
"bucketConfig": {
"count": 0,
"start": 0,
"total": 10000,
"namespace": "nimbus-validation-2",
"randomizationUnit": "nimbus_id"
},
"schemaVersion": "1.5.0",
"userFacingName": "Nimbus Text Variables Validation",
"referenceBranch": "control",
"proposedDuration": 14,
"isEnrollmentPaused": false,
"proposedEnrollment": 7,
"userFacingDescription": "Demonstration experiment to make trivial visible changes to text in Settings",
"last_modified": 1621443780172
},
{
"slug": "feature-icon-variables-validation-ios",
"appId": "org.mozilla.ios.Fennec",
"appName": "firefox_ios",
"channel": "nightly",
"branches": [{
"slug": "control",
"ratio": 100,
"feature": {
"value": {},
"enabled": true,
"featureId": "nimbus-validation"
}
},
{
"slug": "treatment",
"ratio": 0,
"feature": {
"value": {
"settings-title": "Fancy Settings",
"settings-icon": "menu-ViewMobile"
},
"enabled": true,
"featureId": "nimbus-validation"
}
}
],
"outcomes": [],
"arguments": {},
"probeSets": [],
"startDate": null,
"targeting": "true",
"featureIds": [
"nimbus-validation"
],
"application": "org.mozilla.ios.Fennec",
"bucketConfig": {
"count": 0,
"start": 0,
"total": 10000,
"namespace": "nimbus-validation-2",
"randomizationUnit": "nimbus_id"
},
"schemaVersion": "1.5.0",
"userFacingName": "Nimbus Icon Variables Validation",
"referenceBranch": "control",
"proposedDuration": 14,
"isEnrollmentPaused": false,
"proposedEnrollment": 7,
"userFacingDescription": "Demonstration experiment to make trivial visible changes to icons in Settings",
"last_modified": 1621443780172
}
]
}

@ -9,7 +9,7 @@ import android.os.Build
import mozilla.components.lib.dataprotect.SecurePrefsReliabilityExperiment
import mozilla.components.service.nimbus.NimbusApi
import org.mozilla.fenix.experiments.ExperimentBranch
import org.mozilla.fenix.experiments.Experiments
import org.mozilla.fenix.experiments.FeatureId
import org.mozilla.fenix.ext.withExperiment
/**
@ -24,7 +24,7 @@ class SecurePrefsTelemetry(
// The Android Keystore is used to secure the shared prefs only on API 23+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
// These tests should run only if the experiment is live
experiments.withExperiment(Experiments.ANDROID_KEYSTORE) { experimentBranch ->
experiments.withExperiment(FeatureId.ANDROID_KEYSTORE) { experimentBranch ->
// .. and this device is not in the control group.
if (experimentBranch == ExperimentBranch.TREATMENT) {
SecurePrefsReliabilityExperiment(appContext)()

@ -41,7 +41,7 @@ import org.mozilla.fenix.R
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.components.accounts.FenixAccountManager
import org.mozilla.fenix.experiments.ExperimentBranch
import org.mozilla.fenix.experiments.Experiments
import org.mozilla.fenix.experiments.FeatureId
import org.mozilla.fenix.ext.asActivity
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.settings
@ -632,7 +632,7 @@ open class DefaultToolbarMenu(
val experiments = context.components.analytics.experiments
val browsers = BrowsersCache.all(context)
return experiments.withExperiment(Experiments.DEFAULT_BROWSER) { experimentBranch ->
return experiments.withExperiment(FeatureId.DEFAULT_BROWSER) { experimentBranch ->
if (experimentBranch == ExperimentBranch.DEFAULT_BROWSER_TOOLBAR_MENU &&
!browsers.isFirefoxDefaultBrowser
) {

@ -4,14 +4,23 @@
package org.mozilla.fenix.experiments
class Experiments {
companion object {
const val A_A_NIMBUS_VALIDATION = "fenix-nimbus-validation-v3"
const val ANDROID_KEYSTORE = "fenix-android-keystore"
const val DEFAULT_BROWSER = "fenix-default-browser"
}
/**
* Enums to identify features in the app. These will likely grow and shrink depending
* on the experiments we want to perform.
*
* @property jsonName the kebab-case version of the feature id as represented in the Nimbus
* experiment JSON.
*/
enum class FeatureId(val jsonName: String) {
NIMBUS_VALIDATION("nimbus-validation"),
ANDROID_KEYSTORE("fenix-android-keystore"),
DEFAULT_BROWSER("fenix-default-browser")
}
/**
* Experiment branches are becoming less interesting, though we collect some well
* defined ones here.
*/
class ExperimentBranch {
companion object {
const val TREATMENT = "treatment"

@ -15,6 +15,7 @@ import mozilla.components.service.nimbus.NimbusDisabled
import mozilla.components.service.nimbus.NimbusServerSettings
import mozilla.components.support.base.log.logger.Logger
import org.mozilla.fenix.BuildConfig
import org.mozilla.fenix.R
import org.mozilla.fenix.components.isSentryEnabled
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.settings
@ -66,6 +67,10 @@ fun createNimbus(context: Context, url: String?): NimbusApi =
globalUserParticipation = enabled
}
if (url.isNullOrBlank()) {
setExperimentsLocally(R.raw.initial_experiments)
}
// We may have downloaded experiments on a previous run, so let's start using them
// now. We didn't do this earlier, so as to make getExperimentBranch and friends returns
// the same thing throughout the session. This call does its work on the db thread.

@ -5,38 +5,98 @@
package org.mozilla.fenix.ext
import mozilla.components.service.nimbus.NimbusApi
import mozilla.components.support.base.log.logger.Logger
import org.mozilla.fenix.FeatureFlags
import org.mozilla.experiments.nimbus.Variables
import org.mozilla.fenix.experiments.FeatureId
/**
* Gets the branch of the given `experimentId` and transforms it with given closure.
* Gets the branch name of an experiment acting on the feature given `featureId`, and transforms it
* with given closure.
*
* If we're enrolled in the experiment, the transform is passed the branch id/slug as a `String`.
* You are probably looking for `withVariables`.
*
* If we're enrolled in an experiment, the transform is passed the branch id/slug as a `String`.
*
* If we're not enrolled in the experiment, or the experiment is not valid then the transform
* is passed a `null`.
*/
@Suppress("TooGenericExceptionCaught")
fun <T> NimbusApi.withExperiment(experimentId: String, transform: (String?) -> T): T {
val branch = if (FeatureFlags.nimbusExperiments) {
try {
getExperimentBranch(experimentId)
} catch (e: Throwable) {
Logger.error("Failed to getExperimentBranch($experimentId)", e)
null
}
} else {
null
}
return transform(branch)
fun <T> NimbusApi.withExperiment(featureId: FeatureId, transform: (String?) -> T): T {
return transform(getExperimentBranch(featureId.jsonName))
}
/**
* The degenerate case of `withExperiment(String, (String?) -> T))`, with an identity transform.
* The synonym for [getExperimentBranch] to complement [withExperiment(String, (String?) -> T))].
*
* Short-hand for ` org.mozilla.experiments.nimbus.NimbusApi.getExperimentBranch`.
*/
fun NimbusApi.withExperiment(featureId: FeatureId) =
getExperimentBranch(featureId.jsonName)
/**
* Get the variables needed to configure the feature given by `featureId`.
*
* @param featureId The feature id that identifies the feature under experiment.
*
* @param sendExposureEvent Passing `true` to this parameter will record the exposure event
* automatically if the client is enrolled in an experiment for the given [featureId].
* Passing `false` here indicates that the application will manually record the exposure
* event by calling the `sendExposureEvent` function at the time of the exposure to the
* feature.
*
* See [sendExposureEvent] for more information on manually recording the event.
*
* @return a [Variables] object used to configure the feature.
*/
fun NimbusApi.getVariables(featureId: FeatureId, sendExposureEvent: Boolean = true) =
getVariables(featureId.jsonName, sendExposureEvent)
/**
* A synonym for `getVariables(featureId, sendExposureEvent)`.
*
* Short-hand for `mozilla.components.service.nimbus.NimbusApi.getExperimentBranch`.
* This exists as a complement to the `withVariable(featureId, sendExposureEvent, transform)` method.
*
* @param featureId the id of the feature as it appears in `Experimenter`
* @param sendExposureEvent by default `true`. This logs an event that the user was exposed to an experiment
* involving this feature.
* @return a `Variables` object providing typed accessors to a remotely configured JSON object.
*/
fun NimbusApi.withVariables(featureId: FeatureId, sendExposureEvent: Boolean = true) =
getVariables(featureId, sendExposureEvent)
/**
* Get a `Variables` object for this feature and use that to configure the feature itself or a more type safe configuration object.
*
* @param featureId the id of the feature as it appears in `Experimenter`
* @param sendExposureEvent by default `true`. This logs an event that the user was exposed to an experiment
* involving this feature.
*/
fun NimbusApi.withExperiment(experimentId: String) =
this.withExperiment(experimentId, ::identity)
fun <T> NimbusApi.withVariables(featureId: FeatureId, sendExposureEvent: Boolean = true, transform: (Variables) -> T) =
transform(getVariables(featureId, sendExposureEvent))
private fun <T> identity(value: T) = value
/**
* Records the `exposure` event in telemetry.
*
* This is a manual function to accomplish the same purpose as passing `true` as the
* `sendExposureEvent` property of the `getVariables` function. It is intended to be used
* when requesting feature variables must occur at a different time than the actual user's
* exposure to the feature within the app.
*
* - Examples:
* - If the `Variables` are needed at a different time than when the exposure to the feature
* actually happens, such as constructing a menu happening at a different time than the
* user seeing the menu.
* - If `getVariables` is required to be called multiple times for the same feature and it is
* desired to only record the exposure once, such as if `getVariables` were called
* with every keystroke.
*
* In the case where the use of this function is required, then the `getVariables` function
* should be called with `false` so that the exposure event is not recorded when the variables
* are fetched.
*
* This function is safe to call even when there is no active experiment for the feature. The SDK
* will ensure that an event is only recorded for active experiments.
*
* @param featureId string representing the id of the feature for which to record the exposure
* event.
*/
fun NimbusApi.recordExposureEvent(featureId: FeatureId) =
recordExposureEvent(featureId.jsonName)

@ -27,11 +27,10 @@ import org.mozilla.fenix.FeatureFlags
import org.mozilla.fenix.FeatureFlags.tabsTrayRewrite
import org.mozilla.fenix.R
import org.mozilla.fenix.components.accounts.FenixAccountManager
import org.mozilla.fenix.experiments.ExperimentBranch
import org.mozilla.fenix.experiments.Experiments
import org.mozilla.fenix.experiments.FeatureId
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.getVariables
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.ext.withExperiment
import org.mozilla.fenix.theme.ThemeManager
import org.mozilla.fenix.whatsnew.WhatsNew
@ -142,23 +141,9 @@ class HomeMenu(
onItemTapped.invoke(Item.Bookmarks)
}
// We want to validate that the Nimbus experiments library is working, from the android UI
// all the way back to the data science backend. We're not testing the user's preference
// or response, we're end-to-end testing the experiments platform.
// So here, we're running multiple identical branches with the same treatment, and if the
// user isn't targeted, then we get still get the same treatment.
// The `let` block is degenerate here, but left here so as to document the form of how experiments
// are implemented here.
val historyIcon = experiments.withExperiment(Experiments.A_A_NIMBUS_VALIDATION) {
when (it) {
ExperimentBranch.A1 -> R.drawable.ic_history
ExperimentBranch.A2 -> R.drawable.ic_history
else -> R.drawable.ic_history
}
}
val historyItem = BrowserMenuImageText(
context.getString(R.string.library_history),
historyIcon,
R.drawable.ic_history,
primaryTextColor
) {
onItemTapped.invoke(Item.History)
@ -172,9 +157,11 @@ class HomeMenu(
onItemTapped.invoke(Item.Extensions)
}
// Use nimbus to set the icon and title.
val variables = experiments.getVariables(FeatureId.NIMBUS_VALIDATION)
val settingsItem = BrowserMenuImageText(
context.getString(R.string.browser_menu_settings),
R.drawable.ic_settings,
variables.getText("settings-title") ?: context.getString(R.string.browser_menu_settings),
variables.getDrawableResource("settings-icon") ?: R.drawable.ic_settings,
primaryTextColor
) {
onItemTapped.invoke(Item.Settings)
@ -252,23 +239,9 @@ class HomeMenu(
onItemTapped.invoke(Item.Bookmarks)
}
// We want to validate that the Nimbus experiments library is working, from the android UI
// all the way back to the data science backend. We're not testing the user's preference
// or response, we're end-to-end testing the experiments platform.
// So here, we're running multiple identical branches with the same treatment, and if the
// user isn't targeted, then we get still get the same treatment.
// The `let` block is degenerate here, but left here so as to document the form of how experiments
// are implemented here.
val historyIcon = experiments.withExperiment(Experiments.A_A_NIMBUS_VALIDATION) {
when (it) {
ExperimentBranch.A1 -> R.drawable.ic_history
ExperimentBranch.A2 -> R.drawable.ic_history
else -> R.drawable.ic_history
}
}
val historyItem = BrowserMenuImageText(
context.getString(R.string.library_history),
historyIcon,
R.drawable.ic_history,
primaryTextColor
) {
onItemTapped.invoke(Item.History)
@ -310,9 +283,11 @@ class HomeMenu(
onItemTapped.invoke(Item.Help)
}
// Use nimbus to set the icon and title.
val variables = experiments.getVariables(FeatureId.NIMBUS_VALIDATION)
val settingsItem = BrowserMenuImageText(
context.getString(R.string.browser_menu_settings),
R.drawable.ic_settings,
variables.getText("settings-title") ?: context.getString(R.string.browser_menu_settings),
variables.getDrawableResource("settings-icon") ?: R.drawable.ic_settings,
primaryTextColor
) {
onItemTapped.invoke(Item.Settings)

@ -79,7 +79,7 @@ class NimbusBranchesFragment : Fragment() {
try {
val experiments = requireContext().components.analytics.experiments
val branches = experiments.getExperimentBranches(args.experimentId) ?: emptyList()
val selectedBranch = experiments.withExperiment(args.experimentId) ?: ""
val selectedBranch = experiments.getExperimentBranch(args.experimentId) ?: ""
nimbusBranchesStore.dispatch(
NimbusBranchesAction.UpdateBranches(

@ -42,7 +42,7 @@ import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.experiments.ExperimentBranch
import org.mozilla.fenix.experiments.Experiments
import org.mozilla.fenix.experiments.FeatureId
import org.mozilla.fenix.ext.application
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.getPreferenceKey
@ -52,6 +52,7 @@ import org.mozilla.fenix.ext.navigateToNotificationsSettings
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.ext.REQUEST_CODE_BROWSER_ROLE
import org.mozilla.fenix.ext.getVariables
import org.mozilla.fenix.ext.openSetDefaultBrowserOption
import org.mozilla.fenix.ext.showToolbar
import org.mozilla.fenix.ext.withExperiment
@ -161,7 +162,13 @@ class SettingsFragment : PreferenceFragmentCompat() {
override fun onResume() {
super.onResume()
showToolbar(getString(R.string.settings_title))
// Use nimbus to set the title, and a trivial addition
val experiments = requireContext().components.analytics.experiments
val variables = experiments.getVariables(FeatureId.NIMBUS_VALIDATION)
val title = variables.getText("settings-title") ?: getString(R.string.settings_title)
val suffix = variables.getString("settings-title-punctuation") ?: ""
showToolbar("$title$suffix")
// Account UI state is updated as part of `onCreate`. To not do it twice in a row, we only
// update it here if we're not going through the `onCreate->onStart->onResume` lifecycle chain.
@ -589,7 +596,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
private fun isDefaultBrowserExperimentBranch(): Boolean {
val experiments = context?.components?.analytics?.experiments
return experiments?.withExperiment(Experiments.DEFAULT_BROWSER) { experimentBranch ->
return experiments?.withExperiment(FeatureId.DEFAULT_BROWSER) { experimentBranch ->
(experimentBranch == ExperimentBranch.DEFAULT_BROWSER_SETTINGS_MENU)
} == true
}

@ -34,7 +34,7 @@ import org.mozilla.fenix.components.settings.counterPreference
import org.mozilla.fenix.components.settings.featureFlagPreference
import org.mozilla.fenix.components.toolbar.ToolbarPosition
import org.mozilla.fenix.experiments.ExperimentBranch
import org.mozilla.fenix.experiments.Experiments
import org.mozilla.fenix.experiments.FeatureId
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.getPreferenceKey
import org.mozilla.fenix.ext.withExperiment
@ -312,10 +312,10 @@ class Settings(private val appContext: Context) : PreferencesHolder {
val browsers = BrowsersCache.all(appContext)
val experiments = appContext.components.analytics.experiments
val isExperimentBranch =
experiments.withExperiment(Experiments.DEFAULT_BROWSER) { experimentBranch ->
experiments.withExperiment(FeatureId.DEFAULT_BROWSER) { experimentBranch ->
(experimentBranch == ExperimentBranch.DEFAULT_BROWSER_NEW_TAB_BANNER)
}
return isExperimentBranch &&
return isExperimentBranch == true &&
!userDismissedExperimentCard &&
!browsers.isFirefoxDefaultBrowser &&
numberOfAppLaunches > APP_LAUNCHES_TO_SHOW_DEFAULT_BROWSER_CARD

Loading…
Cancel
Save