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.

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        onCreateCallback = { nimbus ->            FxNimbus.initialize { nimbus }        }        onApplyCallback = {            FxNimbus.invalidateCachedValues()        }    }.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)        // …    }