Skip to main content

The Feature Manifest Language

About this document

This document is the specification for the Feature Manifest Language for use with the Nimbus SDK. It is implemented by the nimbus-fml tool, which is a Rust command line app run at build time of a mobile app.

Introduction

Nimbus is an experimentation platform to allow product owners to easily run experiments on their mobile apps.

High level concepts

To the product owner, Nimbus allows you to experiment with different configurations of the application.

It does this by exposing a pre-determined proportion of the audience to different configurations of a feature. Once a winning configuration is identified, it can be rolled out to the rest of the audience, all without a re-release of the app.

To the app developer, Nimbus is a configuration store. Features are configured using data from Nimbus. Nimbus is updated at startup of each run.

The "Feature" in "Feature API" and "Feature Manifest" refers to

An identifiable part of the app in which changes may be detectable by a user.

The "Feature Manifest" is a schema to describe the structure of the configuration of these features, and a complete initial configuration for the app to use.

The "Feature Manifest Language" is definition language for writing feature manifest.

nimbus-fml is the name fo the tool for processing the feature manifest, to generate Swift and Kotlin and the JSON schema that Experimenter can ingest.

Features are configured remotely by sending JSON objects to the Nimbus SDK on each device. It is the job of the nimbus-fml, and the code that it generates to unpack that JSON, validate it, coerce it into values usable by the app and recover if anything goes wrong.

At first glance, the FML might appear like a JSON deserializer like Google GSON or Swift's Codable

This is a reasonable first approximation, however, much of the function of the Feature Manifest is to provide a default configuration for a feature, which is provided when one is not provided by the experiment.

JSON Merge Patch

For each feature, the feature manifest should define a complete configuration needed by the app, including a default value for every variable.

Remotely set experiments and rollouts will vary the configurations by presenting patches on that configuration. If the complete configuration of the feature can be represented by a JSON object, then patches to that configuration would be applied in a manner consistent with JSON Merge Patch RFC 7396.

About the generated code

Each project has one or more manifest file, containing all the features configured in that project.

Running nimbus-fml for a manifest generates a Swift or Kotlin class, which is named as a command line argument.

In the examples, this class is called FxNimbus, but this is project specific. This is the entry point to getting values out of Nimbus.

The generated class must be connected to the Nimbus SDK, which downloads the experiment recipes and decides which experiments this user is in.

// Initialize the Nimbus SDK
let nimbus = createNimbusForApplication()
// Connect the generated code to the Nimbus SDK
FxNimbus.shared.initialize { nimbus }

Feature configuration for each feature are accessed through the FxNimbus.features property. For example, a newtab feature's configuration can be accessed thus:

let newTabConfig = FxNimbus.features.newtab.value()

Details: When the value() method is called:

  • the Nimbus api is queried to get a JSON object for the feature with id of newtab
  • the result is used to construct an instance of Newtab, which is generated by the FML
  • the Newtab object merges its default values with the values from the JSON.
  • this object is cached in memory for the next time this is called.
  • the cache is cleared each time the applyPendingExperiments() method is called.

Recording exposure

You should record when the user has been exposed to the feature. For example, if the feature being configured are the items in the app-menu, then when the user opens the app menu, you should call recordExposure.

FxNimbus.shared.features.appMenu.recordExposure()

Feature design hint: As a rule of thumb, if you find yourself calling recordExposure for the same feature in multiple places, you probably should rethink what the feature actually is. e.g. an app-menu may have a hamburger menu icon, and some menu items. The user is exposed to the hamburger menu icon every time the toolbar is shown, but the menu items only when the menu is opened. This would suggest that the hamburger menu icon needs to be specified in a different feature, e.g. the toolbar feature.

Identifier cases

All the examples below use kebab-case for identifiers. When these identifiers are used to generate code, they are transformed to the language-specific casing. For example, a feature is specified in the FML as being called spotlight-search, but would be referred to in Swift as spotlightSearch.

Introducing the FML

The Feature Manifest Language is a dialect of YAML. The manifest file, say nimbus.fml.yaml must be valid YAML.

YAML was chosen, in part, because it allows comments, there are many high quality parsers, and is a superset of JSON; JSON is used extensively in the configuration of features.

At a minimum the nimbus.fml.yaml file defines some data about the generated file, a list of the channels and a map of features.

# A YAML file to define Nimbus features.
# This file has zero features.
about:
ios:
class: FxNimbus
module: Blockzilla
android:
class: .nimbus.FxNimbus
package: org.mozilla.focus
channels:
- developer
- nightly
- release
features: {}

The about block must contain either an ios or android object property.

The channels list is a list of strings containing one or more strings as build variants, or channels.

The features property is a mapping of feature-id to feature. Feature IDs need to be valid strings. In this document all feature IDs are kebab-cased in the manifest.

Adding features to this object will automatically be picked up by Experimenter.

They are converted to mixedCamelCase in both Kotlin and Swift.

Features have variables

We start with an example which configures the integration between Firefox for iOS and iOS Spotlight feature.

Here, we define a feature with an id of spotlight-search, with two variables— enabled and max-age-in-days

features:
spotlight-search:
description: Configuring how we integrate web-pages with iOS's Spotlight search
variables:
enabled:
description: If `false`, the app will not record anything with Spotlight.
type: Boolean
default: false
max-age-in-days:
description: The number of days a single piece of content is indexed for.
type: Int
default: 28

Each variable has a description, a type and default value.

The description is copied verbatim into the generated code as comments. This will be visible in Xcode and Android Studio when navigating code or code completions.

The type is the type name or type label that the variable will be. The type labels for variables will be familiar to developers who have written Kotlin, Swift or Typescript. The different types of types are discussed in detail below.

The default value is used when the variable is not set remotely, or there is a problem within the SDK.

The default value has to make sense in the context of the variable's type.

let spotlightConfig = FxNimbus.features.spotlightSearch.value()
guard spotlightConfig.enabled else {
return
}

// let item = CSSearchableItem(…)

let oneDayInSeconds: TimeInterval = 24 * 60 * 60
let maxAgeInSeconds = Double(spotlightConfig.maxAgeInDays) * oneDayInSeconds
item.expirationDate = Date(timeIntervalSinceNow: maxAgeInSeconds)

Notice that:

  • the feature configuration is accessible from FxNimbus.features.spotlightSearch.value()
  • the feature id and variable names have been transformed to the Swift formatting convention; in this case both to mixed-camel-case.
  • the values consumed by Swift are non-optional.

The generated code includes the defaults. It does not need to call into the Nimbus runtime or access the network.

struct SpotlightSearch {
private let variables: Variables?

init(variables: Variables? = nil) {
self.variables = variables
}

lazy var enabled: Boolean = {
self.variables?.getBool("enabled") ?? false
}()

lazy var maxAgeInDays: Int = {
self.variables?.getInt("max-age-in-days") ?? 28
}()
}

Variables are evaluated lazily.

Enumerations

Using the same spotlight-search feature from above. We notice that the we can include an icon in the search results.

However, we have some options around what to include, but we're not sure which is best.

features:
spotlight-search:
description: "Configuring how we integrate web-pages with iOS's Spotlight search"
variables:
# … other variables omitted for clarity
item-thumbnail:
description: "The icon that appears in the Spotlight search results.
Note that changing this does not change already indexed content."
type: ThumbnailType
default: letter
enums:
ThumbnailType:
description: An enum of types of icon that can be presented alongside titles of pages
variants:
letter: A rendering of the first letter of the domain name
screenshot: A screenshot thumbnail of the webpage
favicon: The favicon derived from the webpage
none: No icon is displayed

The value itemThumbnail is of type ThumbnailType, which is generated as an enum. This can be used exhaustively matched in a switch statement.

switch spotlightConfig.itemThumbnail {
case .favicon:
item.thumbnailData = FaviconFetcher.getFaviconFromDiskCache(imageKey: baseDomain)?.pngData()
case .letter:
item.thumbnailData = FaviconFetcher.letter(forUrl: url).pngData()
case .screenshot:
item.thumbnailData = tab.screenshot?.pngData()
case .none:
item.thumbnailData = nil
}

Note: this Spotlight API doesn't exist on Android; this code is illustrating the enums on the lefthand side of the case clauses in the when expression.

Note that enumeration variants in Swift are in mixedCamelCase; in Kotlin they are in SCREAMING_SNAKE_CASE.

Feature defaults

Already, in this simple example we have three variables. We may want to vary the configuration of the feature when the user is not involved in an experiment.

This might be because we have run an experiment and learned that a change of configuration should be made more permanent.

The defaults list is a list of zero or more "default blocks", used to patch the default values of the variables in a feature.

The value property defines the patch which is overlaid on top of the existing variable values.

features:
spotlight-search:
variables:
enabled:
description: If `false`, the app will not record anything with Spotlight.
type: Boolean
default: false
max-age-in-days:
description: The number of days a single piece of content is indexed for.
type: Int
default: 28
item-thumbnail:
description: "The icon that appears in the Spotlight search results.
Note that changing this does not change already indexed content."
type: ThumbnailType
default: letter
defaults:
- value: { item-thumbnail: screenshot, max-age-in-days: 56.0 }

The defaults list of values set the default value of item-thumbnail to screenshot, and max-age-in-days to 56.0.

Adding new feature blocks to the defaults list patches the existing defaults further:

    defaults:
- value: { item-thumbnail: screenshot, max-age-in-days: 56.0 }
- value: { max-age-in-days: 64.0 }

In this case, the default value for item-thumbnail is now screenshot, and max-age-in-days is 64.0.

Feature defaults and channels

Sometimes it is useful to have different default configurations for different channels of an application. For example, you may want to have a feature available for testing on Nightly or Beta before turning it on in Release.

This is achieved by declaring channels in the manifest.

channels:
- nightly
- beta
- release
features:
spotlight-search:
variables:
enabled:
description: "…"
type: Boolean
default: false
defaults:
- channel: nightly
value: { enabled: true }

The channel property specifies which build flavour the default applies. In this example, the nightly version of the app set the enabled variable to true; all other versions of the app have enabled as false.

In this way, app developers can use the feature manifest as a replacement for other more adhoc feature flag solutions.

Additional types

Primitive types

Primitive types supported:

  • String
  • Boolean.
  • Int.

Bundle types

Strings that can be coerced to resources within the bundle; however they are error prone to type in the Experimenter interface.

Suggested values can be used to suggest values to the experiment owner, with a description on what the resource will look like.

  • Text performs a lookup for displayable text in the application bundle. If not text exists, returns the string used for lookup.
  • Image performs a lookup for an image in the application bundle.

For each of these types, the default value must correspond to a valid resource identifier in the app.

features:
upgrade-message:
description: A message displayed when the user upgrades
variables:
hero-image:
description: A pre-bundled image
type: Image
default: ic_fox
message-content:
description: Text content to show the user
default: msg_thankyou
Values for `Text` are strings which must correspond to the following format:

KEY | TABLE_NAME '/' KEY

The lookup uses the bundle.localizedString(forKey: key, value: nil, table: tableName) method.

If the text is not found, then the raw string is used instead.

For Image, they should correspond to an image named in an asset bundle, and use the UIImage(named: name, in: bundle, compatibleWith: nil) constructor.


For Text strings, if the text key does not resolve to a localized string, the key itself is used as a string.

Object types

Some features require more organization. In the case, JSON Objects can be coerced into generated data classes.

Object types have fields in the same way features have variables. Objects can be used in multiple places, and in more than one feature.

features:
dialog-appearance:
description: A feature to vary the appearance of all toasts and modal dialogs
variables:
positive-button:
type: ButtonAppearance
default:
background-color: blue
text-color: white
neutral-button:
type: ButtonAppearance
default: {}
negative-button:
type: ButtonAppearance
default:
text-color: white
background-color: red
objects:
ButtonAppearance:
description: A button used in dialogs throughout the app
fields:
text-color:
description: The color of the text
type: String
default: black
background-color:
description: The background color
type: String
default: gray

The JSON to recreate the defaults for the feature above would be:

{
"positive-button": {
"text-color": "blue",
"background-color": "white"
},
"neutral-button": {
"text-color": "black",
"background-color": "gray"
},
"negative-button": {
"text-color": "red",
"background-color": "white"
}
}

Structural types

Generic types aren't supported, but in the following section, T can be any other type supported by the FML, including structural types.

  • Option<T> or T?
  • List<T> - lists of type T. Lists are encoded with JSON arrays. Lists are not merged, so are less useful than you might think.
  • Map<K, V> — maps with key type K to V.

Maps are transported as JSON objects, which restrict the types of the keys to types that can be coerced from Strings. Additionally, JSON values that cannot be coerced to the value type of the map are discarded.

features:
homepage:
variables:
sections-enabled:
description: A map of whether or not to display the sections.
type: Map<SectionId, Boolean>
default:
top-sites: true
jump-back-in: false
pocket: false
recently-saved: false
recent-searches: false
section-ordering:
description: The order that the sections appear in on the homescreen.
type: List<SectionId>
value:
- jump-back-in
- pocket
- recently-saved
- recent-searches
defaults:
- channel: nightly
value:
{
top-sites: true,
jump-back-in: true,
recently-saved: true,
recent-searches: true,
pocket: true,
}
enums:
SectionId:
description: An enum representing the sections enabled by the homepage.
variants:
top-sites:
Frecency based URLs
jump-back-in:
Tabs which the user was interrupted while reading.
pocket:
URLs from the Pocket homepage
recently-saved:
URLs which were recently bookmarked or saved to Pocket
recent-searches:
Search queries and their opened results.

In this example, a Map<SectionId, Boolean> is used. SectionId is an enum.

Maps with enum keys must have a default value for every variant of the enum.

Since maps are backed by JSON objects, the merge/patching allows entries to come from the manifest, experiments and rollouts simultaneously.

String aliasing

As the size and complexity of the feature grows, different parts of the configuration are tied together with String keys.

string-alias is a type annotation for FML feature variables. It's effect is to define a String type with a limited set of valid strings.

This is like enums, in that it defines a set of valid values, but unlike enums in that the set is not known at build time.

features:
onboarding:
description: A feature to vary the appearance of all toasts and modal dialogs
variables:
queries:
description: A map of named JEXL queries
type: Map<QueryName, String>
string-alias: QueryName
default:
ALWAYS: 'true'
CHRISTMAS_DAY: '-12-25' in date_string
cards:
type: Map<CardKey, CardData>
string-alias: CardKey
default: {}

objects:
CardData:
description: An onboarding card, which can be optionally displayed or hidden.
fields:
trigger-if:
description: Show this message if the list of queries are all true.
type: List<QueryName>
default:
- ALWAYS
except-if:
description: Hide this message if any of this list of queries are true.
type: List<QueryName>
default: []

In the above example, QueryName is given as the set of strings that are keys for the feature's queries map.

When used in the cards trigger-ifandexcept-iflists, the FML will validate that each item is found in the map. In this manner, experiment owners can add queries to the map and safely use them when defining newcards`, without needing a rebuild or re-release of the code.

For more, please see the string-alias documentation.

Merging other FML files into this one

This include property is a list of files which will be merged with this one. The files may be relative to this one, absolute or URLs.

include:
- nimbus/search.yaml
- @mozilla/nimbus-shared/fml/messaging.yaml

Notice the @ in the second entry: by default this will be interpreted as a GitHub repository, with the file path on the main branch. More about using @ paths here.

In this example, two files are merged into this one: the features, enums and objects from each are added to the corresponding objects in this one.

More

Linking files from other components

The import list is a list of objects referencing other FML files from other components. The code generated from those files are generated at build time of those components, with their own channels and configurations.

import:
- path: ../Accounts/nimbus.fml.yaml
channel: production
- path: @mozilla-mobile/ios-components/components/feature/search/nimbus.fml.yaml
channel: release

The list contains blocks with the following mandatory properties:

  • path: this string value is a relative path or URL to the imported file.
  • channel: this string value is the name of a channel. The channel must be in the channel list of the included file.

An imported file:

  • must have an about block.
  • may have an include block.

Optionally, a features block is provided, which is a Map<FeatureId, List<DefaultBlock>>. This provides a way of providing app specific configuration to already built components.

A list of DefaultBlocks is the same way feature defaulting, and channel specific defaulting works when specifying a feature.

import:
- path: ../Accounts/nimbus.fml.yaml
channel: production
features:
accounts:
- value:
button-color: blue

The value used by the imported code will come from the importing code.

More

Additional feature specific configuration

In order to drive a better experience for experimenter (the Nimbus web server), a number of extra optional fields are needed. See Providing feature metadata for more.

Feature co-enrollment

A feature which allows co-enrollment allows a client to be enrolled in any number of experiments/rollouts for that feature. See Co-enrolling Features for more information.

Feature variables configured by preferences

Some feature variables may be optionally driven by preferences (UserDefaults or SharedPrefences). There are some restrictions and nuances here, so see the documentation for more information.