Building an A/B Test with EdgeWorkers and EdgeKV
This blog was co-authored by Tim Vereecke, Josh Johnson, and Medhat Yakan
This is a blog series about building an A/B test with EdgeWorkers and EdgeKV. Read part two here.
When paired with our new EdgeKV distributed key-value database, the Akamai EdgeWorkers serverless platform gives you the ability to do powerful things at the CDN level. One common application using these two products is an A/B test: showing users one of two variants of the same webpage to determine which page performs better. Let's build that together.
Note: This blog assumes you have working knowledge of JavaScript. It's also helpful to have read the EdgeWorkers Getting Started Guide to understand how to create and deploy an EdgeWorker.
A/B logic requirements
We'll write an EdgeWorker that randomly places a user into one of two buckets (A or B) and rewrite that user to either an experimental or control URL, depending on the bucket to which they've been assigned.
- The bucket-to-path mapping will be stored in an EdgeKV database
- Client bucket selection will be persistent via a cookie value to ensure a client is locked to the same URL on subsequent visits
- The test will be implemented by redirecting a user to a website with a URI path of /edgekv/abtest. Note that this URI path could be anything; we simply chose this one for convenience
Importing helper libraries
The main.js file in the EdgeWorker code bundle will contain our code. We'll start by importing the modules needed to manipulate cookies and log debug information to the browser console.
import {Cookies, SetCookie} from 'cookies';
import {logger} from 'log';
Handling inbound requests
Next, we need to handle inbound requests received from the client. This is where it's helpful to understand how request flow works at Akamai. As a trusted man in the middle, requests can have four possible stages in a request/response cycle. In the case of an A/B test, we want to ensure that our logic runs on the incoming request from client to edge, as depicted in the diagram below. This means we want to use the onClientRequest stage for our function.
We'll write this as an asynchronous function to ensure it handles the promise-based response for the EdgeKV request. Note that EdgeKV requests use the httpRequest EdgeWorker method.
export async function onClientRequest(request) {
Now that we've defined the function, let's set some variables.
let cookies = new Cookies(request.getHeader('Cookie'));
let bucketCookie = cookies.get('bucket-id');
let requestUrl = request.url.toLowerCase();
let bucket_id = getBucketId(bucketCookie);
let abpath = await getBucketABPath(bucket_id);
let redir_path = getRedirect(abpath, requestUrl);
Here, we extract a cookie value from the cookie request header and populate the bucket-id based on what we find there, lowercasing the requestURL for consistency. The bucket_id field helps in our routing logic, while abpath holds the path associated with the bucket_id value, which will be retrieved from EdgeKV. Note that we use the "await" keyword with abpath to pause the code until the async call to getBucketABPath returns. Last, the redir_path sets the redirect URL where we'll send the client based on the bucket_id and path.
Next, we want to store the bucket_id in a PMUSER variable, which is a variable stored inside Akamai delivery configuration metadata. We set the PMUSER_EKV_ABTEST_EW variable to be used in the onClientResponse phase (see diagram above) to set the response cookie value. We cannot share JavaScript variables between the onClientRequest and onClientResponse stages, so a PM variable is a workaround for that limitation.
Next, we invoke the route method and set the origin and path we need for the redirect. That takes care of routing the request to our desired path.
// Store bucket_id in PMUSER var for onClientResponse
request.setVariable('PMUSER_EKV_ABTEST_EW', bucket_id);
request.route({
origin: request.host,
path: redir_path,
});
}
Setting session stickiness
We also use the onClientResponse event handler to set the bucket-id cookie to support experiment stickiness for the client. In this event handler, we set the bucket_id cookie expiration to be seven days from the current date. We also create the cookie header itself with the SetCookie function. We are manipulating the client response because the origin web server is unaware of the stickiness function we're creating. (The cookie comes from the edge, not the origin server.
export function onClientResponse(request, response) {
// Retrieve the bucket_id from PMUSER var
let bucket_id = request.getVariable('PMUSER_EKV_ABTEST_EW');
if (!bucket_id) {
bucket_id = randBucket(); // Should not happen!
}
// Set bucket_id in cookie with 7 day expiry (A/B selections stickiness)
let expDate = new Date();
expDate.setDate(expDate.getDate() + 7);
let setBucketCookie =
new SetCookie({name: "bucket-id", value: bucket_id, expires: expDate});
response.addHeader('Set-Cookie', setBucketCookie.toHeader());
}
Generating the redirect path
The last bit of code involves constructing the redirect path itself. This replaces the source URI with the destination path we're redirecting the user to for the experiment.
function getRedirect(abpath, req_url) {
let relpath = '/' + abpath + '/';
return req_url.toLowerCase().replace("/edgekv/abtest", relpath);
}
Experiment/control allocation
Now, let's focus on the main logic that determines to which experiment the client will be assigned. We create a helper function that will be used to randomly allocate a user to bucket A or B, using the equivalent of a 50-50 coin toss.
function randBucket() {
let x = Math.random();
if (x < 0.5){
return 'A';
}
else {
return 'B';
}
}
We also create a getBucketID helper function that helps us retrieve the value of the bucket from the cookie. If the cookie isn't available or doesn't contain a valid bucket, we use the randomization function above to assign a random bucket to the client. The bucket-id is then normalized to uppercase for consistency.
function getBucketId(bucket_cookie) {
if (!bucket_cookie) {
return randBucket();
}
// Return a random bucket if cookie is not set
if (bucket_cookie.toUpperCase() == 'A') {
return 'A';
} else if (bucket_cookie.toUpperCase() == 'B') {
return 'B';
} else {
return randBucket();
}
}
Here, we're setting the A/B path information statically inside the main.js file, which is a convenient way to get started.
const default_path = "ekv_experience/default";
const bucketPathMap = new Map([["A", "ekv_experience/experiment-A"],
["B", "ekv_experience/experiment-B"]]);
async function getBucketABPath(bucket_id) {
// If we do not have a valid bucket, we will default to the following
if (!bucket_id) {
return default_path;
}
let path = bucketPathMap.get(bucket_id.toUpperCase());
return path || default_path;
}
Summary
Until now, we've focused solely on writing the core JavaScript code for our A/B test and hardcoded the values. In the next blog, we'll layer in EdgeKV to store and retrieve the redirect path based on the bucket ID values we've defined.