AWS Lesson 73 of 123

End-User and Synthetic Monitoring on AWS: CloudWatch RUM and Synthetics Canaries

In a nutshell

Imagine you run a busy restaurant. Your kitchen dashboards are perfect: every burner is lit, the pass clears tickets in under four minutes, the health inspector loves you. Yet customers are leaving one-star reviews. Why? Because the dining room is where they actually sit — and the kitchen instruments cannot see a wobbly table, a waiter who never showed, or a front door that sticks. Backend monitoring is the kitchen. The browser is the dining room. This lesson is about finally putting sensors in the dining room.

You do that two ways, and you need both. CloudWatch RUM (Real User Monitoring) is a tiny sensor you clip onto every real diner — a JavaScript snippet in the browser that reports what actual people experienced: how fast the page painted, whether a button froze, which script threw an error. It is passive and honest, but it only sees a table once someone sits at it. CloudWatch Synthetics sends in a robot diner on a timer — a headless browser (a “canary”) that walks your key journeys every minute from several AWS regions, so an empty table with a broken chair is found at 3am by the robot instead of at 9am by a paying customer. RUM tells you what users got; canaries tell you what a user would get right now.

The payoff is that both feed the same CloudWatch metrics you already use for backend services, so you can write real frontend SLOs — an availability target (“99.5% of page views error-free”) and a latency target (“95% of navigations under 2 s”) — with error budgets and burn-rate alarms, and put the browser on call like any other tier.

Level: Advanced · Time: ~45 min

Prerequisites

After this lesson you can

CloudWatch RUM + Synthetics canaries for frontend SLO monitoring

Read the diagram left → right: real browsers stream Core Web Vitals through the RUM web client while scheduled canaries actively probe the same CloudFront + S3 frontend; both land in CloudWatch, where availability and latency SLIs drive a multi-window burn-rate alarm that pages on-call via SNS.

RUM vs Synthetics at a glance

The single most important idea in this lesson is that these two tools are complementary opposites — passive vs active, real vs synthetic. Beginners reach for one and wonder why they still get surprised; the answer is almost always that they needed the other.

Dimension CloudWatch RUM (real-user) CloudWatch Synthetics (canaries)
Signal type Passive — observes real sessions Active — generates traffic on a schedule
Data source Actual browsers, real devices/networks Headless browser / HTTP from AWS regions
Sees What users did experience What a user would experience now
Blind to Paths no one visited; 3am with no traffic Real device/network/geo diversity; rage-clicks
Coverage of empty paths ✗ needs a real visitor ✓ runs regardless of traffic
Cost driver Per event ingested (× sampling) Per canary run (× cadence × regions)
Namespace AWS/RUM CloudWatchSynthetics
Best used as The reported SLO (ground truth) The leading indicator (pages first)

Hold that last row: you page on the canary (it fires with zero live traffic) but you report the SLO on RUM (it is what humans actually got). Everything below is the detail behind those two sentences.

Backend dashboards lie to you about the frontend. Your ALB target group is healthy, p99 server latency is flat, X-Ray shows no faults - and a third of your users are staring at a blank page because a CDN-served bundle 404’d or a third-party tag blew up the main thread. The two signals that close that blind spot are passive and active. CloudWatch RUM (Real User Monitoring) is passive: a JavaScript web client reports what real browsers actually experienced - Core Web Vitals, JS errors, fetch failures, navigation timing. Synthetics canaries are active: scheduled headless-browser scripts that exercise your site from AWS regions on a fixed cadence, so you find out it’s down at 3am instead of from a customer at 9am. Run both, feed their metrics into the same SLO math you already use for backend services, and you get a frontend you can actually put on call.

1. Instrumenting the browser with the CloudWatch RUM web client

RUM has two halves: an app monitor (the AWS-side resource that ingests events, emits metrics, and stores raw events in CloudWatch Logs) and the web client (aws-rum-web, the npm package or CDN snippet that runs in the browser and posts events to the data plane). The web client authenticates to the RUM PutRumEvents API using temporary, unauthenticated credentials from a Cognito identity pool - that is the supported, low-friction path, and it is why you never ship long-lived keys to the browser.

When you create an app monitor with --cw-log-enabled and let RUM manage the identity pool, AWS provisions the Cognito identity pool, the unauthenticated IAM role, and the role policy for you. Create it first:

aws rum create-app-monitor \
  --name kloudvin-web \
  --domain app.kloudvin.com \
  --cw-log-enabled \
  --app-monitor-configuration '{
    "AllowCookies": true,
    "EnableXRay": true,
    "SessionSampleRate": 0.2,
    "Telemetries": ["errors", "performance", "http"],
    "FavoritePages": ["/", "/checkout"]
  }' \
  --region eu-west-1

SessionSampleRate: 0.2 instruments 20% of sessions - the single biggest cost and volume lever (section 7). Telemetries selects which plugins the client loads: errors (JS errors), performance (navigation, resource, and Web Vitals), and http (XHR/fetch instrumentation). EnableXRay: true makes the client attach trace headers to instrumented HTTP calls so RUM sessions stitch to backend traces (section 5).

Grab the generated identity pool ID and app monitor ID from the response - the snippet needs both:

aws rum get-app-monitor --name kloudvin-web --region eu-west-1 \
  --query 'AppMonitor.{Id:Id,Pool:AppMonitorConfiguration.IdentityPoolId}'

Now install the client. Prefer the npm module over the CDN loader in a real app - you get version pinning, tree-shaking, and the ability to gate it behind consent. The config object passed to AwsRum mirrors the app monitor:

import { AwsRum } from 'aws-rum-web';

const config = {
  sessionSampleRate: 0.2,
  identityPoolId: 'eu-west-1:11111111-2222-3333-4444-555555555555',
  endpoint: 'https://dataplane.rum.eu-west-1.amazonaws.com',
  telemetries: ['errors', 'http', ['performance', { recordAllTypes: ['css','script','img','fetch'] }]],
  allowCookies: true,
  enableXRay: true
};

const APPLICATION_ID = 'a1b2c3d4-...';
const APPLICATION_VERSION = '1.0.0';
const APPLICATION_REGION = 'eu-west-1';

// Hold the instance so you can record custom errors/events later.
export const awsRum = new AwsRum(APPLICATION_ID, APPLICATION_VERSION, APPLICATION_REGION, config);

Load the client as early as possible in the document head, before your app bundle. Web Vitals like Largest Contentful Paint and the navigation-timing entries are emitted by the browser early in page load; if the RUM client mounts after first paint it will miss them. Early load is the difference between a populated Web Vitals dashboard and an empty one.

A note on allowCookies: when true, the client stores a session ID and user ID in cookies so a session survives across page navigations and the per-session metrics (below) are accurate. With false, every page load looks like a new session, inflating SessionCount and breaking per-session ratios. Set it according to your consent posture, but understand what you lose.

Under the hood: how the browser is allowed to write to RUM

The one part of this section worth slowing down on is authentication, because it is where beginners either ship a security hole or give up. The browser is a hostile place to keep credentials — anything you put in JavaScript is readable by anyone who opens devtools. So RUM never uses long-lived keys. Instead the web client calls the Cognito identity pool’s GetId / GetCredentialsForIdentity to obtain temporary, unauthenticated (“guest”) AWS credentials, and uses those to sign the single API call it makes: rum:PutRumEvents against the RUM data plane.

When you pass --cw-log-enabled and let RUM manage identity, AWS creates the pool, the guest role, and a scoped role policy for you. That generated policy is deliberately tiny — it can do exactly one thing, to exactly one app monitor:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": "rum:PutRumEvents",
    "Resource": "arn:aws:rum:eu-west-1:123456789012:appmonitor/kloudvin-web"
  }]
}

Two things follow. First, a leaked guest credential can only append RUM events to one monitor — it cannot read your data or touch anything else, which is why unauthenticated ingest is acceptable at all. Second, if you bring your own identity pool (common when you already authenticate users), you must attach exactly this permission to the role the pool hands out, and nothing more. Widening it “just to make it work” is the classic self-inflicted wound.

Beyond the built-in telemetry, the held awsRum instance lets you enrich the stream. recordEvent attaches a custom event type you can later query in Logs Insights, and session attributes (via sessionAttributes in config, or addSessionAttributes at runtime) become metadata dimensions on every event — a build SHA, an A/B cohort, a tenant tier:

awsRum.addSessionAttributes({ release: 'checkout@2.4.1', cohort: 'canary-10pct' });
awsRum.recordEvent('cart_abandoned', { step: 'shipping', itemCount: 3 });

That is how “LCP regressed” becomes “LCP regressed for the canary-10pct cohort on release 2.4.1” — the difference between a mystery and a one-click rollback.

2. Capturing Core Web Vitals, JS errors, and session/page performance

Once the performance, errors, and http telemetries are live, the app monitor emits a fixed set of CloudWatch metrics into the AWS/RUM namespace, every one carrying an application_name dimension equal to the monitor name. These are the metrics your SLOs and alarms key off. The ones that matter:

Metric Unit What it measures
WebVitalsLargestContentfulPaint ms LCP - loading; the headline “is it fast” Web Vital
WebVitalsCumulativeLayoutShift None CLS - visual stability
WebVitalsInteractionToNextPaint ms INP - responsiveness (replaced FID as a Core Web Vital)
WebVitalsFirstInputDelay ms FID - legacy responsiveness signal
PerformanceNavigationDuration ms full navigation timing for a page load
NavigationSatisfiedTransaction Count navigations under the 2000ms Apdex objective
NavigationToleratedTransaction Count navigations between 2000ms and 8000ms
NavigationFrustratedTransaction Count navigations slower than the 8000ms frustrating threshold
JsErrorCount Count uncaught JS error events ingested
Http5xxCount / Http4xxCount Count fetch/XHR responses by status class
PageViewCount Count page-view events
SessionCount Count new sessions started

A few things follow from this table. First, the Apdex split (Satisfied/Tolerated/Frustrated) is pre-bucketed against the standard 2000ms/8000ms thresholds, which is exactly the shape you want for a navigation-latency SLI - a count-based good/total ratio, not a quantile you have to estimate. Second, WebVitals* metrics are emitted with the value distribution, so you query them with the p75 statistic - the percentile Google’s Core Web Vitals thresholds are defined against (LCP good <= 2.5s, INP good <= 200ms, CLS good <= 0.1 at p75).

To catch errors your window.onerror handler swallows or that originate in a try/catch, record them explicitly through the held client:

try {
  await loadCheckout();
} catch (err) {
  awsRum.recordError(err);   // becomes a com.amazon.rum.js_error_event -> JsErrorCount
  showFallbackUI();
}

The raw events behind these aggregates land in a CloudWatch Logs log group named aws-rum/<app-monitor-id> (because you set --cw-log-enabled). That is where you go from “JsErrorCount spiked” to “which file, which line, which browser” - query it with Logs Insights:

fields @timestamp, event_details.message, event_details.fileName, event_details.lineNumber, metadata.browserName
| filter event_type = "com.amazon.rum.js_error_event"
| sort @timestamp desc
| limit 50

Aggregates vs raw events: two stores, two jobs

A subtle point that trips people up: RUM writes your data to two places with different shapes, retention, and cost, and you query them for different questions.

CloudWatch metrics (AWS/RUM) CloudWatch Logs (aws-rum/<id>)
Shape Pre-aggregated numbers (counts, distributions) One JSON document per raw event
Answers “Is LCP p75 within budget? Are errors up?” Which file / line / browser / user threw it?”
Retention 15 months (standard CloudWatch metric retention) Whatever you set on the log group (default: never expire — fix this)
Cost model Metric + query charges Ingestion + storage per GB
Use for Dashboards, SLOs, alarms Root-cause drill-down, forensics

Alarms and SLOs live on the metrics; investigations live in the logs. Wiring both is the whole point of --cw-log-enabled.

A few properties of the metric side reward attention. WebVitals* metrics are published as distributions, so you must read them with a percentile statistic — p75 specifically, because that is the aggregation Google’s Core Web Vitals thresholds are defined against (not the average, which quietly hides your worst quartile). In the console you pick the p75 statistic; from the CLI you pass --extended-statistics p75, as the Verify section shows. The FavoritePages you set on the monitor become a page-group dimension, letting you SLO /checkout separately from the marketing home page — essential, because a blended average lets a fast landing page mask a slow, revenue-critical funnel. RUM also derives a coarse geolocation (country/region) and device/browser dimension from each session, which is how you discover that “the site is slow” is really “the site is slow on Android Chrome in one country.”

3. Authoring Synthetics canaries: heartbeat, API, and broken-link blueprints

RUM tells you what users hit. Canaries tell you what would happen if a user hit you right now, including paths with no live traffic. A canary is a Lambda function built from your script plus a Synthetics runtime layer, invoked on a schedule, recording screenshots/HAR/logs to S3 and publishing pass/fail metrics. AWS ships blueprints for the three most common shapes; you can author them in the console, but encode them as IaC so they’re reviewable and reproducible.

Pin the runtime version explicitly. The current Node/Puppeteer line is syn-nodejs-puppeteer-12.0 and the Playwright line is syn-nodejs-playwright-4.0; AWS deprecates old runtimes on a published schedule, so a hard-coded version is a thing you must keep current, not set-and-forget.

Heartbeat - load a URL, assert it rendered, capture a screenshot. The smallest useful canary:

const { URL } = require('url');
const synthetics = require('Synthetics');
const log = require('SyntheticsLogger');

const pageLoadBlueprint = async function () {
  const url = 'https://app.kloudvin.com/';
  const page = await synthetics.getPage();

  // executeStep names the step -> SuccessPercent/Duration get a StepName dimension
  await synthetics.executeStep('loadHomepage', async function () {
    const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
    if (!response || response.status() < 200 || response.status() > 299) {
      throw new Error(`Failed to load ${url}: status ${response && response.status()}`);
    }
  });

  await synthetics.executeStep('verifyContent', async function () {
    await page.waitForSelector('#app', { timeout: 15000 });
  });
};

exports.handler = async () => {
  return await pageLoadBlueprint();
};

API canary - exercise an endpoint and validate status, headers, and body without a browser. Use executeHttpStep, which is purpose-built for request/response assertions and emits per-step 2xx/4xx/5xx metrics:

const synthetics = require('Synthetics');

const apiCanary = async function () {
  const validate = async function (res) {
    return new Promise((resolve, reject) => {
      if (res.statusCode !== 200) {
        reject(new Error(`${res.statusCode} ${res.statusMessage}`));
        return;
      }
      let body = '';
      res.on('data', (d) => { body += d; });
      res.on('end', () => {
        const json = JSON.parse(body);
        if (json.status !== 'ok') reject(new Error(`bad payload: ${body}`));
        else resolve();
      });
    });
  };

  await synthetics.executeHttpStep('GET /healthz', {
    hostname: 'api.kloudvin.com',
    method: 'GET',
    path: '/healthz',
    port: 443,
    protocol: 'https:',
    headers: { 'User-Agent': synthetics.getCanaryUserAgentString() }
  }, validate);
};

exports.handler = async () => {
  return await apiCanary();
};

Broken-link checker - crawl a page, follow its links, and fail when any returns an error. The runtime provides LinkChecker/addLinks/checkLinks:

const synthetics = require('Synthetics');
const SyntheticsLink = require('SyntheticsLink');

const brokenLinkChecker = async function () {
  const baseUrl = 'https://app.kloudvin.com/';
  const syntheticsConfiguration = synthetics.getConfiguration();
  syntheticsConfiguration.setConfig({ continueOnStepFailure: true });

  let page = await synthetics.getPage();
  await synthetics.executeStep('openBase', async () => {
    await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
  });

  const hrefs = await page.$$eval('a[href]', (as) => as.map((a) => a.href).slice(0, 20));
  for (const href of hrefs) {
    const link = new SyntheticsLink(href);
    await synthetics.executeStep(`link:${href}`, async () => {
      const res = await page.goto(href, { waitUntil: 'domcontentloaded', timeout: 20000 });
      link.withStatusCode(res.status()).withText(href);
      await synthetics.addLinkToReport(link);
      if (res.status() < 200 || res.status() > 399) {
        throw new Error(`Broken link ${href}: ${res.status()}`);
      }
    });
  }
};

exports.handler = async () => {
  return await brokenLinkChecker();
};

Provision the canary itself with Terraform so the schedule, runtime, artifact bucket, and alarm live together:

resource "aws_synthetics_canary" "homepage_heartbeat" {
  name                 = "kv-homepage"            # <= 21 chars, lowercase
  artifact_s3_location = "s3://${aws_s3_bucket.canary_artifacts.id}/homepage/"
  execution_role_arn   = aws_iam_role.canary.arn
  runtime_version      = "syn-nodejs-puppeteer-12.0"
  handler              = "pageLoadBlueprint.handler"
  zip_file             = data.archive_file.heartbeat.output_path

  schedule {
    expression = "rate(1 minute)"   # cadence == your detection latency floor
  }

  run_config {
    timeout_in_seconds = 60
    memory_in_mb       = 1024
    active_tracing     = true       # write canary spans to X-Ray
  }

  success_retention_period = 7      # days of artifacts kept for passing runs
  failure_retention_period = 31     # keep failures longer for forensics

  start_canary = true
}

4. Scheduling canaries across regions and alerting on SuccessPercent

Every canary publishes to the CloudWatchSynthetics namespace. Two metrics are always emitted, both with and without a CanaryName dimension: SuccessPercent (percentage of runs in the period that passed) and Duration (run time in ms). Canaries using executeStep/executeHttpStep additionally emit SuccessPercent and Duration per step (CanaryName + StepName), and HTTP-step canaries emit 2xx/4xx/5xx/Failed. SuccessPercent is what you page on - it’s already a clean availability percentage.

Two scheduling decisions drive coverage:

  1. Cadence is your detection-latency floor. A rate(1 minute) canary cannot detect an outage faster than ~1 minute. Tighter cadence buys faster detection at linear cost (section 7). Run business-critical journeys at 1 minute, secondary paths at 5.
  2. Geography is real. A canary in eu-west-1 will not catch a CloudFront edge or Route 53 issue affecting us-west-2 users. Deploy the same canary to several regions and alarm per region. Single-region synthetic monitoring gives false confidence the moment the problem is regional or DNS-level.

The alarm is the product. Alarm on SuccessPercent below a threshold so any run-level failure trips it, and use breaching as the missing-data treatment so a canary that stops running entirely (a broken deploy, an IAM regression) is itself an alert:

resource "aws_cloudwatch_metric_alarm" "homepage_down" {
  alarm_name          = "kv-homepage-down-eu-west-1"
  namespace           = "CloudWatchSynthetics"
  metric_name         = "SuccessPercent"
  dimensions          = { CanaryName = aws_synthetics_canary.homepage_heartbeat.name }
  statistic           = "Average"
  period              = 60
  evaluation_periods  = 3
  datapoints_to_alarm = 2          # 2 of last 3 runs failing -> page (debounces a flaky run)
  comparison_operator = "LessThanThreshold"
  threshold           = 90         # < 90% of runs in the window succeeded
  treat_missing_data  = "breaching"
  alarm_actions       = [aws_sns_topic.pager.arn]
  ok_actions          = [aws_sns_topic.pager.arn]
}

The datapoints_to_alarm = 2 of evaluation_periods = 3 (an “M of N” alarm) is what keeps a single transient failure - one slow third party, one TCP reset - from waking someone, while a genuine outage (two consecutive misses) still pages within ~2 minutes.

5. Correlating RUM and canary data with X-Ray traces for root cause

Detection without correlation just tells you that something broke. The win is jumping from a degraded frontend signal straight to the offending backend span. AWS wires three planes together if you opt in.

On the RUM side, enableXRay: true makes the web client generate an X-Ray trace header for each instrumented fetch/XHR and emit a trace segment for the browser-side request. When that request reaches an X-Ray-instrumented backend (ALB, API Gateway, Lambda, your ADOT-instrumented EKS service), the IDs line up and the X-Ray service map shows a node for your client application feeding into the backend graph - so a JS Http5xxCount spike is one click from the failing downstream segment.

On the canary side, active_tracing = true (the run_config flag above) makes the canary’s outbound calls participate in X-Ray. A failing API canary then carries a trace ID, and the trace shows exactly which hop errored - DNS, TLS, the ALB, or the service - turning “the API canary is red” into “the orders service returned a 503 on the database call.”

Correlation only works if the trace context propagates end to end. If an intermediary (a misconfigured CloudFront behavior, an ALB rule, a service that doesn’t forward X-Amzn-Trace-Id) drops the header, the IDs break and you get orphaned segments instead of one connected map. Verify propagation deliberately - it is the single most common reason RUM-to-X-Ray correlation silently does nothing.

The practical triage loop becomes: alarm fires on canary SuccessPercent or RUM Http5xxCount -> open the failed run / RUM session -> follow its trace ID into the X-Ray service map -> read the faulting segment. Minutes, not a war-room.

6. Defining frontend availability and latency SLOs from RUM metrics

You already do SLOs for backend services as good/valid event ratios. RUM lets you extend the same discipline to the browser, with two user-facing SLIs.

Availability SLI - page loads not broken by errors. The cleanest proxy from the default metrics is server-error rate experienced in the browser: good = page views - sessions/views with errors, bad = Http5xxCount (+ JsErrorCount for hard failures). Compute a success ratio with a CloudWatch metric-math expression over the AWS/RUM namespace:

# Metric math on AWS/RUM (dimension application_name = kloudvin-web)
m1 = JsErrorCount        (Sum)
m2 = Http5xxCount        (Sum)
m3 = PageViewCount       (Sum)
e1 = 100 * (1 - (m1 + m2) / m3)     # frontend success %, the SLI you SLO against

Latency SLI - navigations that felt fast. The Apdex buckets are tailor-made: good = NavigationSatisfiedTransaction, valid = Satisfied + Tolerated + Frustrated. That is a count-based ratio against the 2000ms objective with no quantile estimation:

s = NavigationSatisfiedTransaction (Sum)
t = NavigationToleratedTransaction (Sum)
f = NavigationFrustratedTransaction (Sum)
fast_pct = 100 * s / (s + t + f)    # % of navigations under the 2000ms Apdex objective

Separately, track the Core Web Vitals at p75 against Google’s thresholds, because that is how Search and field-data tools judge you: LCP good <= 2500ms, INP good <= 200ms, CLS good <= 0.1. Alarm on the p75 statistic of WebVitalsLargestContentfulPaint and WebVitalsInteractionToNextPaint.

Then put a multi-window burn-rate discipline on the availability SLI exactly as you would for a backend SLO: a fast-burn alarm (e.g. 14.4x burn over 1h) for “we’re torching the budget now,” and a slow-burn alarm (e.g. 6x over 6h) for “a low-grade regression is quietly eating the month.” Synthetic SuccessPercent is the leading indicator (catches outages with zero live traffic); RUM ratios are the ground truth (what users actually got). Page on the canary, report the SLO on RUM.

Managed SLOs with CloudWatch Application Signals, and composite alarms

The metric-math SLIs above are portable and explicit, and for many teams they are enough. But CloudWatch also offers a managed SLO primitive — CloudWatch Application Signals SLOs — that removes the hand-rolled burn-rate math. You point an SLO at a metric (including a RUM or Synthetics metric, or a metric-math expression over them), declare the objective and rolling window, and Application Signals computes attainment and the remaining error budget for you, renders the burn-down, and can attach burn-rate alarms without you deriving the 14.4× threshold by hand.

Sketching the availability SLO from below as an Application Signals SLO:

{
  "Name": "frontend-availability",
  "Sli": {
    "SliMetric": {
      "MetricDataQueries": [
        { "Id": "m1", "ReturnData": false, "MetricStat": { "Period": 60, "Stat": "Sum",
          "Metric": { "Namespace": "AWS/RUM", "MetricName": "JsErrorCount",
            "Dimensions": [{ "Name": "application_name", "Value": "kloudvin-web" }] } } },
        { "Id": "m3", "ReturnData": false, "MetricStat": { "Period": 60, "Stat": "Sum",
          "Metric": { "Namespace": "AWS/RUM", "MetricName": "PageViewCount",
            "Dimensions": [{ "Name": "application_name", "Value": "kloudvin-web" }] } } },
        { "Id": "e1", "ReturnData": true, "Expression": "100 * (1 - m1/m3)" }
      ]
    },
    "MetricThreshold": 99.5,
    "ComparisonOperator": "GreaterThanOrEqualToThreshold"
  },
  "Goal": {
    "AttainmentGoal": 99.5,
    "WarningThreshold": 30.0,
    "Interval": { "RollingInterval": { "Duration": 30, "DurationUnit": "DAY" } }
  }
}

The other production pattern to reach for is the composite alarm. Frontend health is a fleet of signals — canary SuccessPercent, RUM Http5xxCount, LCP p75 — and paging on each one independently produces alert storms during a real incident (they all fire at once) and false pages during a blip (one flickers). A composite alarm expresses the human judgement in Boolean logic, so on-call gets one page that already encodes “this is really broken”:

resource "aws_cloudwatch_composite_alarm" "frontend_unhealthy" {
  alarm_name = "frontend-unhealthy"
  alarm_rule = join(" OR ", [
    "ALARM(${aws_cloudwatch_metric_alarm.homepage_down.alarm_name})",
    "(ALARM(rum-http5xx-high) AND ALARM(rum-lcp-p75-slow))"
  ])
  alarm_actions = [aws_sns_topic.pager.arn]
}

Read the rule: page if the heartbeat canary is down or if real users are simultaneously seeing elevated 5xx and slow LCP. A single slow-LCP blip with healthy errors stays quiet; a genuine regression pages once. That is how you keep an SLO program from training on-call to ignore it.

7. Sampling, data retention, and managing RUM/Synthetics cost

Both services bill on volume, and both have a single dominant lever.

RUM is priced per RUM event ingested (every page view, error, navigation, resource, and Web Vital is an event), so cost scales with traffic x events-per-session x SessionSampleRate. Controls, highest leverage first:

Synthetics is priced per canary run. Cost scales linearly with (number of canaries) x (runs per hour) x (regions) - which is precisely the cross-product you expand for coverage in section 4. So treat cadence as a budget: 1-minute on a handful of revenue-critical journeys, 5- to 15-minute on the long tail. The two artifact retention knobs (success_retention_period, failure_retention_period) govern S3 storage; the asymmetric default in our Terraform - 7 days for passes, 31 for failures - keeps forensics cheap without hoarding green screenshots.

Cost on both services is a deliberate trade against statistical power and detection latency, not an afterthought. Set SessionSampleRate and canary cadence per application based on traffic and how much downtime that journey can tolerate - one global default is always wrong somewhere.

Going deeper

The canary execution role is the thing to get right

A canary is a Lambda, and like any Lambda it assumes an execution role. Console-created canaries generate a role that is often broader than you want; in IaC you write it explicitly and least-privilege. A canary genuinely needs four capabilities and no more: write artifacts to its S3 prefix, publish the CloudWatchSynthetics metrics, write its CloudWatch Logs, and — if active_tracing is on — put X-Ray segments.

{
  "Version": "2012-10-17",
  "Statement": [
    { "Sid": "Artifacts", "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:GetBucketLocation"],
      "Resource": "arn:aws:s3:::kv-canary-artifacts/*" },
    { "Sid": "ArtifactBucketList", "Effect": "Allow",
      "Action": "s3:ListAllMyBuckets", "Resource": "*" },
    { "Sid": "Metrics", "Effect": "Allow",
      "Action": "cloudwatch:PutMetricData", "Resource": "*",
      "Condition": { "StringEquals": { "cloudwatch:namespace": "CloudWatchSynthetics" } } },
    { "Sid": "Logs", "Effect": "Allow",
      "Action": ["logs:CreateLogStream", "logs:PutLogEvents", "logs:CreateLogGroup"],
      "Resource": "arn:aws:logs:eu-west-1:123456789012:log-group:/aws/lambda/cwsyn-*" },
    { "Sid": "Xray", "Effect": "Allow",
      "Action": ["xray:PutTraceSegments", "xray:PutTelemetryRecords"], "Resource": "*" }
  ]
}

The cloudwatch:PutMetricData statement is scoped by a condition on the namespace — without it, a compromised canary script could scribble into any metric in your account, including the very metrics your alarms trust. That one condition is the difference between a monitoring role and a blank cheque.

Running a canary inside a VPC

By default a canary runs in AWS-managed network space and can reach the public internet. Point it at a private endpoint — an internal ALB, a service only reachable inside your VPC, a /healthz that is not exposed — and it must run in the VPC. You attach subnets and a security group; Synthetics creates an elastic network interface (ENI) in each subnet, exactly like a VPC Lambda:

resource "aws_synthetics_canary" "internal_api" {
  name             = "kv-internal-api"
  # ... runtime, handler, role, schedule as in section 3 ...
  vpc_config {
    subnet_ids         = [aws_subnet.private_a.id, aws_subnet.private_b.id]
    security_group_ids = [aws_security_group.canary.id]
  }
}

Two gotchas decide whether this works. First, a canary in private subnets has no public-internet path unless those subnets route through a NAT gateway — so a canary that hits both an internal service and a public cloudfront.net URL needs NAT, or it hangs on the public leg and fails with a timeout that looks like an outage. Second, the canary’s security group and the target’s security group must both allow the flow; a red canary that is really a missing SG rule is a self-inflicted 3am page. Prefer VPC endpoints (an S3 gateway endpoint for artifacts) to keep artifact writes off the NAT path and cheaper.

Runtimes age out — pin, then upgrade deliberately

Runtime family Engine Language Reach for it when
syn-nodejs-puppeteer-N Chromium + Puppeteer Node.js GUI workflows, screenshots, the broken-link checker
syn-nodejs-playwright-N Chromium + Playwright Node.js Newer GUI canaries; richer auto-waiting and selectors
syn-python-selenium-N Chromium + Selenium Python Your team standardised on Python/Selenium

AWS deprecates old runtime versions on a published schedule; a canary left on a deprecated runtime eventually stops getting patches and can be blocked from create/update. So runtime_version is not set-and-forget — treat a runtime bump like any dependency upgrade: pin it explicitly (never “latest”), watch the deprecation calendar, and roll it on one canary you can revert first. The syn-nodejs-puppeteer-12.0 / syn-nodejs-playwright-4.0 versions used in this lesson are current at the time of writing; confirm the latest before you copy them.

The SyntheticsConfiguration object exposes the knobs that decide cost and forensic value per run: screenshotOnStepStart / screenshotOnStepFailure (turn off success screenshots on high-frequency canaries to cut S3), harFile (capture the HAR waterfall — gold for “which request was slow”, but heavier), continueOnStepFailure (the broken-link checker sets this so one dead link does not abort the crawl), and includeRequestHeaders / restrictedHeaders (keep Authorization out of the HAR that lands in S3 — a real data-leak vector).

What RUM actually sends, and why sampling is per-session

The web client batches events and flushes them to the data plane periodically and on page-hide (using the browser’s sendBeacon, so the final batch survives a tab close). Sampling is decided once per session, not per event: when a session is admitted at SessionSampleRate = 0.2, all of its events are recorded; when it is dropped, none are. That is deliberate — it keeps each recorded session internally consistent (you never get a page-view whose errors were sampled away), and it means “20% sampling” is 20% of sessions, so per-session ratios (errors per session, Web Vitals distributions) stay statistically valid; you are simply working from a representative subsample.

That subsample is why cost and confidence trade off cleanly. Web Vitals are distributional, so a few thousand sampled sessions already pin p75 tightly; error rate needs more volume to catch rare errors, which is the argument for sampling lower on a firehose consumer site but at 1.0 on a low-traffic internal app where every session is precious. The formula to internalise: events ≈ sessions × sample_rate × events_per_session, and events_per_session climbs with page views and, especially, with unbounded resource-timing capture — which is the quiet multiplier recordAllTypes / eventLimit exist to cap.

Quotas, throttling, and blast radius

Limit Rough default Why you hit it
Canaries per account / region 100 (soft) Fan-out of journeys × regions adds up fast
RUM app monitors per account 10 (soft) One per app/environment; request an increase for many apps
PutRumEvents request rate Throttled per app monitor A traffic spike at 1.0 sampling can hit it — another reason to sample
Canary name ≤ 21 chars, lowercase Terraform fails late if you forget

The soft limits are raised via a Service Quotas request, but they still shape design: run the same journey in several regions rather than minting a unique canary per micro-path, and consolidate RUM app monitors per application rather than per page.

A cost worked example

Put numbers on it. A consumer site with 2 million sessions/day, ~30 events/session:

Now canaries: one heartbeat at rate(1 minute) = 1,440 runs/day per region. Across 3 regions = 4,320 runs/day. Add a 5-minute API canary in the same 3 regions = 288 × 3 = 864 more. The lever is visible: dropping the heartbeat to rate(5 minutes) cuts its runs 5× but quintuples your worst-case detection latency. That is the trade — detection latency priced in canary runs — and it should be made per journey, not by one global default. Raw-event Logs and canary S3 artifacts are separate line items; a retention policy on both is the cheapest money you will ever save.

When these are the wrong tool

RUM and Synthetics are web-first. For a native mobile app, the RUM web client does not apply — reach for a mobile RUM SDK (a third-party APM, or roll your own telemetry to CloudWatch). For deep transaction tracing across many microservices, RUM and canaries detect and localise but X-Ray (or an OpenTelemetry pipeline) does the causal work — which is exactly why the correlation in section 5 matters. And a canary is a poor load test: it is a correctness probe on a schedule, not a concurrency generator — use a load-testing tool for that and keep the canary as your steady-state heartbeat.

Verify

Confirm the whole pipeline is actually emitting before you trust the dashboards.

Check the app monitor exists, is ingesting, and is logging raw events:

aws rum get-app-monitor --name kloudvin-web --region eu-west-1 \
  --query 'AppMonitor.{State:State,Log:DataStorage.CwLog.CwLogEnabled,Sample:AppMonitorConfiguration.SessionSampleRate}'

Confirm RUM metrics are flowing into AWS/RUM (load the site in a browser first, then give it a minute):

aws cloudwatch list-metrics --namespace AWS/RUM \
  --dimensions Name=application_name,Value=kloudvin-web \
  --region eu-west-1 --query 'Metrics[].MetricName'

Check p75 LCP over the last hour - this is your headline Web Vital:

aws cloudwatch get-metric-statistics --namespace AWS/RUM \
  --metric-name WebVitalsLargestContentfulPaint \
  --dimensions Name=application_name,Value=kloudvin-web \
  --start-time "$(date -u -v-1H +%Y-%m-%dT%H:%M:%SZ)" \
  --end-time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  --period 3600 --extended-statistics p75 --region eu-west-1

Confirm the canary ran and check its last result, then verify SuccessPercent exists:

aws synthetics get-canary-runs --name kv-homepage --max-results 3 --region eu-west-1 \
  --query 'CanaryRuns[].{Status:Status.State,Started:Timeline.Started}'

aws cloudwatch list-metrics --namespace CloudWatchSynthetics \
  --dimensions Name=CanaryName,Value=kv-homepage --region eu-west-1 \
  --query 'Metrics[].MetricName'

Prove the alarm actually pages: temporarily point the heartbeat at a path that returns 500 (or stop the canary) and confirm the alarm transitions to ALARM and SNS delivers. An alarm you have never seen fire is a hypothesis, not a control.

aws cloudwatch describe-alarms --alarm-names kv-homepage-down-eu-west-1 \
  --region eu-west-1 --query 'MetricAlarms[].{State:StateValue,Reason:StateReason}'

Enterprise scenario

A retail platform team ran a single-page checkout behind CloudFront, with a eu-west-1 heartbeat canary on rate(5 minutes) and RUM at 100% sampling. During a Black Friday dry run they hit two problems at once. First, RUM ingestion costs were projected to roughly 5x their normal monthly bill under peak traffic, because 100% sampling on a high-traffic site multiplies events per session by the navigation count. Second - and worse - a canary deploy in week one had silently failed to roll out the new runtime, the canary stopped running, and because their alarm treated missing data as notBreaching, the monitor going dark looked identical to “all healthy.” They found it only because a manual smoke test caught a real checkout 500 the dead canary had missed.

The constraint: cut RUM cost without going blind on Web Vitals, and make canary silence itself an incident, across the three regions their customers actually came from.

The fix had three parts. They dropped SessionSampleRate to 0.15 (still tens of thousands of sampled sessions/day - more than enough for stable p75 Web Vitals and error rates), put a 14-day retention policy on the raw-event log group, deployed the checkout heartbeat to eu-west-1, eu-central-1, and us-east-1 at rate(1 minute), and changed every alarm to treat_missing_data = "breaching" so a canary that stops emitting pages on its own. The alarm that turned “we’ll find out next dry run” into “we find out in two minutes”:

resource "aws_cloudwatch_metric_alarm" "checkout_down" {
  alarm_name          = "checkout-down-${var.region}"
  namespace           = "CloudWatchSynthetics"
  metric_name         = "SuccessPercent"
  dimensions          = { CanaryName = "checkout-${var.region}" }
  statistic           = "Average"
  period              = 60
  evaluation_periods  = 3
  datapoints_to_alarm = 2
  comparison_operator = "LessThanThreshold"
  threshold           = 90
  treat_missing_data  = "breaching"   # the line that made silence an incident
  alarm_actions       = [aws_sns_topic.checkout_pager.arn]
}

Net result: projected RUM spend dropped ~85% against the 100%-sampling baseline with no loss of confidence in the p75 Web Vitals SLO, and on the actual Black Friday a CloudFront edge issue affecting only us-east-1 users tripped the us-east-1 canary nine minutes before the first customer complaint - exactly the regional failure the single-region setup would have missed.

Practice challenges

Work these in order; each has a hidden solution and a one-line why in the collapsible block. Everything uses placeholder names — swap in your own. No live AWS account is assumed; the point is that each answer is real and schema-correct.

1. (Beginner) Stand up an app monitor. Create a RUM app monitor shop-web for domain shop.example.com with 25% session sampling, X-Ray on, and raw-event logging on, in eu-west-1.

<details><summary>Solution</summary>

aws rum create-app-monitor \
  --name shop-web \
  --domain shop.example.com \
  --cw-log-enabled \
  --app-monitor-configuration '{
    "AllowCookies": true, "EnableXRay": true,
    "SessionSampleRate": 0.25, "Telemetries": ["errors","performance","http"]
  }' \
  --region eu-west-1

Why: SessionSampleRate: 0.25 and --cw-log-enabled are the two decisions with cost consequences; everything else is default-safe. </details>

2. (Beginner) Find the top JS errors. JsErrorCount just spiked. Write the Logs Insights query that lists the offending file, line, and browser for the last hour.

<details><summary>Solution</summary>

fields @timestamp, event_details.message, event_details.fileName,
       event_details.lineNumber, metadata.browserName
| filter event_type = "com.amazon.rum.js_error_event"
| sort @timestamp desc
| limit 50

Why: the metric tells you errors rose; only the raw events in the aws-rum/<id> log group tell you which error — which is the whole reason to enable logging. </details>

3. (Intermediate) Express the availability SLI. Using AWS/RUM metrics, write the metric-math for “percentage of page views not hit by a JS error or a 5xx.”

<details><summary>Solution</summary>

m1 = JsErrorCount   (Sum)
m2 = Http5xxCount    (Sum)
m3 = PageViewCount   (Sum)
e1 = 100 * (1 - (m1 + m2) / m3)    # frontend success %  ->  SLO against 99.5

Why: an SLI must be a good/valid ratio, not a raw count — a spike in errors only matters relative to how many page views absorbed it. </details>

4. (Intermediate) Page when — and only when — it is real. Terraform a SuccessPercent alarm on canary kv-homepage that pages after 2 of 3 one-minute runs fail, and that also pages if the canary stops running entirely.

<details><summary>Solution</summary>

resource "aws_cloudwatch_metric_alarm" "homepage_down" {
  alarm_name          = "kv-homepage-down"
  namespace           = "CloudWatchSynthetics"
  metric_name         = "SuccessPercent"
  dimensions          = { CanaryName = "kv-homepage" }
  statistic           = "Average"
  period              = 60
  evaluation_periods  = 3
  datapoints_to_alarm = 2
  comparison_operator = "LessThanThreshold"
  threshold           = 90
  treat_missing_data  = "breaching"    # a dead canary is itself the alert
  alarm_actions       = [aws_sns_topic.pager.arn]
}

Why: datapoints_to_alarm = 2 of 3 debounces one flaky run, while treat_missing_data = "breaching" closes the “monitor went dark = looks healthy” trap. </details>

5. (Advanced) Deploy one journey to three regions. Using Terraform for_each, deploy the same heartbeat canary and its alarm to eu-west-1, eu-central-1, and us-east-1, each alarming on its own region.

<details><summary>Solution</summary>

Define per-region provider aliases, then iterate a set of regions:

locals { regions = toset(["eu-west-1", "eu-central-1", "us-east-1"]) }

resource "aws_synthetics_canary" "hb" {
  for_each             = local.regions
  provider             = aws.by_region[each.key]           # one aliased provider per region
  name                 = "kv-hb-${replace(each.key, "-", "")}"  # <= 21 chars, lowercase
  runtime_version      = "syn-nodejs-puppeteer-12.0"
  handler              = "pageLoad.handler"
  artifact_s3_location = "s3://kv-canary-${each.key}/hb/"
  execution_role_arn   = aws_iam_role.canary[each.key].arn
  zip_file             = data.archive_file.hb.output_path
  schedule { expression = "rate(1 minute)" }
  start_canary = true
}

resource "aws_cloudwatch_metric_alarm" "hb_down" {
  for_each            = local.regions
  provider            = aws.by_region[each.key]
  alarm_name          = "kv-hb-down-${each.key}"
  namespace           = "CloudWatchSynthetics"
  metric_name         = "SuccessPercent"
  dimensions          = { CanaryName = aws_synthetics_canary.hb[each.key].name }
  statistic           = "Average"
  period              = 60
  evaluation_periods  = 3
  datapoints_to_alarm = 2
  comparison_operator = "LessThanThreshold"
  threshold           = 90
  treat_missing_data  = "breaching"
  alarm_actions       = [aws_sns_topic.pager[each.key].arn]
}

Why: a single-region canary gives false confidence the instant the failure is regional (a CloudFront edge, a Route 53 / DNS issue) — coverage means the same journey alarmed per region. </details>

6. (Advanced) One page, not an alert storm. Combine the heartbeat canary alarm with two RUM alarms (Http5xxCount high and WebVitalsLargestContentfulPaint p75 slow) so on-call is paged once for “canary down, OR real users seeing both errors and slow loads.”

<details><summary>Solution</summary>

resource "aws_cloudwatch_composite_alarm" "frontend_unhealthy" {
  alarm_name = "frontend-unhealthy"
  alarm_rule = join(" OR ", [
    "ALARM(kv-homepage-down)",
    "(ALARM(rum-http5xx-high) AND ALARM(rum-lcp-p75-slow))"
  ])
  alarm_actions = [aws_sns_topic.pager.arn]
}

Why: a composite alarm encodes the human judgement — page on a hard down, or on the conjunction of error and latency — so one real incident is one page, and a lone LCP blip stays quiet. </details>

Common beginner mistakes

Glossary

Checklist

awscloudwatchrumsyntheticsobservability
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments