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.
- Swift
- Kotlin
// Initialize the Nimbus SDK
let nimbus = createNimbusForApplication()
// Connect the generated code to the Nimbus SDK
FxNimbus.shared.initialize { nimbus }
// Initialize the Nimbus SDK
val nimbus = createNimbusForApplication()
// Connect the generated code to the Nimbus SDK
FxNimbus.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:
- Swift
- Kotlin
let newTabConfig = FxNimbus.features.newtab.value()
val 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 ofnewtab
- 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
.
- Swift
- Kotlin
FxNimbus.shared.features.appMenu.recordExposure()
FxNimbus.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. anapp-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. thetoolbar
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 channel
s.
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.
- Swift
- Kotlin
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)
val spotlightConfig = FxNimbus.features.spotlightSearch.value()
if (!spotlightConfig.enabled) {
return
}
// val item = SearchableItem(…)
val oneDayInSeconds: Long = 24 * 60 * 60
val maxAgeInSeconds = spotlightConfig.maxAgeInDays * oneDayInSeconds
item.expirationDate = Date().addSeconds(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.
- Swift
- Kotlin
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
}()
}
data class SpotlightSearch(private val variables: Variables? = null) {
var enabled: Boolean by lazy {
self.variables?.getBool("enabled") ?? false
}
var maxAgeInDays: Int by lazy {
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.
- Swift
- Kotlin
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
}
item.thumbnail = when (spotlightConfig.itemThumbnail) {
ThumbnailType.FAVICON ->
FaviconFetcher.getFaviconFromDiskCache(imageKey: baseDomain)
ThumbnailType.LETTER -> FaviconFetcher.letter(forUrl: url)
ThumbnailType.SCREENSHOT -> tab.screenshot
ThumbnailType.NONE -> null
}
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 value
s 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
- Swift
- Kotlin
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.
Values for Text
and Image
are strings which must correspond to the following format:
[a-z][a-z_0-9]*
The lookup uses the R
class.
Android uses integer identifiers stored in the R
class. Sometimes these identifiers are what is required.
In the above example:
val heroImage = FxNimbus.features.upgradeMessage.value().heroImage
val resId: Int = heroImage.resourceId
val image: Drawable = heroImage.resource
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>
orT?
List<T>
- lists of typeT
. Lists are encoded with JSON arrays. Lists are not merged, so are less useful than you might think.Map<K, V>
— maps with key typeK
toV
.
Maps are transported as JSON objects, which restrict the types of the keys to types that can be coerced from String
s. 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-ifand
except-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 new
cards`, 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.
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 DefaultBlock
s 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.
Additional feature specific configuration
Links to documentation and contacts
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.