├── .gitignore ├── README.md ├── content.zip ├── edge-functions └── src │ ├── common.js │ ├── originrequest.js │ ├── originresponse.js │ ├── origins_config.js │ └── viewerrequest.js ├── resources ├── Navigate_to_cloudformation.png ├── access_denied.png ├── cloud9_intro.png ├── cloudformation-lambda-dist-output.png ├── cloudfront-events-that-trigger-lambda-functions.png ├── cloudfront_url.png ├── create_bucket.png ├── create_bucket2.png ├── create_bucket3.png ├── create_bucket4.png ├── deployed_lambda-edge_fns.png ├── file_upload.png ├── first_deploy_done.png ├── lambda@edge_internals.png ├── network_log.png ├── oai_for_lambda_edge_lab.png ├── origin-a-bucket-empty.png ├── origin_a.png ├── origin_b.png ├── origin_config.png ├── pool=a_cookie.png ├── pool=b_cookie.png ├── s3_origin_bucketslist.png ├── sam-deploy-output.png ├── select_us-east-1.png ├── simple_arch_diagram.png └── uploaded-content-origin-a.png └── template.yml /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | samconfig.toml 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # A/B testing with lambda@edge 3 | 4 | This lab is provided as part of [AWS Summit Online](https://aws.amazon.com/events/summits/online/), click [here](https://bit.ly/2yLtZqL) to explore the full list of hands-on labs. 5 | 6 | ℹ️ You will run this lab in your own AWS account. Please follow directions at the end of the lab to remove resources to avoid future costs. 7 | 8 | 9 | ## Overview 10 | 11 | In this lab we will learn how we can use lambda@edge functions to serve different variants of the same static resources from a CloudFront distribution. 12 | 13 | This ability can be used to enable A/B testing of staticly deployed websites without resorting to complex and resource intensive conditional server-side rendering of content. 14 | 15 | Headings with the ⓘ symbol indicate Information only sections of this README 16 | 17 | Those with the ☛ symbol indicate action to be taken to complete the lab 18 | 19 | 20 | ## Setting up the Lab Environment 21 | 22 | To run this lab, you will require an AWS account. You will be using Cloud9, which is a web-based development environment that provides a terminal program running on a virtual machine that has the AWS CLI pre-installed and configured 23 | 24 | 1. Login to your [AWS Account Console](https://console.awas.amazon.com) 25 | 2. From the *Services* menu, select *Cloud9* 26 | 27 | If you are prompted for a region, select the one closest to you. 28 | 29 | 3. Click the *Create Environment* button 30 | 4. for Name, enter: `lambda-edge-lab` 31 | 5. Click *Next step* twice, to accept the default options, then click *Create Environment* 32 | 33 | Cloud9 will take a few minutes to launch the environment. Once it is ready, continue to the next step. 34 | 35 | ![Cloud9 welcome screen](/resources/cloud9_intro.png) 36 | 37 | 6. In the bash terminal at the bottom of the screen (showing `/environment $`), run the following command to download a copy of this lab to your Cloud9 environment: 38 | 39 | ```bash 40 | git clone https://github.com/justasitsounds/lambda-edge-lab 41 | ``` 42 | 43 | *Hint*: You can expand the size of the terminal pane. 44 | 45 | 46 | ## ⓘ What are we building? 47 | 48 | Our imaginary scenario is that our colleagues in the marketing team have some ideas about improving customer engagement on our company's website by tweaking some of the design features of our site 49 | 50 | The functionality we need: 51 | 1. Randomly split unassigned traffic into two groups or pools 52 | 2. Serve two different variants of content from either of two origin S3 buckets (the `A` or `B` bucket) 53 | 3. Ensure that subsequent requests from the same clients will be served the same content that they first received (session stickyness) 54 | 4. Record and compare the impact the changes make to the customer engagement 55 | 56 | 57 | To get started we will deploy: 58 | - the CloudFront distribution 59 | - an S3 bucket to retain the CloudFront access logs 60 | - a CloudFront Origin Access Identity - a special CloudFront user that is associated with our CloudFront distribution (https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html) 61 | - the two origin S3 buckets with attached bucket policies that grants the Origin Access Identity `s3:getObject` permission on all the resources within the bucket 62 | 63 | 64 | ![Simple architecture](resources/simple_arch_diagram.png) 65 | 66 | 67 | There is a SAM template in this solution `template.yml` that we will use to deploy this stack. SAM templates are an extension of AWS CloudFormation templates that are focussed on providing a shorthand way of deploying AWS Lambda functions. 68 | 69 | ```yaml 70 | AWSTemplateFormatVersion: '2010-09-09' 71 | Transform: AWS::Serverless-2016-10-31 72 | Description: A/B testing using cloudfront and lambda@edge 73 | 74 | Resources: 75 | 76 | OriginAccessIdentity: 77 | Type: AWS::CloudFront::CloudFrontOriginAccessIdentity 78 | Properties: 79 | CloudFrontOriginAccessIdentityConfig: 80 | Comment: Origin Access Identity for lambda@edge dev lab 81 | 82 | LogBucket: 83 | Type: "AWS::S3::Bucket" 84 | 85 | CFDistribution: 86 | Type: AWS::CloudFront::Distribution 87 | Properties: 88 | DistributionConfig: 89 | Logging: 90 | Bucket: !GetAtt LogBucket.DomainName 91 | IncludeCookies: true 92 | Enabled: true 93 | DefaultRootObject: index.html 94 | Comment: 'AB testing Cloudfront distribution' 95 | Origins: 96 | - Id: s3 97 | DomainName: !GetAtt OriginABucket.DomainName 98 | S3OriginConfig: 99 | OriginAccessIdentity: !Join [ "/", [ origin-access-identity, cloudfront, !Ref OriginAccessIdentity ]] 100 | DefaultCacheBehavior: 101 | TargetOriginId: s3 102 | ForwardedValues: 103 | QueryString: false 104 | Cookies: 105 | Forward: whitelist 106 | WhitelistedNames: 107 | - pool 108 | ViewerProtocolPolicy: redirect-to-https 109 | DefaultTTL: 30 110 | MinTTL: 0 111 | AllowedMethods: 112 | - HEAD 113 | - GET 114 | CachedMethods: 115 | - HEAD 116 | - GET 117 | SmoothStreaming: false 118 | Compress: true 119 | 120 | OriginABucket: 121 | Type: AWS::S3::Bucket 122 | Properties: 123 | BucketName: !Join 124 | - "-" 125 | - - "ab-testing-origin-a" 126 | - !Select 127 | - 0 128 | - !Split 129 | - "-" 130 | - !Select 131 | - 2 132 | - !Split 133 | - "/" 134 | - !Ref "AWS::StackId" 135 | AccessControl: Private 136 | Tags: 137 | - Key: purpose 138 | Value: lab 139 | - Key: project 140 | Value: lambda-edge-ab 141 | 142 | OriginBBucket: 143 | Type: AWS::S3::Bucket 144 | Properties: 145 | BucketName: !Join 146 | - "-" 147 | - - "ab-testing-origin-b" 148 | - !Select 149 | - 0 150 | - !Split 151 | - "-" 152 | - !Select 153 | - 2 154 | - !Split 155 | - "/" 156 | - !Ref "AWS::StackId" 157 | AccessControl: Private 158 | Tags: 159 | - Key: purpose 160 | Value: lab 161 | - Key: project 162 | Value: lambda-edge-ab 163 | 164 | OriginAAccessBucketPolicy: 165 | Type: AWS::S3::BucketPolicy 166 | Properties: 167 | Bucket: 168 | Ref: OriginABucket 169 | PolicyDocument: 170 | Version: '2008-10-17' 171 | Id: PolicyForCloudFrontPrivateContentA 172 | Statement: 173 | - Sid: '1' 174 | Effect: Allow 175 | Principal: 176 | CanonicalUser: !GetAtt OriginAccessIdentity.S3CanonicalUserId 177 | Action: s3:GetObject 178 | Resource: 179 | - !Join [ "", [ "arn:aws:s3:::", !Ref OriginABucket, "/*"]] 180 | 181 | OriginBAccessBucketPolicy: 182 | Type: AWS::S3::BucketPolicy 183 | Properties: 184 | Bucket: 185 | Ref: OriginBBucket 186 | PolicyDocument: 187 | Version: '2008-10-17' 188 | Id: PolicyForCloudFrontPrivateContentB 189 | Statement: 190 | - Sid: '1' 191 | Effect: Allow 192 | Principal: 193 | CanonicalUser: !GetAtt OriginAccessIdentity.S3CanonicalUserId 194 | Action: s3:GetObject 195 | Resource: 196 | - !Join [ "", [ "arn:aws:s3:::", !Ref OriginBBucket, "/*"]] 197 | 198 | Outputs: 199 | CFDistribution: 200 | Description: Cloudfront Distribution Domain Name 201 | Value: !GetAtt CFDistribution.DomainName 202 | 203 | BucketADomain: 204 | Description: Regional Domian name of the A bucket 205 | Value: !GetAtt OriginABucket.RegionalDomainName 206 | 207 | BucketBDomain: 208 | Description: Regional Domian name of the A bucket 209 | Value: !GetAtt OriginBBucket.RegionalDomainName 210 | 211 | LogBucketDomain: 212 | Description: Regional Domian name of the log bucket 213 | Value: !GetAtt LogBucket.RegionalDomainName 214 | 215 | ``` 216 | 217 | ## ☛ 1. Deploy the CloudFront stack 218 | 219 | In the bash terminal at the bottom of the screen, and enter these commands to deploy this CloudFormation stack 220 | 221 | ```bash 222 | cd ~/environment/lambda-edge-lab 223 | sam deploy --stack-name lambda-edge-dist --region us-east-1 -g 224 | ``` 225 | 226 | The `SAM CLI` will ask a series of questions as you deploy for the first time. 227 | 228 | ```bash 229 | Stack Name [lambda-edge-dist]: 230 | AWS Region [us-east-1]: 231 | #Shows you resources changes to be deployed and require a 'Y' to initiate deploy 232 | Confirm changes before deploy [y/N]: N 233 | #SAM needs permission to be able to create roles to connect to the resources in your template 234 | Allow SAM CLI IAM role creation [Y/n]: Y 235 | Save arguments to samconfig.toml [Y/n]: Y 236 | ``` 237 | 238 | Just hit `enter` for each question to accept the default option. 239 | 240 | It will take a little while (up to 10 minutes) for the stack to complete deployment. When it does you should see output similar to the following in your terminal - listing the outputs of the cloudformation stack you have deployed: 241 | 242 | ![stack deployed](/resources/first_deploy_done.png) 243 | 244 | 245 | So, now we have a cloudfront distribution that is set to serve content from the `A` S3 bucket. The subdomain name of your new CloudFront distribution is randomly generated. To find out what the full domain name is, either refer to the OutputValue that corresponds to the OutputKey `CFDistribution` in the return from the `sam deploy` command described above, or find the deployed CloudFormation stack in your AWS console called `lambda-edge-dist` (In the AWS Console, select *CloudFormation* fro the *Services* menu) make sure you are looking at the N. Virginia region) and find it listed under the output tab there. 246 | 247 | 248 | ![Cloudformation outputs](resources/cloudformation-lambda-dist-output.png) 249 | 250 | 251 | Point your browser to your new CloudFront distribution domain name and you'll see: 252 | 253 | 254 | ![access denied](resources/access_denied.png) 255 | 256 | 257 | This is because there is no content in the bucket to serve. Or rather the cloudfront distribution is trying to serve the default root object (index.html) from the S3 bucket, but it's not there. 258 | 259 | 260 | ## ☛ 2. Upload your `A` content using the console 261 | 262 | 263 | 1. Download and unzip the source files from the github repository to your local machine from the [github repository](https://github.com/justasitsounds/lambda-edge-lab/raw/master/content.zip) 264 | The unzipped files should be arranged like this, with two subfolders: `origin-a` and `origin-b`, both containing two different versions of a single web-page (index.html) 265 | 266 | ``` 267 | ── content 268 | │   ├── origin-a 269 | │   │   ├── favicon.ico 270 | │   │   └── index.html 271 | │   └── origin-b 272 | │      ├── favicon.ico 273 | │      └── index.html 274 | ``` 275 | 2. Navigate to the S3 service dashboard in the AWS console (https://s3.console.aws.amazon.com/s3/home) 276 | 3. Find the `A` bucket - it's name should start with `ab-testing-origin-a` ![origin buckets](resources/s3_origin_bucketslist.png) 277 | 4. open the origin A bucket by clicking on it's name ![origin bucket a](resources/origin-a-bucket-empty.png) 278 | 5. Upload the `index.html` and `favicon.ico` files from the local `content/origin-a` folder. ![uploaded content bucket a](resources/uploaded-content-origin-a.png) 279 | Once you have selected the files to upload, simply click the 'Upload' button on the left hand side of the dialog to accept the default permissions and properties for those files 280 | ![file upload](resources/file_upload.png) 281 | 282 | Once this is done, check that you can see the `A` content being served from your CloudFront domain by pointing your browser to the Cloudfront distribution dmain name again. 283 | 284 | ![origin a](resources/origin_a.png) 285 | 286 | 287 | ## ☛ 3. Upload your `B` content using the console 288 | 289 | Repeat the steps listed above to upload the files `content/origin-b/favicon.ico` and `content/origin-a/index.html` to the `B` content bucket (the name starts with `ab-testing-origin-b`) 290 | 291 | If you point your browser at the CloudFront distribution domain again, you will only see the `A` content being served. 292 | 293 | In order to change the origin for this CloudFront distribution as we want, we'll need to add some lambda@edge functions to the distribution. 294 | 295 | 296 | 297 | ## ⓘ How do lambda@edge functions work? 298 | 299 | lambda@edge functions can be triggered by 4 different CloudFront events that correspond to the following stages of a Cloudfront content request: 300 | 301 | ![cloudfront lambda events](resources/cloudfront-events-that-trigger-lambda-functions.png) 302 | 303 | - After CloudFront receives a request from a viewer (viewer request) 304 | - Before CloudFront forwards the request to the origin (origin request) 305 | - After CloudFront receives the response from the origin (origin response) 306 | - Before CloudFront forwards the response to the viewer (viewer response) 307 | 308 | 309 | The CloudFront distribution has been configured to forward `pool` cookies to the Origin - meaning that the `pool` cookie is part of the cache key. This allows us to utilise the caching abilities of CloudFront so that subsequent requests that include a `pool` cookie (`pool=a` or `pool=b`), will fetch the same content from the edge cache without making a request to the Origin. 310 | 311 | ![lambda@edge functions](resources/lambda@edge_internals.png) 312 | 313 | 314 | ### ⓘ Viewer request function - assigning new visitors to content pool `A` or `B` 315 | 316 | This function intercepts the viewer request before it is routed to the Cloudfront cache. The code simply adds a `pool` cookie, value: `pool=a` or `pool=b` to the request header if one is not already present. Users without an existing pool cookie are randomly assigned either `pool=a` or `pool=b` with an equal probability (50/50) of being assigned either. 317 | 318 | 319 | ### ⓘ Origin Request function: 320 | 321 | This function changes the origin bucket location in the request to point to either origin `A` or origin `B` depending on the value of the `pool` cookie. 322 | 323 | 324 | ### ⓘ Origin Response function: 325 | 326 | Adds a `Set-Cookie` header to set the `pool` cookie to match the origin from where the content was served - ensuring that clients that made a requests without a `pool` cookie are instructed to store and send the pool cookie value that matches the origin for subsequent requests. 327 | 328 | 329 | ## ☛ 4. Write the viewer request Lambda@edge function 330 | 331 | Open the `edge-functions/viewerrequest.js` file in this solution in your Cloud9 editor. Copy and paste the following code and save. 332 | 333 | ```javascript 334 | 'use strict'; 335 | 336 | // the `pool` cookie designates the user pool that the request belongs to 337 | const cookieName = 'pool'; 338 | 339 | // returns cookies as an associative array, given a CloudFront request headers array 340 | const parseCookies = require('./common.js').parseCookies; 341 | 342 | // returns either 'a' or 'b', with a default probability of 1:1 343 | const choosePool = (chance = 2) => Math.floor(Math.random()*chance) === 0 ? 'b' : 'a'; 344 | 345 | //if the request does not have a pool cookie - assign one 346 | exports.handler = (event, context, callback) => { 347 | const request = event.Records[0].cf.request; 348 | const headers = request.headers; 349 | const parsedCookies = parseCookies(headers); 350 | 351 | if(!parsedCookies || !parsedCookies[cookieName]){ 352 | let targetPool = choosePool(); //pass a Number as argument to change the chance that user is assigned to Pool 'a' or 'b' 353 | headers['cookie'] = [{ key: 'cookie', value: `${cookieName}=${targetPool}`}] 354 | } 355 | 356 | callback(null, request); 357 | }; 358 | ``` 359 | 360 | ## ☛ 5. update the shared origin_config.js configuration file 361 | 362 | Both the `origin_request` and `origin_response` lambda@edge functions depend on a common configuration file that holds the bucket names for the `A` and `B` S3 buckets. 363 | 364 | You'll need to update this file with the names of the `A` and `B` origin buckets that were created when you first deployed the solution stack. You can find this name by either looking at the output of the `sam deploy` function if you still have that open in your terminal window: IE: 365 | 366 | ![sam deploy output](resources/sam-deploy-output.png) 367 | 368 | or by looking at the outputs in the `lambda-edge-dist` stack in the Cloudformation console. 369 | 370 | In this deployment example, the name of the `A` bucket is: `ab-testing-origin-a-01207890` and the name of the `B` bucket is: `ab-testing-origin-b-01207890` (note that the name is the first segment of the bucket domain name) 371 | 372 | Using these two values, update the code in `edge-functions/origins_config.js` to match. So for the deployment shown above the file would change from: 373 | 374 | ```javascript 375 | module.exports = { 376 | a:'', 377 | b:'' 378 | }; 379 | ``` 380 | 381 | to 382 | 383 | ```javascript 384 | module.exports = { 385 | a:'ab-testing-origin-a-01207890', 386 | b:'ab-testing-origin-b-01207890' 387 | }; 388 | ``` 389 | 390 | 391 | 392 | ## ☛ 6. Write the origin request Lambda@edge function 393 | 394 | Copy and paste the following code into the `edge-functions/originrequest.js` file in this solution and save. 395 | 396 | ```javascript 397 | 'use strict'; 398 | 399 | // the S3 origins that correspond to content for Pool A and Pool B 400 | const origins = require('./origins_config.js'); 401 | const parseCookies = require('./common.js').parseCookies; 402 | 403 | // the `pool` cookie determines which origin to route to 404 | const cookieName = 'pool'; 405 | 406 | // changes request origin depending on value of the `pool` cookie 407 | exports.handler = (event, context, callback) => { 408 | const request = event.Records[0].cf.request; 409 | const headers = request.headers; 410 | const requestOrigin = request.origin.s3; 411 | const parsedCookies = parseCookies(headers); 412 | 413 | let targetPool = parsedCookies[cookieName]; 414 | let s3Origin = `${origins[targetPool]}.s3.us-east-1.amazonaws.com`; 415 | 416 | requestOrigin.region = 'us-east-1'; 417 | requestOrigin.domainName = s3Origin; 418 | headers['host'] = [{ key: 'host', value: s3Origin }]; 419 | 420 | callback(null, request); 421 | }; 422 | ``` 423 | 424 | ## ☛ 7. Write the origin response Lambda@edge function 425 | 426 | Copy and paste the following code into the `edge-functions/originresponse.js` file in this solution and save. 427 | 428 | ```javascript 429 | 'use strict'; 430 | 431 | // the S3 origins that correspond to content for Pool A and Pool B 432 | const origins = require('./origins_config.js'); 433 | 434 | //returns a set-cookie header based on where the content was served from 435 | exports.handler = (event, context, callback) => { 436 | 437 | const response = event.Records[0].cf.response; //response from the origin 438 | const reqHeaders = event.Records[0].cf.request; //request from cloudfront 439 | 440 | let poolorigin = 'a'; //default origin pool 441 | 442 | if(reqHeaders.origin.s3.domainName.indexOf(origins.b) === 0){ 443 | poolorigin = 'b'; 444 | } 445 | response.headers['Set-Cookie'] = [{key:'Set-Cookie', value: `pool=${poolorigin}`}]; 446 | 447 | callback(null, response); 448 | }; 449 | ``` 450 | 451 | ## ☛ 8. update the SAM template to include the lambda@edge functions 452 | 453 | Below is the updated SAM template that includes the lambda@edge functions and integrates them with the CloudFront distribution. New sections have comments to show the additions. Open the file `template.yml` in the base folder of the solution in Cloud9. Copy and paste the following to update and replace the content of the file. 454 | 455 | ```yaml 456 | AWSTemplateFormatVersion: '2010-09-09' 457 | Transform: AWS::Serverless-2016-10-31 458 | Description: A/B testing using cloudfront and lambda@edge 459 | 460 | ### The Globals section is a SAM specific element that defines the common attributes of the Lambda@Edge functions we are deploying 461 | Globals: 462 | Function: 463 | Runtime: nodejs10.x 464 | Timeout: 5 465 | AutoPublishAlias: live 466 | ### 467 | 468 | Resources: 469 | 470 | OriginAccessIdentity: 471 | Type: AWS::CloudFront::CloudFrontOriginAccessIdentity 472 | Properties: 473 | CloudFrontOriginAccessIdentityConfig: 474 | Comment: Origin Access Identity for lambda@edge dev lab 475 | 476 | ### the EdgeFunctionRole is an IAM role that is granted to the Lambda@Edge functions 477 | EdgeFunctionRole: 478 | Type: AWS::IAM::Role 479 | Properties: 480 | RoleName: !Sub ${AWS::StackName}-edgeFunction 481 | AssumeRolePolicyDocument: 482 | Version: 2012-10-17 483 | Statement: 484 | Effect: Allow 485 | Principal: 486 | Service: 487 | - lambda.amazonaws.com 488 | - edgelambda.amazonaws.com 489 | Action: sts:AssumeRole 490 | ManagedPolicyArns: 491 | - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole 492 | - arn:aws:iam::aws:policy/AWSXrayWriteOnlyAccess 493 | ### 494 | 495 | ### The Lambda@Edge functions - these require the EdgeFunctionRole 496 | ViewerRequestLambda: 497 | Type: AWS::Serverless::Function 498 | Properties: 499 | Description: Assigns pool cookie if not present on request 500 | Role: !GetAtt EdgeFunctionRole.Arn 501 | CodeUri: ./edge-functions/src/ 502 | Handler: viewerrequest.handler 503 | 504 | OriginRequestLambda: 505 | Type: AWS::Serverless::Function 506 | Properties: 507 | Description: Changes request Origin depending on value of pool cookie 508 | Role: !GetAtt EdgeFunctionRole.Arn 509 | CodeUri: ./edge-functions/src/ 510 | Handler: originrequest.handler 511 | 512 | OriginResponseLambda: 513 | Type: AWS::Serverless::Function 514 | Properties: 515 | Description: Appends Set-cookie header to match response origin 516 | Role: !GetAtt EdgeFunctionRole.Arn 517 | CodeUri: ./edge-functions/src/ 518 | Handler: originresponse.handler 519 | ### 520 | 521 | LogBucket: 522 | Type: "AWS::S3::Bucket" 523 | 524 | CFDistribution: 525 | Type: AWS::CloudFront::Distribution 526 | Properties: 527 | DistributionConfig: 528 | Logging: 529 | Bucket: !GetAtt LogBucket.DomainName 530 | IncludeCookies: true 531 | Enabled: true 532 | DefaultRootObject: index.html 533 | Comment: 'AB testing Cloudfront distribution' 534 | Origins: 535 | - Id: s3 536 | DomainName: !GetAtt OriginABucket.DomainName 537 | S3OriginConfig: 538 | OriginAccessIdentity: !Join [ "/", [ origin-access-identity, cloudfront, !Ref OriginAccessIdentity ]] 539 | DefaultCacheBehavior: 540 | TargetOriginId: s3 541 | ForwardedValues: 542 | QueryString: false 543 | Cookies: 544 | Forward: whitelist 545 | WhitelistedNames: 546 | - pool 547 | ViewerProtocolPolicy: redirect-to-https 548 | DefaultTTL: 30 549 | MinTTL: 0 550 | AllowedMethods: 551 | - HEAD 552 | - GET 553 | CachedMethods: 554 | - HEAD 555 | - GET 556 | SmoothStreaming: false 557 | Compress: true 558 | ### Lambda Function Associations - associating the functions with the CloudFront request/response events 559 | LambdaFunctionAssociations: 560 | - 561 | EventType: viewer-request 562 | LambdaFunctionARN: !Ref ViewerRequestLambda.Version 563 | - 564 | EventType: origin-request 565 | LambdaFunctionARN: !Ref OriginRequestLambda.Version 566 | - 567 | EventType: origin-response 568 | LambdaFunctionARN: !Ref OriginResponseLambda.Version 569 | ### 570 | 571 | OriginABucket: 572 | Type: AWS::S3::Bucket 573 | Properties: 574 | BucketName: !Join 575 | - "-" 576 | - - "ab-testing-origin-a" 577 | - !Select 578 | - 0 579 | - !Split 580 | - "-" 581 | - !Select 582 | - 2 583 | - !Split 584 | - "/" 585 | - !Ref "AWS::StackId" 586 | AccessControl: Private 587 | Tags: 588 | - Key: purpose 589 | Value: lab 590 | - Key: project 591 | Value: lambda-edge-ab 592 | 593 | OriginBBucket: 594 | Type: AWS::S3::Bucket 595 | Properties: 596 | BucketName: !Join 597 | - "-" 598 | - - "ab-testing-origin-b" 599 | - !Select 600 | - 0 601 | - !Split 602 | - "-" 603 | - !Select 604 | - 2 605 | - !Split 606 | - "/" 607 | - !Ref "AWS::StackId" 608 | AccessControl: Private 609 | Tags: 610 | - Key: purpose 611 | Value: lab 612 | - Key: project 613 | Value: lambda-edge-ab 614 | 615 | OriginAAccessBucketPolicy: 616 | Type: AWS::S3::BucketPolicy 617 | Properties: 618 | Bucket: 619 | Ref: OriginABucket 620 | PolicyDocument: 621 | Version: '2008-10-17' 622 | Id: PolicyForCloudFrontPrivateContentA 623 | Statement: 624 | - Sid: '1' 625 | Effect: Allow 626 | Principal: 627 | CanonicalUser: !GetAtt OriginAccessIdentity.S3CanonicalUserId 628 | Action: s3:GetObject 629 | Resource: 630 | - !Join [ "", [ "arn:aws:s3:::", !Ref OriginABucket, "/*"]] 631 | 632 | OriginBAccessBucketPolicy: 633 | Type: AWS::S3::BucketPolicy 634 | Properties: 635 | Bucket: 636 | Ref: OriginBBucket 637 | PolicyDocument: 638 | Version: '2008-10-17' 639 | Id: PolicyForCloudFrontPrivateContentA 640 | Statement: 641 | - Sid: '1' 642 | Effect: Allow 643 | Principal: 644 | CanonicalUser: !GetAtt OriginAccessIdentity.S3CanonicalUserId 645 | Action: s3:GetObject 646 | Resource: 647 | - !Join [ "", [ "arn:aws:s3:::", !Ref OriginBBucket, "/*"]] 648 | 649 | Outputs: 650 | CFDistribution: 651 | Description: Cloudfront Distribution Domain Name 652 | Value: !GetAtt CFDistribution.DomainName 653 | 654 | DistributionID: 655 | Description: Cloudfront Distribution ID 656 | Value: !Ref CFDistribution 657 | 658 | BucketADomain: 659 | Description: Regional Domian name of the A bucket 660 | Value: !GetAtt OriginABucket.RegionalDomainName 661 | 662 | BucketBDomain: 663 | Description: Regional Domian name of the A bucket 664 | Value: !GetAtt OriginBBucket.RegionalDomainName 665 | 666 | LogBucketDomain: 667 | Description: Regional Domian name of the log bucket 668 | Value: !GetAtt LogBucket.RegionalDomainName 669 | ``` 670 | 671 | 672 | ## ☛ 9. Deploy the updated SAM template 673 | 674 | To deploy the updated stack, we just need to invoke the `sam deploy` command again - with specific CAPABILITY_NAMED_IAM capabilites because now our template defines an IAM role: *edgeFunctionRole* that will be assumed by our lambda@edge functions. 675 | 676 | ```bash 677 | sam deploy --capabilities CAPABILITY_NAMED_IAM 678 | ``` 679 | 680 | This will take between 5-10 minutes to complete. Once it is finished, you can proceed to the final stage: Testing! 681 | 682 | --- 683 | 684 | ## ☛ Testing 685 | 686 | 687 | As a quick recap, our requirements are: 688 | 689 | 1. Randomly split unassigned traffic into two groups or pools 690 | 2. Serve two different variants of content from either of two origin S3 buckets (the `A` or `B` bucket) 691 | 3. Ensure that subsequent requests from the same clients will be served the same content that they first received (session stickyness) 692 | 4. Record and compare the impact the changes make to the customer engagement 693 | 694 | 695 | To test the first three requirements: 696 | 697 | 1. Point your browser at the CloudFront distribution 698 | 2. You will see either of these two pages: 699 | 700 | ![origin a](resources/origin_a.png) 701 | 702 | ![origin b](resources/origin_b.png) 703 | 704 | 3. If you open the dev tools (in Chrome: it's under View > Developer Tools > Developer Tools) and examine the Cookies (Chrome Developer Tools > Application tab, Storage : Cookies) you will see that your browser will now have a `pool` session cookie for the current domain, the value of which will match the Origin that the content was served from: 705 | 706 | ![pool=a cookie](resources/pool=a_cookie.png) 707 | 708 | 4. Open the Network tab in the developer tools and ensure that the __Preserve log__ checkbox is checked, then refresh the page any number of times. You will see the same content with each page refresh - the `pool` session cookie ensures that your request will be served content from the same origin. If you examine the response headers while you do this (Chrome: Developer Tools : Network tab) you will see a couple of things: 709 | - The initial response will have a `200` (OK) status code and a custom `X-Cache` header with a value: `Miss from cloudfront` - this is a hint from cloudfront that tells us that this content was fetched directly from the origin, and not served from the cloudfront cache. The matching request will have no `Cookie: pool=[[X]]` header, but the server will add a `Set-Cookie: pool=[[X]]` header to the response - this instructs your browser to create the pool cookie and append it to subsequent requests to the same domain. 710 | - Subsequent requests will have the `Cookie: pool=[[X]]` header and may have a `304` (Not Modified) status code along with an `X-Cache: Hit from cloudfront` header and an `Age` header - this type of response means a couple of things: 711 | - [304 - not modified](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/304). The server (cloudfront) has recognised that the content being requested has not changed since the client last received a valid copy - this is mediated by the `If-Modified-Since` and `If-None-Match` request headers sent by the client. When your browser receieves the `304` response it actually serves the page content from it's internal cache rather than the full content from the server - this is the purpose of 304 responses, they do not contain a message-body for this reason 712 | - `X-Cache: Hit from cloudfront` - cloudfront has checked the request against it's edge cache - and not the origin. 713 | - The `Age` response header tells us how old the cached copy of the content is in seconds. By default this distribution is configured to only keep a copy of the content in it's cache for 30 seconds - the default cache lifetime can be overridden by adding a `max-age` cache-control header to the content in the S3 origin. 714 | ![network log](resources/network_log.png) 715 | 5. Change the value of the `pool` cookie to the other origin (if it is currently `a`, change it to `b`) and then refresh the page. You will now see the alternate content for the same resource. 716 | 6. Delete the `pool` cookie and refresh the browser, you have a 50/50 chance of seeing the alternate content. 717 | 718 | To test the last requirement (record and compare the impact the changes make to the customer engagement) you can: 719 | 720 | 7. [Query the Cloudfront access logs using Athena](https://docs.aws.amazon.com/athena/latest/ug/cloudfront-logs.html). You can segment your user traffic based on the `pool` cookie value in the logs so that you can compare the customer journeys for those customers who received the `A` content and `B` content 721 | 722 | 723 | --- 724 | 725 | # Cleaning up 726 | 727 | 1. Delete all content from the log and origin S3 buckets 728 | 729 | a) Open the [AWS Console](https://console.aws.amazon.com) and select *S3* from the *Services* menu to open the S3 dashboard 730 | 731 | b) click on the bucket name for each of the buckets 732 | 733 | c) select all content in the bucket using the radiobuttons and click the 'Delete' button 734 | 735 | 2. Delete the CloudFormation stack: 736 | 737 | a) Open the [AWS Console](https://console.aws.amazon.com) and select *CloudFormation* from the *Services* menu to open the CloudFormation dashboard 738 | 739 | b) Select the radiobutton for the `lambda-edge-dist` stack and click the 'Delete' button 740 | 741 | c) If there is any content in the the log or origin buckets you will get a DELETE_FAILED status on the cloudformation stack. Delete the content (see above) and try the delete cloudformation stack operation again 742 | 743 | -------------------------------------------------------------------------------- /content.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justasitsounds/lambda-edge-lab/cca8027cb1b1bbdea3ed8882c22bfae7c33e7cb3/content.zip -------------------------------------------------------------------------------- /edge-functions/src/common.js: -------------------------------------------------------------------------------- 1 | exports.parseCookies = (headers) => { 2 | const parsedCookie = {}; 3 | if (headers.cookie) { 4 | headers.cookie[0].value.split(';').forEach((cookie) => { 5 | if (cookie) { 6 | const parts = cookie.split('='); 7 | parsedCookie[parts[0].trim()] = parts[1].trim(); 8 | } 9 | }); 10 | } 11 | return parsedCookie; 12 | }; 13 | -------------------------------------------------------------------------------- /edge-functions/src/originrequest.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // the S3 origins that correspond to content for Pool A and Pool B 4 | const origins = require('./origins_config.js'); 5 | const parseCookies = require('./common.js').parseCookies; 6 | 7 | // the `pool` cookie determines which origin to route to 8 | const cookieName = 'pool'; 9 | 10 | // changes request origin depending on value of the `pool` cookie 11 | exports.handler = (event, context, callback) => { 12 | 13 | const request = event.Records[0].cf.request; 14 | 15 | callback(null, request); 16 | }; 17 | -------------------------------------------------------------------------------- /edge-functions/src/originresponse.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // the S3 origins that correspond to content for Pool A and Pool B 4 | const originconfig = require('./origins_config.js'); 5 | 6 | //returns a set-cookie header based on where the content was served from 7 | exports.handler = (event, context, callback) => { 8 | 9 | const response = event.Records[0].cf.response; //response from the origin 10 | 11 | callback(null, response); 12 | }; 13 | -------------------------------------------------------------------------------- /edge-functions/src/origins_config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | a:'', 3 | b:'' 4 | }; -------------------------------------------------------------------------------- /edge-functions/src/viewerrequest.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // the `pool` cookie designates the user pool that the request belongs to 4 | const cookieName = 'pool'; 5 | 6 | //returns cookies as an associative array, given a CloudFront request headers array 7 | const parseCookies = require('./common.js').parseCookies; 8 | 9 | // returns either 'a' or 'b', with a default probability of 1:1 10 | const choosePool = (chance = 2) => Math.floor(Math.random()*chance) === 0 ? 'b' : 'a'; 11 | 12 | //if the request does not have a pool cookie - assign one 13 | exports.handler = (event, context, callback) => { 14 | const request = event.Records[0].cf.request; 15 | 16 | callback(null, request); 17 | }; 18 | -------------------------------------------------------------------------------- /resources/Navigate_to_cloudformation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justasitsounds/lambda-edge-lab/cca8027cb1b1bbdea3ed8882c22bfae7c33e7cb3/resources/Navigate_to_cloudformation.png -------------------------------------------------------------------------------- /resources/access_denied.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justasitsounds/lambda-edge-lab/cca8027cb1b1bbdea3ed8882c22bfae7c33e7cb3/resources/access_denied.png -------------------------------------------------------------------------------- /resources/cloud9_intro.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justasitsounds/lambda-edge-lab/cca8027cb1b1bbdea3ed8882c22bfae7c33e7cb3/resources/cloud9_intro.png -------------------------------------------------------------------------------- /resources/cloudformation-lambda-dist-output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justasitsounds/lambda-edge-lab/cca8027cb1b1bbdea3ed8882c22bfae7c33e7cb3/resources/cloudformation-lambda-dist-output.png -------------------------------------------------------------------------------- /resources/cloudfront-events-that-trigger-lambda-functions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justasitsounds/lambda-edge-lab/cca8027cb1b1bbdea3ed8882c22bfae7c33e7cb3/resources/cloudfront-events-that-trigger-lambda-functions.png -------------------------------------------------------------------------------- /resources/cloudfront_url.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justasitsounds/lambda-edge-lab/cca8027cb1b1bbdea3ed8882c22bfae7c33e7cb3/resources/cloudfront_url.png -------------------------------------------------------------------------------- /resources/create_bucket.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justasitsounds/lambda-edge-lab/cca8027cb1b1bbdea3ed8882c22bfae7c33e7cb3/resources/create_bucket.png -------------------------------------------------------------------------------- /resources/create_bucket2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justasitsounds/lambda-edge-lab/cca8027cb1b1bbdea3ed8882c22bfae7c33e7cb3/resources/create_bucket2.png -------------------------------------------------------------------------------- /resources/create_bucket3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justasitsounds/lambda-edge-lab/cca8027cb1b1bbdea3ed8882c22bfae7c33e7cb3/resources/create_bucket3.png -------------------------------------------------------------------------------- /resources/create_bucket4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justasitsounds/lambda-edge-lab/cca8027cb1b1bbdea3ed8882c22bfae7c33e7cb3/resources/create_bucket4.png -------------------------------------------------------------------------------- /resources/deployed_lambda-edge_fns.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justasitsounds/lambda-edge-lab/cca8027cb1b1bbdea3ed8882c22bfae7c33e7cb3/resources/deployed_lambda-edge_fns.png -------------------------------------------------------------------------------- /resources/file_upload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justasitsounds/lambda-edge-lab/cca8027cb1b1bbdea3ed8882c22bfae7c33e7cb3/resources/file_upload.png -------------------------------------------------------------------------------- /resources/first_deploy_done.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justasitsounds/lambda-edge-lab/cca8027cb1b1bbdea3ed8882c22bfae7c33e7cb3/resources/first_deploy_done.png -------------------------------------------------------------------------------- /resources/lambda@edge_internals.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justasitsounds/lambda-edge-lab/cca8027cb1b1bbdea3ed8882c22bfae7c33e7cb3/resources/lambda@edge_internals.png -------------------------------------------------------------------------------- /resources/network_log.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justasitsounds/lambda-edge-lab/cca8027cb1b1bbdea3ed8882c22bfae7c33e7cb3/resources/network_log.png -------------------------------------------------------------------------------- /resources/oai_for_lambda_edge_lab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justasitsounds/lambda-edge-lab/cca8027cb1b1bbdea3ed8882c22bfae7c33e7cb3/resources/oai_for_lambda_edge_lab.png -------------------------------------------------------------------------------- /resources/origin-a-bucket-empty.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justasitsounds/lambda-edge-lab/cca8027cb1b1bbdea3ed8882c22bfae7c33e7cb3/resources/origin-a-bucket-empty.png -------------------------------------------------------------------------------- /resources/origin_a.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justasitsounds/lambda-edge-lab/cca8027cb1b1bbdea3ed8882c22bfae7c33e7cb3/resources/origin_a.png -------------------------------------------------------------------------------- /resources/origin_b.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justasitsounds/lambda-edge-lab/cca8027cb1b1bbdea3ed8882c22bfae7c33e7cb3/resources/origin_b.png -------------------------------------------------------------------------------- /resources/origin_config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justasitsounds/lambda-edge-lab/cca8027cb1b1bbdea3ed8882c22bfae7c33e7cb3/resources/origin_config.png -------------------------------------------------------------------------------- /resources/pool=a_cookie.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justasitsounds/lambda-edge-lab/cca8027cb1b1bbdea3ed8882c22bfae7c33e7cb3/resources/pool=a_cookie.png -------------------------------------------------------------------------------- /resources/pool=b_cookie.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justasitsounds/lambda-edge-lab/cca8027cb1b1bbdea3ed8882c22bfae7c33e7cb3/resources/pool=b_cookie.png -------------------------------------------------------------------------------- /resources/s3_origin_bucketslist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justasitsounds/lambda-edge-lab/cca8027cb1b1bbdea3ed8882c22bfae7c33e7cb3/resources/s3_origin_bucketslist.png -------------------------------------------------------------------------------- /resources/sam-deploy-output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justasitsounds/lambda-edge-lab/cca8027cb1b1bbdea3ed8882c22bfae7c33e7cb3/resources/sam-deploy-output.png -------------------------------------------------------------------------------- /resources/select_us-east-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justasitsounds/lambda-edge-lab/cca8027cb1b1bbdea3ed8882c22bfae7c33e7cb3/resources/select_us-east-1.png -------------------------------------------------------------------------------- /resources/simple_arch_diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justasitsounds/lambda-edge-lab/cca8027cb1b1bbdea3ed8882c22bfae7c33e7cb3/resources/simple_arch_diagram.png -------------------------------------------------------------------------------- /resources/uploaded-content-origin-a.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justasitsounds/lambda-edge-lab/cca8027cb1b1bbdea3ed8882c22bfae7c33e7cb3/resources/uploaded-content-origin-a.png -------------------------------------------------------------------------------- /template.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Transform: AWS::Serverless-2016-10-31 3 | Description: A/B testing using cloudfront and lambda@edge 4 | 5 | Resources: 6 | 7 | OriginAccessIdentity: 8 | Type: AWS::CloudFront::CloudFrontOriginAccessIdentity 9 | Properties: 10 | CloudFrontOriginAccessIdentityConfig: 11 | Comment: Origin Access Identity for lambda@edge dev lab 12 | 13 | LogBucket: 14 | Type: "AWS::S3::Bucket" 15 | 16 | CFDistribution: 17 | Type: AWS::CloudFront::Distribution 18 | Properties: 19 | DistributionConfig: 20 | Logging: 21 | Bucket: !GetAtt LogBucket.DomainName 22 | IncludeCookies: true 23 | Enabled: true 24 | DefaultRootObject: index.html 25 | Comment: 'AB testing Cloudfront distribution' 26 | Origins: 27 | - Id: s3 28 | DomainName: !GetAtt OriginABucket.DomainName 29 | S3OriginConfig: 30 | OriginAccessIdentity: !Join [ "/", [ origin-access-identity, cloudfront, !Ref OriginAccessIdentity ]] 31 | DefaultCacheBehavior: 32 | TargetOriginId: s3 33 | ForwardedValues: 34 | QueryString: false 35 | Cookies: 36 | Forward: whitelist 37 | WhitelistedNames: 38 | - pool 39 | ViewerProtocolPolicy: redirect-to-https 40 | DefaultTTL: 30 41 | MinTTL: 0 42 | AllowedMethods: 43 | - HEAD 44 | - GET 45 | CachedMethods: 46 | - HEAD 47 | - GET 48 | SmoothStreaming: false 49 | Compress: true 50 | 51 | 52 | OriginABucket: 53 | Type: AWS::S3::Bucket 54 | Properties: 55 | BucketName: !Join 56 | - "-" 57 | - - "ab-testing-origin-a" 58 | - !Select 59 | - 0 60 | - !Split 61 | - "-" 62 | - !Select 63 | - 2 64 | - !Split 65 | - "/" 66 | - !Ref "AWS::StackId" 67 | AccessControl: Private 68 | Tags: 69 | - Key: purpose 70 | Value: lab 71 | - Key: project 72 | Value: lambda-edge-ab 73 | 74 | OriginBBucket: 75 | Type: AWS::S3::Bucket 76 | Properties: 77 | BucketName: !Join 78 | - "-" 79 | - - "ab-testing-origin-b" 80 | - !Select 81 | - 0 82 | - !Split 83 | - "-" 84 | - !Select 85 | - 2 86 | - !Split 87 | - "/" 88 | - !Ref "AWS::StackId" 89 | AccessControl: Private 90 | Tags: 91 | - Key: purpose 92 | Value: lab 93 | - Key: project 94 | Value: lambda-edge-ab 95 | 96 | OriginAAccessBucketPolicy: 97 | Type: AWS::S3::BucketPolicy 98 | Properties: 99 | Bucket: 100 | Ref: OriginABucket 101 | PolicyDocument: 102 | Version: '2008-10-17' 103 | Id: PolicyForCloudFrontPrivateContentA 104 | Statement: 105 | - Sid: '1' 106 | Effect: Allow 107 | Principal: 108 | CanonicalUser: !GetAtt OriginAccessIdentity.S3CanonicalUserId 109 | Action: s3:GetObject 110 | Resource: 111 | - !Join [ "", [ "arn:aws:s3:::", !Ref OriginABucket, "/*"]] 112 | 113 | OriginBAccessBucketPolicy: 114 | Type: AWS::S3::BucketPolicy 115 | Properties: 116 | Bucket: 117 | Ref: OriginBBucket 118 | PolicyDocument: 119 | Version: '2008-10-17' 120 | Id: PolicyForCloudFrontPrivateContentA 121 | Statement: 122 | - Sid: '1' 123 | Effect: Allow 124 | Principal: 125 | CanonicalUser: !GetAtt OriginAccessIdentity.S3CanonicalUserId 126 | Action: s3:GetObject 127 | Resource: 128 | - !Join [ "", [ "arn:aws:s3:::", !Ref OriginBBucket, "/*"]] 129 | 130 | Outputs: 131 | CFDistribution: 132 | Description: Cloudfront Distribution Domain Name 133 | Value: !GetAtt CFDistribution.DomainName 134 | 135 | DistributionID: 136 | Description: Cloudfront Distribution ID 137 | Value: !Ref CFDistribution 138 | 139 | BucketADomain: 140 | Description: Regional Domian name of the A bucket 141 | Value: !GetAtt OriginABucket.RegionalDomainName 142 | 143 | BucketBDomain: 144 | Description: Regional Domian name of the A bucket 145 | Value: !GetAtt OriginBBucket.RegionalDomainName 146 | 147 | LogBucketDomain: 148 | Description: Regional Domian name of the log bucket 149 | Value: !GetAtt LogBucket.RegionalDomainName --------------------------------------------------------------------------------