Dynamically routing user requests to multiple CloudFront origins

We can use Lambda@Edge functions to dynamically change CloudFront origins based on request parameters and perform production testing with a small subset of users.

1. The scenario

Say we have a new website and want to test it with real users in a production environment. We allow a subset of users access to the new page while the majority should still go to the existing site. This testing type is known as A/B testing or split testing.

In this scenario, we host the website on S3. We have two buckets, one for the existing site and another for the new one, which we want to test. We have also set up a CloudFront distribution in front of the buckets for optimization and security reasons. The buckets are private, and only the distribution can access them through an origin access control. The website files are in the root of the buckets, so no v2 folder exists for the new variant. Both the existing and new sites should be available under the same URL.

It’s a requirement that 10% of the users should view the new site via the same custom domain name as the existing page. We need to perform the user selection and redirection inside the AWS infrastructure.

2. Solution overview

The CloudFront distribution has two origins, one for the existing bucket and another for the new one. It also has the default behavior with the * path pattern and the existing bucket defined as the origin.

CloudFront can deploy Lambda functions to the points of presence around the world and invoke them in various situations. We can use these functions to modify the requests and responses originated from both the users and the origin.

We can create two Lambda functions.

First, we decide if the user is lucky to be in the 10% who has the privilege to access the new page. We want to remember these users, so we add a cookie to their requests. We invoke this function on the viewer request.

The second function assigned to the origin request will check if the cookie is present. If so, the function will change the default origin (pointing to the existing website bucket) to the new one.

3. Settings and code

Let’s see how we can set up this solution.

3.1. CloudFront settings

We need a custom origin request policy that forwards our cookie called X-New-Page-Test (the name can be different) to the origin. We want the second (origin request) Lambda@Edge function to decide if it needs to redirect the request to the new origin.

3.2. Viewer request Lambda

We associate the first Lambda function to the viewer request (configurable on the Behavior).

An example code can look like this:

import { CloudFrontRequestEvent, CloudFrontRequest, CloudFrontResponse }
from 'aws-lambda';

const NEW_PAGE_COOKIE = 'X-New-Page-Test';

export const handler = async (event: CloudFrontRequestEvent):
  Promise<CloudFrontRequest | CloudFrontResponse> => {
  const request = event.Records[0].cf.request;
  const cookies = request.headers.cookie;

  // 1. Check if the cookie is present
  const hasNewPageCookie = cookies?.find((cookie) =>
    cookie.value.includes(NEW_PAGE_COOKIE));
  if (hasNewPageCookie) {
    // The cookie is present, and the function has nothing to do here.
    // The origin request function will handle it.
    return request;
  }

  // 2. If the cookie is not present, we decide if the user should access
  // the new page
  const shouldGoToNewPage = Math.random() >= 0.9;
  if (shouldGoToNewPage) {
    const response = {
      status: '302',
      statusDescription: 'Found',
      headers: {
        'cache-control': [
          {
            key: 'Cache-Control',
            value: 'no-store',
          },
        ],
        'set-cookie': [
          {
            key: 'Set-Cookie',
            value: `${NEW_PAGE_COOKIE}=true`,
          },
        ],
        location: [
          {
            key: 'Location',
            value: request.uri,
          },
        ],
      },
    };
    // 3. We send the request back to the browser with the cookie
    return response;
  }

  // 4. The user doesn't belong to the tester group, so no cookie is set.
  // The request proceeds as intended.
  return request;
};

The function does a couple of things.

It checks if the X-New-Page-Test cookie is in the request (1). If so, it means that we already set it up earlier. The request can proceed with the cookie, and the origin request Lambda@Edge function will re-route it to the new bucket.

If the cookie is not in the request, the function decides if it should add to it (2). The Math.random() function returns a random number between 0 and 1. If the number is greater than or equal to 0.9, the user will belong to the 10% who get access to the new version. In this case, we add the X-New-Page-Test cookie and respond with a 302 status code. It will indicate to the browser that it should redirect the request to the URL specified in Location. But the value of the Location header is the same as before, so the browser will call the same URL again. This time, the cookie will be part of the request (3).

If the user turns out to belong to the 90% based on the Math.random() function’s outcome, we don’t set the custom cookie. Everything will go as usual this time, and we return the original request object (4).

3.3. The origin request Lambda

The second Lambda@Edge function will redirect the user to the new site if the X-New-Page-Test cookie is in the request.

Its code can look like this:

import { CloudFrontRequestEvent, CloudFrontRequest } from 'aws-lambda';

const NEW_PAGE_COOKIE = 'X-New-Page-Test';

export const handler = async (event: CloudFrontRequestEvent):
  Promise<CloudFrontRequest> => {
  const request = event.Records[0].cf.request;
  const cookies = request.headers.cookie;

  // 1. Check if our cookie is in the request
  const hasNewPageCookie = cookies?.find((cookie) =>
    cookie.value.includes(NEW_PAGE_COOKIE));
  if (hasNewPageCookie) {
    // 2. Change the relevant request fields to the new value
    const newPageDomainName = 'NEW_BUCKET_NAME.s3.eu-central-1.amazonaws.com';
    request.headers.host = [{ key: 'Host', value: newPageDomainName }];
    request.origin = {
      s3: {
        domainName: newPageDomainName,
        authMethod: 'none',
        customHeaders: {},
        path: '',
        region: 'eu-central-1',
      },
    };

    return request;
  }

  // 3. The cookie is not in the request, so go to the original page
  return request;
};

It starts similarly to the first function. We check if the X-New-Page-Test cookie is part of the request (1).

If so, we change the host header and domainName field in the origin object to the domain name of the second bucket that contains the new version (2). We then return the modified request object. CloudFront will then know that the request should go to the second origin.

If the cookie is not in the request, we return the same request object we received from CloudFront. In this case, the request will proceed to the existing bucket as usual.

4. Considerations

I want to highlight some points regarding the solution.

4.1. Lambda permissions

CloudFront can only invoke the functions if we add the edgelambda.amazonaws.com service to the Principal element. So the trust policy of both Lambda functions’ execution role should look like this:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": [
          "edgelambda.amazonaws.com",
          "lambda.amazonaws.com"
        ]
      },
      "Action": "sts:AssumeRole"
    }
  ]
}

The permission policy is a different discussion. In our case, the AWSLambdaBasicExecutionRole AWS-managed policy is sufficient as the functions don’t call any other AWS service.

4.2. Other Lambda requirements

For a Lambda function to become Lambda@Edge, we should be aware of some caveats:

  • The functions must be deployed to us-east-1 regardless of the buckets' regions. Because CloudFront is a global service, it lives in Northern Virginia.
  • The maximum allowed function timeout is 5 seconds.
  • Lambda@Edge only supports the x86_64 architecture. We can't use Graviton-based processors at the moment for Lambda@Edge.
  • We must specify a qualified function ARN when we configure Lambda@Edge in the CloudFront behavior. It means that we must create a numbered version of the function and can't use $LATEST.
  • We can't use environment variables in the functions.

4.3. Logs in the closest region

Lambda@Edge functions send their logs to a log group in the region closest to the user. It’s eu-central-1 for me, even if I have deployed the functions to us-east-1.

So, if your first thought is that CloudFront doesn’t trigger the function, check CloudWatch Logs in the region closest to you before starting debugging.

We can always aggregate the logs into a single log group in a dedicated region, but it’s beyond the scope of this article.

4.4. Not the only way to solve the problem

As always, the solution described above is not the only way to do A/B testing with CloudFront. Requirements are always different, and there might be a solution that suits a specific use case better.

That said, you might find an easier way to perform a similar logic. A different solution may be a better fit for your scenario, which is it’s OK. We can solve one problem in multiple ways. I added some links to the end that provide some alternative solutions.

5. Summary

We can use Lambda@Edge functions to perform live A/B testing with CloudFront. We want to send a group of users to the new version of our website by adding a custom cookie to the request. A Lambda function can inspect the request, and if the cookie is present, it can dynamically point the request to the new origin.

6. Further reading

Content-based dynamic origin selection - examples - Dynamically selecting origin for different scenarios

Example: A/B testing - Path-based A/B testing in CloudFront

Performing A/B tests on static websites using Cloudfront and Lambda@Edge - Great article on the same problem with a slightly different solution

Getting started with Lambda - How to create a Lambda function

Creating a distribution - How to create a CloudFront distribution