Core Web Vitals, CDK Constructs and YOU!

Start Measuring Core Web Vitals using AWS CDK and CloudWatch RUM

·

7 min read

Featured on Hashnode

As a web developer, you know the importance of delivering a fast and smooth user experience. But with the constantly evolving web landscape, it can be challenging to keep up with the latest best practices. In this blog post, I'll take you through the ins and outs of integrating Core Web Vitals into your development projects. With its focus on real-world user experience, Core Web Vitals is quickly becoming an essential part of any web development process. So buckle up, grab a coffee and let's dive in together to see how you can elevate your website's performance and provide a top-notch user experience for your visitors!

What are Core Web Vitals?

Core Web Vitals are a set of metrics defined by Google to measure the user experience on the web. They focus on the key aspects of website performance that directly impact user experience, such as loading speed, interactivity, and visual stability. The basics of Core Web Vitals are:

  1. Largest Contentful Paint (LCP): measures loading performance and is calculated as the time it takes for the largest content element on the page (e.g. an image or text block) to load and become visible to the user.

  2. First Input Delay (FID): measures interactivity and is calculated as the time it takes for a page to become responsive after a user first interacts with it (e.g. clicks a button).

  3. Cumulative Layout Shift (CLS): measures visual stability and is calculated as the total amount of unexpected layout shifts that occur during the page load.

These metrics are considered important because they directly impact user experience and are tied to search ranking factors. Websites that score well on Core Web Vitals are likely to have better search engine rankings and provide a better user experience. In other words, they lead to:

  1. Happy Users: By optimizing Core Web Vitals, your website will load faster, be more interactive, and have less visual instability. This leads to a better overall user experience, which means visitors will stay on your site longer and be more likely to return in the future.

  2. Better SEO: Google uses Core Web Vitals as part of its ranking algorithm, so websites that score well on these metrics are likely to have better search engine rankings. That means more visibility, more traffic, and more potential customers!

  3. Increased Conversions: A great user experience leads to higher engagement and increased conversions. By optimizing Core Web Vitals, you'll give your visitors a smooth and seamless experience that will keep them coming back for more.

  4. Industry Standard: Core Web Vitals are becoming the industry standard for measuring website performance and user experience. By optimizing these metrics, you'll ensure that your website is up-to-date and providing the best possible experience for your visitors.

Real User Monitoring with CloudWatch RUM and CDK

Let's get started with Core Web Vitals by writing some reusable components: A CDK Construct and Typescript snippet so that we can add CloudWatch RUM (Real User Monitoring) to our application.

The code for this section is located at http://github.com/martzcodes/blog-cdk-rum

We're going to start with a baseline project that includes a simple website deployed to S3 and hosted by a CloudFront distribution. This is similar to the site we created in my article about how to Protect a Static Site with Auth0 Using Lambda@Edge and CloudFront

We're going to focus on the 3 most important files:

  1. /lib/rum-runner-construct.ts - CDK Construct

  2. /lib/rum-runner-fn.ts - Custom Resource Function

  3. /ui/rum.ts - Typescript Snippet to add to our front-end code

CDK Construct

Our RumRunnerConstruct, /lib/rum-runner-construct.ts, will take in two properties to its interface:

  • bucket - the UI's deployment bucket

  • cloudFront - the UI's CloudFront distribution

CloudWatch RUM requires a Cognito Identity Pool to allow unauthenticated access for the CloudWatch RUM web client to publish events.

We create the Identity Pool

const cwRumIdentityPool = new CfnIdentityPool(
  this,
  "cw-rum-identity-pool",
  { allowUnauthenticatedIdentities: true }
);

A role for unauthenticated users to use:

const cwRumUnauthenticatedRole = new Role(
  this,
  "cw-rum-unauthenticated-role",
  {
    assumedBy: new FederatedPrincipal(
      "cognito-identity.amazonaws.com",
      {
        StringEquals: {
          "cognito-identity.amazonaws.com:aud": cwRumIdentityPool.ref,
        },
        "ForAnyValue:StringLike": {
          "cognito-identity.amazonaws.com:amr": "unauthenticated",
        },
      },
      "sts:AssumeRoleWithWebIdentity"
    ),
  }
);

And we make sure that role has access to put events into CloudWatch RUM:

cwRumUnauthenticatedRole.addToPolicy(
  new PolicyStatement({
    effect: Effect.ALLOW,
    actions: ["rum:PutRumEvents"],
    resources: [
      `arn:aws:rum:${Stack.of(this).region}:${
        Stack.of(this).account
      }:appmonitor/${cloudFront.distributionDomainName}`,
    ],
  })
);

Then we attach the role to the unauthenticated users:

new CfnIdentityPoolRoleAttachment(
  this,
  "cw-rum-identity-pool-role-attachment",
  {
    identityPoolId: cwRumIdentityPool.ref,
    roles: {
      unauthenticated: cwRumUnauthenticatedRole.roleArn,
    },
  }
);

Next, we need to create the app monitor, which we do by using the Level 1 CDK Construct CfnAppMonitor:

const cwRumAppMonitor = new CfnAppMonitor(this, "cw-rum-app-monitor", {
  domain: cloudFront.distributionDomainName,
  name: cloudFront.distributionDomainName,
  appMonitorConfiguration: {
    allowCookies: true,
    enableXRay: false,
    sessionSampleRate: 1,
    telemetries: ["errors", "performance", "http"],
    identityPoolId: cwRumIdentityPool.ref,
    guestRoleArn: cwRumUnauthenticatedRole.roleArn,
  },
  cwLogEnabled: true,
});

Now, to automatically include the newly created app monitor client into our app... we need to take a few extra steps in our Construct. We'll need to run a Custom Resource to fetch some metadata and store it in the UI's bucket for the UI to load. The RUM App Monitor web client needs the CloudWatch RUM App Monitor Id, the Role ARN and the Identity Pool Id.

We create the NodeJS Function:

const rumRunnerFn = new NodejsFunction(this, "rum-runner", {
  runtime: Runtime.NODEJS_18_X,
  environment: {
    BUCKET_NAME: bucket.bucketName,
    RUM_APP: cloudFront.distributionDomainName,
    GUEST_ROLE_ARN: cwRumUnauthenticatedRole.roleArn,
    IDENTITY_POOL_ID: cwRumIdentityPool.ref,
  },
  timeout: Duration.seconds(30),
  entry: join(__dirname, "./rum-runner-fn.ts"),
});

And give it the right permissions:

bucket.grantWrite(rumRunnerFn);
rumRunnerFn.addToRolePolicy(
  new PolicyStatement({
    effect: Effect.ALLOW,
    resources: [
      `arn:aws:rum:${Stack.of(this).region}:${
        Stack.of(this).account
      }:appmonitor/${cloudFront.distributionDomainName}`,
    ],
    actions: ["rum:GetAppMonitor"],
  })
);

Then we create the Custom Resource, with a dependency on the App Monitor so that it runs AFTER the App Monitor is created:

const rumRunnerProvider = new Provider(this, "rum-runner-provider", {
  onEventHandler: rumRunnerFn,
});

const customResource = new CustomResource(this, "rum-runner-resource", {
  serviceToken: rumRunnerProvider.serviceToken,
  properties: {
    // Bump to force an update
    Version: "2",
  },
});

customResource.node.addDependency(cwRumAppMonitor);

Custom Resource Function

CloudFormation Custom Resources are a way to write some provisioning logic that is executed as part of a CDK (CloudFormation) deployment. The Custom Resource will invoke our lambda (/lib/rum-runner-fn.ts ) which will use the AWS SDK to fetch the App Monitor Id after it's created and store it in S3, along with the Role ARN and Identity Pool Id.

We import and create two clients:

import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { RUMClient, GetAppMonitorCommand } from "@aws-sdk/client-rum";

const s3 = new S3Client({});
const rum = new RUMClient({});

Fetch the App Monitor config using the RUMClient:

export const handler = async () => {
  const app = await rum.send(
    new GetAppMonitorCommand({ Name: `${process.env.RUM_APP}` })
  );
};

And then upload them to the UI bucket:

export const handler = async () => {
  // ...
  const command = new PutObjectCommand({
    Key: "rum.json",
    Bucket: process.env.BUCKET_NAME,
    Body: JSON.stringify({
      APPLICATION_ID: `${app?.AppMonitor?.Id}`,
      guestRoleArn: `${process.env.GUEST_ROLE_ARN}`,
      identityPoolId: `${process.env.IDENTITY_POOL_ID}`,
    }),
  });
  await s3.send(command);
};

Typescript Snippet

The final piece of the puzzle is injecting this into our front end. The front-end will need to include our front-end typescript snippet/ui/rum.ts

This snippet extends the official/boilerplate snippet, that you would download from the CloudWatch RUM Console, by including some code to fetch the required metadata we stored using the Custom Resource above.

We fetch the configuration by calling:

const res = await fetch('/rum.json');
const rum = await res.json();

And then using the fetched metadata in the configuration of the web client:

const config: AwsRumConfig = {
  sessionSampleRate: 1,
  guestRoleArn: `${rum?.guestRoleArn}`,
  identityPoolId: `${rum?.identityPoolId}`,
  endpoint: "https://dataplane.rum.us-east-1.amazonaws.com",
  telemetries: ["errors","performance","http"],
  allowCookies: true,
  enableXRay: false
};

const APPLICATION_ID: string = `${rum?.APPLICATION_ID}`;
const APPLICATION_VERSION: string = "1.0.0";
const APPLICATION_REGION: string = "us-east-1";

if (APPLICATION_ID) {
  const awsRum: AwsRum = new AwsRum(
    APPLICATION_ID,
    APPLICATION_VERSION,
    APPLICATION_REGION,
    config
  );
  console.log(awsRum);
}

Then we only need to make sure we import and run the rumRunner function at the start of our front-end code (ui/main.ts):

import { rumRunner } from "./rum";

rumRunner();

Deploying and Integrating with Other Web Applications

By deploying this code we can start to gain insights from our applications including Core Web Vitals and other performance metrics.

By packaging this as a CDK Construct and Typescript snippet... we could easily add this to other CDK projects by adding in FOUR lines of code 🤯

Two lines in your CDK App:

import { RumRunnerConstruct } from './rum-runner-construct';
new RumRunnerConstruct(this, `Rum`, { bucket, cloudFront, });

And two lines in your front-end app:

import { rumRunner } from "./rum";
rumRunner();

(and I'm being generous with the line counts)

Conclusion

Measuring the quality of user experience is key to delivering a seamless and enjoyable experience for your website visitors. Core Web Vitals play a crucial role in this measurement, providing objective metrics to assess the performance of your website. To make the most of these metrics, it's important to consider Core Web Vitals early on in the development process. One way to do this is by using reusable CDK constructs, which can help you identify areas for improvement and optimize the user experience of your website.