Example

This document describes the end-to-end process of developing, shipping, and executing a new action with SHIELD, in order to illustrate in detail the mechanics of the system.

In this example, we want to add the ability to SHIELD to prompt users with a pop-up notification with some configurable text, and only prompts users once.

1. Adding a Function to the Driver

The first step is to add a new function to the driver that will trigger the notification on the client. The driver is implemented in the system add-on, so we update the add-on to implement the function:

showNotification(message)

Display a pop-up notification to the user.

Arguments
  • message – Message to show in the pop-up.

The change has to be merged into the system add-on and released before it will be available to actions to use.

2. Write a Notification Action

Next, we must develop an action that uses showNotification to display the notification given in the action’s arguments. This includes the logic for only showing the notification if the user hasn’t seen it before:

export default class NotificationAction {
  constructor(normandy, recipe) {
    this.normandy = normandy; // The driver object
    this.recipe = recipe; // Contains recipe arguments and other data

    // Persistent data store on the client.
    this.storage = normandy.createStorage(recipe.id);
  }

  async execute() {
    // Check if we've shown the notification previously, and show it if we
    // have not.
    const haveShownPreviously = await this.storage.getItem('shownPreviously');
    if (!haveShownPreviously) {
      this.storage.setItem('shownPreviously', true);
      this.normandy.showNotification(this.recipe.arguments.message);
    }
  }
}

registerAction('notification', ConsoleLogAction);

Note

In addition to the code above, actions have to define a JSON Schema as well as a form for the arguments. These are used in the control interface in Normandy as well as in the system add-on to validate arguments.

After creating the action, it must be deployed to the Normandy service before it can be used in a recipe.

3. Create a Recipe

The next step is to create a recipe that uses the notification action that we created above. This is done via the control interface on the Normandy service itself. Important fields to input via the web interface include:

  • Action: The notification action.

  • Filter Expression: A JEXL statement to filter the recipe so that only certain users see it. For testing purposes, the expression true will match all users.

  • Arguments: A single message field containing the message to display.

Once created, the recipe will need to be submitted for peer review and approved by another users before it is enabled within the service.

4. Delivery

Once the recipe is enabled, the service will include it in queries to the recipe API. The system add-on performs the following steps to fetch and execute our new recipe:

  1. Upon activation, send a POST request to /api/v1/recipe/?enabled=true to retrieve all currently-enabled recipes. This returns a JSON response that looks similar:

    [
       {
            "id": 1,
            "name": "Notification",
            "enabled": true,
            "revision_id": 1,
            "action_name": "notification",
            "arguments": {
                "message": "Notification message!"
            },
            "filter_expression": "true"
       }
    ]
    

    Note

    Some fields were removed from the response above for readability.

  2. For each recipe, evaluate its filter_expression field as a JEXL expression against a context containing information about the client and environment that it is running in. If the expression returns true, then the recipe matches the client and will be run. Otherwise, the recipe is discarded.

    The /api/v1/classify_client/ API endpoint is used to populate the context with the current server time and the country the user is located in via IP address geolocation.

  3. For each matching recipe, download the action specified in the recipe if it hasn’t been downloaded yet. Actions served from URLs of the form /api/v1/action/notification/ and return a response that looks like:

    {
       "name": "show-heartbeat",
       "implementation_url": "https://normandy.cdn.mozilla.net/v1/action/notification/implementation/4574dbc126af07cd031a0da29d625a11365403ea/",
       "arguments_schema": {
             "$schema": "http://json-schema.org/draft-04/schema#",
             "title": "Display a pop-up notification",
             "type": "object",
             "required": [
                 "message"
             ],
             "properties": {
                 "message": {
                     "description": "Message to show in the notification",
                     "type": "string",
                     "default": ""
                 }
             }
         }
    }
    

    In addition, the JavaScript code for the action is downloaded via the URL in the implementation_url property of the response above.

  4. For each matching recipe, execute the action associated with it in a sandbox, passing in information about the recipe (including its arguments) and the driver object.

After these steps, the notification action and recipe that we created will have been downloaded and executed, and the user will see a notification pop up. Future runs of that specific recipe will not show a notification.