Your First Openmix Application

stevenlyons edited this page Aug 4, 2017 · 11 revisions

Your First Openmix Application

This page describes a quick and easy way to create a working application by building on the examples in this library. Although this is meant as a basic walk through, some experience with javaScript or a similar language will be useful.

For an in-depth look at writing Openmix applications from scratch, check out Writing Openmix Applications.

Before Getting Started

Before anything else make sure that your system has everything needed for Openmix application development. You'll install the JavaScript interpreter which will let you test your Openmix application on your local computer. This provides valuable feedback during development and helps to ensure that your application behaves exactly as you expect.

To install node.js and its prerequisites, please refer to the setup instructions.

Getting Started

With node successfully installed, download the Openmix application library and unzip this file to your local drive. You'll see directories for each of the applications in the Openmix Application Library.

We'll be exploring the basic performance and availability application for the rest of this tutorial. The two files we'll be looking at are:

  • apps-javascript/optimal-rtt/app.js - the Openmix application
  • apps-javascript/optimal-rtt/test/tests.js - the unit tests for the application

Open a command window (cmd.exe or PowerShell, on Windows) and navigate to the "optimal-rtt" directory.

Install the application dependencies:

$ npm install

Once the dependencies are installed, you can run the application unit tests. Here's the command to run the tests, followed by typical output:

$ ./run-tests.sh

Running Openmix application unit tests

INFO [karma]: Karma v0.10.10 server started at http://localhost:9876/
INFO [launcher]: Starting browser PhantomJS
INFO [PhantomJS 1.9.8 (Mac OS X)]: Connected on socket 2esgYqMd3TSPMTcEwDW6
PhantomJS 1.9.8 (Mac OS X): Executed 9 of 9 SUCCESS (0.076 secs / 0.018 secs)

All unit tests passed

But what is an Openmix app? Let's look at apps-javascript/optimal-rtt/app.js from top to bottom.

First notice the OpenmixApplication object declaration. It encapsulates the functions that are called as part of the Openmix application execution. It provides two functions as part of the Openmix interface: init and onRequest.

The init method is called exactly once, when the app is first initialized. Here, the application must declare what input data it expects to use.

The onRequest method is called once for every DNS request. It draws data from the API, makes a decision about what provider to select, and optionally specifies a reason code, which is metadata describing why the decision was made.

providers: {
    'foo': {
        cname: 'www.foo.com',
    },
    'bar': {
        cname: 'www.bar.com',
    },
    'baz': {
        cname: 'www.baz.com',
        padding: 0,
    },
    'qux': {
        cname: 'www.qux.com',
    }
},
// The minimum availability score that providers must have in order to be considered available
availability_threshold: 90,
default_provider: 'foo',
default_ttl: 90,

At the top is an array of data objects that define the providers that are available to use in the Openmix application. In the providers array, the property name, such as 'foo', defines the name of the provider to which the application can refer. Each provider includes a 'cname' property that specifies the cname that will be used for the Openmix decision if the provider is chosen.

The following lines define three more variables availability_threshold, default_provider and default_ttl. availability_threshold is the availability below which a provider is not considered a candidate for selection. default_provider is the provider that will be chosen by default if no other provider meets the criteria specified. default_ttl is the default TTL value that will be used.

All member variables defined in the OpenmixApplication handler object are available in the init and onRequest functions via the settings object. For example, the default ttl is available by referring to the settings.default_ttl variable.

The init handler function begins at line 75. The init handler accepts one argument that contains the settings defined in the application handler. The init handler function calls the do_init function on line 94, where the work for initialization is actually contained.

this.do_init = function(config) {
    var i = aliases.length,
        alias;

    while (i --) {
        alias = aliases[i];
        config.requireProvider(alias);
        settings.providers[alias].padding = settings.providers[alias].padding || 0;
    }
};

In the do_init function, the providers that are available during for decisions are declared. The requireProvider function is called with the alias and cname of each provider. Platforms are registered so that they can be made available for reporting and analysis.

That's it for the init method. Next is defined the onRequest method. This is the real workhorse of an Openmix app, since it gets called for each and every DNS request that Openmix services. Therefore, it should be written to perform as efficiently as possible.

The onRequest method takes 2 arguments:

  • request, a Request object
  • response, a Response object

The first thing the onRequest method does, on line 112, is obtain the availability for all of the platforms by calling:

var dataAvail = request.getProbe('avail')

This function call returns an object that contains the RUM availability of each provider configured for the application. Providers with availability that falls below the availability threshold, specified in the availability_threshold variable on line 33, are filtered from the list of providers able for selection in the decision.

The filtering of available providers is done in the filterCandidates function:

function filterCandidates(candidate, alias) {
    var provider = settings.providers[alias];
    // Considered only available providers in the provider countries/markets/asn
    return (candidate.avail !== undefined && candidate.avail >= settings.availability_threshold)
        && provider !== undefined
        && (provider.except_country === undefined || provider.except_country.indexOf(request.country) === -1)
        && (provider.countries === undefined || provider.countries.indexOf(request.country) !== -1)
        && (provider.markets === undefined || provider.markets.indexOf(request.market) !== -1)
        && (provider.asns === undefined || provider.asns.indexOf(request.asn) !== -1);
}

After the unavailable providers are filtered from the list of candidates, the HTTP round-trip response time for providers is retrieved. The providers are then ranked based on the round-trip time.

request.getProbe('http_rtt'),

The getProbe('http_rtt') call returns a Javascript object that contains an array of average RTT values for the user's network, keyed by provider alias. It might look something like this

{
    "foo": {
        "http_rtt": 200
    },
    "bar": {
        "http_rtt": 199 
    },
    "baz": {
        "http_rtt": 198
    },
    // providers without data will not be included in this object
}

The first thing the application does with the round-trip time probe values is filter out any providers that don't have an RTT value and filter out any providers returned from the getProbe call that we don't have specified in our providers array. This defensive programming allows us to add or remove providers from the application or Openmix configuration without unwanted side-effects. If a provider is selected that is not configured in the application, it may trigger a Fallback.

candidates = intersectObjects(candidates, dataRtt, 'http_rtt');
candidateAliases = Object.keys(candidates);

The application does a check to see if only one provider has an RTT value. If so, it sets a variable with that provider rather than doing additional processing. The code sets the desired provider and the TTL for the response.

The response also sets the reason code for the response. The reason code is a code that is logged along with the response that allows for debugging information. It can be accessed through the Openmix logs to provide visibility into the reason for a decision. The reason code is not returned as part of the response and is only used for logging and analysis in the Openmix platform.

if (candidateAliases.length === 1) {
    decisionProvider = candidateAliases[0];
    decisionReasons.push(allReasons.optimum_server_chosen);
    decisionTtl = decisionTtl || settings.default_ttl;
}

Question: How do we know it selected code 'A' with the code on line 180?

Answer: It's what's specified in the allReasons object at key optimum_server_chosen.

If there is more than 1 provider returned, the application chooses the provider with the best performance. It calls the getLowest function which returns the provider with the lowest round-trip time.

else if (candidateAliases.length !== 0) {
    // Apply padding to rtt scores
    addRttPadding(candidates);
    decisionProvider = getLowest(candidates, 'http_rtt');
    decisionReasons.push(allReasons.optimum_server_chosen);
    decisionTtl = decisionTtl || settings.default_ttl;
}

The application then deals with the case where there are no providers with RTT data. The code below chooses the default provider configured for the application on line 68. It also sets the ttl to be the value used when an error occurs and sets the reason code to the value for allReasons.no_available_servers so that the logs reflect why the fallback was returned..

if (decisionProvider === '') {
            decisionProvider = settings.default_provider;
            decisionTtl = decisionTtl || settings.error_ttl;
            decisionReasons.push(allReasons.no_available_servers);
        }

Finally, the application sets the response values with the variables that were set during processing. The response.respond method specifies the provider key and the CNAME for the response. Similarly, the response.setTTL and response.setReasonCode functions set the corresponding values for the response.

response.respond(decisionProvider, cnameOverride + settings.providers[decisionProvider].cname);
response.setTTL(decisionTtl);
response.setReasonCode(decisionReasons.join(','));

That's the basic "Performance with Availability Threshold" app, the basis for many real-world customer apps.

Unit Testing

Unit tests are used to verify that the application logic is valid and performs as expected. It allows you to pass test values into your Openmix application and show the expected decisions are made based on known criteria. It is strongly suggested that you create unit tests that validate your application logic so that you have confidence the Openmix routing is working as expected. Always run your unit tests before you upload a new version of your Openmix application.

Now have a look at optimal-rtt/test/tests.js.

Although we can't really describe karma and unit testing in great depth within the scope of this document, we will do a walk-through of this file and describe what it's doing when you run the tests in it.

Line 4 imports the do_init function that will be tested in the Openmix app. Similarly, the handle_request function is imported on line 51.

module('do_init');

...

module('handle_request');

The test_init function sets up an easy way to test the do_init function which is run once at application start on each Openmix server on which it runs. It declares some generic data to pass into the OpemixApplication and a generic setup/test/assert process. Tests use this function to pass in test data without needing to re-do the testing logic.

function test_init(i) {
    return function() {

        var sut = new OpenmixApplication(i.settings),
            config = {
                requireProvider: this.stub()
            },
            test_stuff = {
                instance: sut,
                config: config
            };

        i.setup(test_stuff);

        // Test
        sut.do_init(config);

        // Assert
        i.verify(test_stuff);
    };
}

The function uses function properties to setup the test. The setup function on line 18 is used create the environment data for the test and verify on line 24 provides the test a way to verify the test specific conditions.

The first test should help illustrate the process. Tests are contained in a test function call. The first argument for the function is the test description (in the test below it is called 'basic') and the second is the function it the test will use. The test calls the test_init generic test function with properties for settings, setup and verify.

test('basic', test_init({
    settings: {
        providers: {
            'foo': {
                cname: 'www.foo.com',
                padding: 0
            },
            'bar': {
                cname: 'www.bar.com',
                padding: 0
            }
        }
    },
    setup: function() {
        return;
    },
    verify: function(i) {
        equal(i.config.requireProvider.callCount, 2);
        equal(i.config.requireProvider.args[1][0], 'foo');
        equal(i.config.requireProvider.args[0][0], 'bar');
    }
}));

The settings are used to setup the providers and defaults for the test run. Setup can be used to run additional code on test initialization. Verify is used to check that the expected output is generated. For do_init tests, you will want to verify that the providers and other settings are set correctly. For this test, it checks that the providers are both set and named correctly. handle_response tests will usually check the decision criteria to ensure that the expected decision is made with the passed-in inputs.

test('basic', test_init({
    settings: {
        providers: {
            'foo': {
                cname: 'www.foo.com',
                padding: 0
            },
            'bar': {
                cname: 'www.bar.com',
                padding: 0
            }
        }
    },
    setup: function() {
        return;
    },
    verify: function(i) {
        equal(i.config.requireProvider.callCount, 2);
        equal(i.config.requireProvider.args[1][0], 'foo');
        equal(i.config.requireProvider.args[0][0], 'bar');
    }
}));

The handle_request tests simulate the code that runs each time a request is made to the Openmix application. Similarly to test_init, the test_handle_request provided a generic test function that can be used to simplify the individual tests.

function test_handle_request(i) {
    return function() {
        var sut = new OpenmixApplication(i.settings),
            request = {
                getProbe: this.stub()
            },
            response = {
                respond: this.stub(),
                setTTL: this.stub(),
                setReasonCode: this.stub()
            },
            test_stuff = {
                instance: sut,
                request: request,
                response: response
            };

        i.setup(test_stuff);

        // Test
        sut.handle_request(request, response);

        // Assert
        i.verify(test_stuff);
    };
}

Each test is with a test function call. The callback function passed to the test function is split into three parts: setting, setup and verification.

test('foo fastest', test_handle_request({ ... }

The settings pass in the configured providers in the providers object and the application defaults below that.

    settings: {
        providers: {
            'foo': {
                cname: 'www.foo.com',
                padding: 0
            },
            'bar': {
                cname: 'www.bar.com',
                padding: 0
            }
        },
        availability_threshold: 90,
        market_to_provider: {},
        country_to_provider: {},
        conditional_hostname: {},
        geo_override: false,
        geo_default: false,
        default_provider: 'foo',
        default_ttl: 20,
        error_ttl: 10
    },

The setup property allows you to configure the probe data that will be returned to the application. With this, you can test the logic of the application with different provider performance. For example, you can set the availability all of the providers to less than the threshold and verify that the fallback provider is returned.

    setup: function(i) {
        i.request.getProbe.onCall(0).returns({
            foo: { avail: 100 },
            bar: { avail: 100 }
        });
        i.request.getProbe.onCall(1).returns({
            foo: { http_rtt: 200 },
            bar: { http_rtt: 201 }
        });
    },

The availability for each provider can be specified with the avail property and the http round-trip time with the http_rtt property. One thing to note is that the stubs are based on the order the getProbe functions are called. For example, if you move the RTT probe retrieval before the availability probe, you will need to update the tests to switch the order of the data returned.

The verify function ensures the expected provider is selected. The equal function allows you to test that the correct provider is selected by the application.

    verify: function(i) {
        equal(i.response.respond.callCount, 1, 'Verifying respond call count');
        equal(i.response.setTTL.callCount, 1, 'Verifying setTTL call count');
        equal(i.response.setReasonCode.callCount, 1, 'Verifying setReasonCode call count');

        equal(i.response.respond.args[0][0], 'foo', 'Verifying selected alias');
        equal(i.response.respond.args[0][1], 'www.foo.com', 'Verifying CNAME');
        equal(i.response.setTTL.args[0][0], 20, 'Verifying TTL');
        equal(i.response.setReasonCode.args[0][0], 'A', 'Verifying reason code');
    }
}));

The provider alias and CNAME are verified by testing the arguments passed to the respond function. The provider alias is the first argument and the CNAME is the second. The arguments are addressed as a 0-based array so the alias is [0] and the CNAME is [1].

        equal(i.response.respond.args[0][0], 'foo', 'Verifying selected alias');
        equal(i.response.respond.args[0][1], 'www.foo.com', 'Verifying CNAME');

The TTL is set with the setTTL function so you will use the response.setTTL.args property to access the TTL value.

        equal(i.response.setTTL.args[0][0], 20, 'Verifying TTL');

Similarly, the reason code is set with setReasonCode function and accessed by that name in the tests. This is an important response value to test because one provider can be selected for many reasons so just testing the provider does not guarantee the app is returning the expected value. Reason codes allow you to verify the reason the provider is selected and have confidence the logic is working as expected.

        equal(i.response.setReasonCode.args[0][0], 'A', 'Verifying reason code');

Fallbacks

The Fallback CNAME is a default response that can be returned for a few reasons:

  • Switching between versions of your application, like when you upload a new script, will cause a very brief (milliseconds) time period of fallbacks. This occurs as the new script is being initialized after the old one is removed.
  • If Openmix is overloaded it will respond with the Fallback CNAME since this causes less load on the service. This is extremely rare, and I'm not sure if it has ever happened.
  • Your application causes an error of some kind. If this is the case you are likely to see many many fallback responses.

Seeing a small number of fallback responses generally expected. However, a large number of fallbacks happening frequently means something isn't right and you should dig into it. Please contact Cedexis Support if you need any help.

Every Openmix application has an associated fallback CNAME, which is specified when uploading or updating it in the Portal. Every application should be programmed such that all execution paths result in some kind of provider selection. Applications should have a default provider that is specified if no other code path sets a provider CNAME.

Including the hostname prefix in the fallback CNAME

Openmix applications can utilize the hostname prefix included on the DNS request to perform additional logic. The hostname prefix can also be used to populate a CNAME template that includes a ${hostname} placeholder at runtime, including the fallback CNAME.

For example, suppose the fallback CNAME is set to "${hostname}.foo.com", and a DNS request is made for prefix.2-01-29a4-002d.cdx.cedexis.net. If the app responds with fallback, the answer will be "prefix.foo.com".

Going Further

If you'd like to get deeper into writing Openmix applications, check out Writing Openmix Applications.