Skip to main content

Introduction

Nimbus is an experimentation platform from Mozilla.

This document shows you how to set up the Nimbus SDK with a new Android app. It assumes that your app is already using the Glean SDK and Android Components.

Building with Nimbus

Nimbus is distributed through bundled Rust code as part of Mozilla's Application Services "Megazord".

In app/build.gradle, in the dependencies block, include the implementation line for Nimbus:

dependencies {

implementation "org.mozilla.appservices:nimbus:${Versions.mozilla_appservices}"

}

Building with the Nimbus FML gradle plugin

The Feature Manifest Language provides type-safe access to configuration coming out of the Nimbus SDK, and is used to configure your application features, by generating Kotlin from a Feature Manifest.

The tooling-nimbus-gradle plugin manages the download of the tooling, the generating of the Kotlin code, and is configured by gradle.

In your top-level build.gradle:

buildscript {
dependencies {
classpath "org.mozilla.appservices:tooling-nimbus-gradle:${Versions.mozilla_appservices}"
}
}

and in app/build.gradle:

apply plugin: "org.mozilla.appservices.nimbus-gradle-plugin"

nimbus {
// The path to the Nimbus feature manifest file
manifestFile = "nimbus.fml.yaml"

// Map from the variant name to the channel as experimenter and nimbus understand it.
// If nimbus's channels were accurately set up well for this project, then this
// shouldn't be needed.
channels = [
debug: "debug",
nightly: "nightly",
beta: "beta",
release: "release",
]

// This is generated by the FML and should be checked into git.
// It will be fetched by Experimenter (the Nimbus experiment website)
// and used to inform experiment configuration.
//
// *NOTE*: This value is optional, and is not necessary when Nimbus is being used
// as part of a library.
experimenterManifest = ".experimenter.yaml"

// This is an optional value, and updates the plugin to use a copy of application
// services. The path should be relative to the root project directory.
// *NOTE*: This example will not work for all projects, but should work for Fenix, Focus, and Android Components
applicationServicesDir = gradle.hasProperty('localProperties.autoPublish.application-services.dir')
? gradle.getProperty('localProperties.autoPublish.application-services.dir') : null
}

In this case, it should generate a file named in the nimbus.fml.yaml file. In the case of Fenix, this is called FxNimbus.

The start-up sequence

Before using Nimbus in your Android app, you need to start it.

The Nimbus SDK is a configuration store, making configuration available to the any thread, and — to a first approximation— to be immutable within the same session of the app.

For this reason, we want to be starting the Nimbus SDK as close to the beginning of the start of the app as possible.

In Firefox for Android and Focus for Android, this is done at the beginning of the Application#onCreate() method.

class MyApplication: Application() {

lateinit var nimbus: NimbusInterface

override fun onCreate() {
beginNimbusSetup()


// do the rest of the set up here.

finishNimbusSetup()
}

fun beginNimbusSetup() {
Megazord.init()

nimbus = createNimbus(this, NIMBUS_REMOTE_SETTINGS_ENDPOINT)
}

fun finishNimbusSetup() {
nimbus.fetchExperiments()
}

fun createNimbus(context: Context, urlString: String): NimbusInterface {
val isAppFirstRun = context.settings().isFirstRun
val customTargetingAttibutes = JSONObject().apply {
// Put any custom attributes you want to use to segment an audience on to
// target your experiments.
put("is_first_run", isAppFirstRun)
}

val appInfo = NimbusAppInfo(
appName = "my-app-name",
channel = BuildConfig.BUILD_TYPE,
customTargetingAttributes = customTargetingAttributes
)

// Use the Nimbus builder to build a NimbusInterface object.
return NimbusBuilder(context).apply {
url = urlString
errorReporter = { message, e ->
Logger.error("Nimbus error: $message", e)
}

}.build(appInfo)
}
}

Notes:

  1. Megazord.init() is called before createNimbus().
  2. createNimbus uses a NimbusBuilder to create the Nimbus object.
  3. We build a JSONObject of custom targeting attributes.
  4. The nimbus.fetchExperiments() method is called sometime at or after the app has started.

NimbusBuilder configuration

Getting errors out of Nimbus

By design, Nimbus is deliberately unobtrusive; if it fails then it should not crash, but continue as if not enrolled in any experiments.

The errorReporter callback is there to connect Nimbus to any error reporting framework in the rest of the app.

    return NimbusBuilder(context).apply {
// …
errorReporter = { message, e ->
Logger.error("Nimbus error: $message", e)
}
// …
}.build(appInfo)

Connecting the NimbusInterface to FML generated code

The FML generated code has a runtime dependency on the NimbusInterface.

To connect it to the Nimbus object, we need to tell the NimbusBuilder. In this case, the generated class is FxNimbus.

    return NimbusBuilder(context).apply {
// …
// Connect FxNimbus to the Nimbus SDK.
featureManifest = FxNimbus
// …
}.build(appInfo)

Handling First Run experiments

Since fetchExperiments from the remote settings URL is slow, and we wish to be able have access to the Nimbus experimental configuration as early in start up as possible, Nimbus downloads and caches the experiment recipes on the nth run of the app and only applies them and makes them available to the app at the beginning of the next i.e. the (n + 1)th run of the app.

Astute readers will notice that when n = 0, i.e. the very first time the app is run, there are no experiment recipes downloaded. If Remote Settings experiment recipes JSON payload is available as a raw/ resource, it can be loaded in at first run:

    return NimbusBuilder(context).apply {
// …
isFirstRun = isAppFirstRun
initialExperiments = R.raw.initial_experiments
timeoutLoadingExperiment = TIME_OUT_LOADING_EXPERIMENT_FROM_DISK_MS // defaults to 200 (ms)
// …
}.build(appInfo)

The initial_experiments.json file can be downloaded, either as part of the build, or in an automated/timed job. e.g. this is the Github Action workflow used by Fenix.

To check if the firstrun experiment merged into beta to catch the next release

First run experiments need to be in the beta build 8-11 days before release, so that they are in the release candidate. Final build happens 8 days before release on Monday - so best to get in and uplift approved by Friday at the latest. On Android the Release Candidate goes out to 5% of users a week before general release.

After the change is made in Nimbus/Experimenter to launch, enrollment end, or end the experiment - a github action kicks off the PR automatically to update 'initial_experiments.json'. Then a mobile engineer needs to r+ that PR and request uplift to Beta. If you replace 'version number' in the following file name, you can check this file to see if the experiment config is in the right state before release candidate build https://raw.githubusercontent.com/mozilla-mobile/firefox-android/releases_v'version number'/fenix/app/src/main/res/raw/initial_experiments.json.

Using the experiments preview collection

The preview collection is a staging area for new experiments to be tested on the device. This should be toggleable via the UI, but should trigger a restart.

Adding the usePreviewCollection flag allows the builder to configure a NimbusInterface object connected to the experiment recipes in the preview collection.

        // Use the Nimbus builder to build a NimbusInterface object.
return NimbusBuilder(context).apply {
// …
usePreviewCollection = context.settings().nimbusUsePreview
// …
}.build(appInfo)

A complete NimbusBuilder example

    return NimbusBuilder(context).apply {
url = urlString
errorReporter = { message, e ->
Logger.error("Nimbus error: $message", e)
}
initialExperiments = R.raw.initial_experiments
usePreviewCollection = context.settings().nimbusUsePreview
isFirstRun = isAppFirstRun
sharedPreferences = context.settings().preferences
// Optional callbacks.
onCreateCallback = { nimbus ->
// called when nimbus is set up
}
onFetchCallback = {
// called each time the app fetches experiments
}
onApplyCallback = {
// called each time the applies the fetched experiments.
}
}.build(appInfo)

Instrumenting the app for testing

The nimbus-cli allows QA and engineers to launch the app in different experimental configurations. It largely obviates the need for configuring Nimbus to use the preview collection, above.

To connect the NimbusInterface object to the command line, we need to feed the intent from the app's launch activity to the NimbusInterface.


import org.mozilla.experiments.nimbus.initializeTooling

open class HomeActivity : AppCompatActivity() {)
override fun onCreate(savedInstanceState: Bundle?) {
// Find the nimbus singleton
val app = application as MyApplication
val nimbus = app.nimbus
// Pass it the launch intent
nimbus.initializeTooling(applicationContext, intent)
// …
}