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 SDKlet nimbus = createNimbusForApplication()// Connect the generated code to the Nimbus SDKFxNimbus.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.focuschannels:  - developer  - nightly  - releasefeatures: {}

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: Double        default: 28.0

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 * 60let maxAgeInSeconds = Double(spotlightConfig.maxAgeInDays) * oneDayInSecondsitem.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 keepNumDays: Double = {        self.variables?.getDouble("enabled") ?? 28.0    }()}

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: letterenums:  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: Double        default: 28.0      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  - releasefeatures:  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: redobjects:  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.

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