preloader

The Canary That Never Sleeps: Monitoring User Journeys with CloudWatch Synthetics

Posted on: 11/9/2026 The Canary That Never Sleeps: Monitoring User Journeys with CloudWatch Synthetics

For most of the twentieth century, British coal miners took a small yellow bird to work with them. Canaries react to carbon monoxide much faster than people do, so a bird that stopped singing gave miners time to get out before the gas reached them. The practice lasted until 1986, when the British government decided to phase the birds out in favour of electronic detectors (Smithsonian).

The canaries are retired. The idea behind them isn’t. Send something small and expendable ahead of the people you care about, and watch what happens to it. That’s exactly what a CloudWatch Synthetics canary does for your application. It just sings in JSON.

The 3 a.m. Problem

Here’s a scenario you have probably lived through, or will.

It’s Saturday morning. Your CPU graphs are flat and healthy. Lambda error rates are zero. The load balancer reports every target as healthy. Your on-call engineer is asleep, because nothing has paged them.

Meanwhile, nobody has been able to log in since 3 a.m.

A front-end deploy on Friday evening shipped a JavaScript bundle that throws an error on the login page, but only after the page loads. The server returned 200 OK for every request. As far as your infrastructure is concerned, everything went perfectly. You find out on Monday, from a support ticket that starts with “Is anyone there?”

This isn’t a failure of monitoring. Every metric you collected was accurate. The problem is what you were measuring.

What you monitor What it tells you What it misses
CPU, memory, disk Your servers are alive Whether anything useful is happening on them
Error rates and logs Your code reported a failure Failures your code never noticed: broken JavaScript, a missing button, a third-party script that hangs
Load balancer health checks One endpoint returns 200 Whether a user can get from the home page to “Order confirmed”
Real user monitoring What real users experienced Anything at 3 a.m. when there are no users, and anything before the first user hits it

All four rows describe your system from the inside. They depend on traffic, and on your code knowing it failed. What’s missing is a view from the outside: something that behaves like a customer, on a schedule, whether or not any real customers are around.

That’s synthetic monitoring. “Synthetic” because the traffic is fake. It’s a script pretending to be a user, and that’s the point.

What a CloudWatch Synthetics Canary Actually Is

AWS describes canaries as “configurable scripts that run on a schedule, to monitor your endpoints and APIs” that “follow the same routes and perform the same actions as a customer” (AWS docs).

Underneath, there’s less magic than the name suggests:

That last pair is what turns a script into a monitoring tool. A failing canary doesn’t just tell you that the login page broke. It shows you a screenshot of the broken page, the network waterfall that led up to it, and the log line where the script gave up.

Pick your runtime

At the time of writing, the latest runtime in each browser family is (runtime versions):

Runtime Language Framework Browsers
syn-nodejs-playwright-9.0 Node.js 22.x Playwright 1.63.0 Chromium, Firefox
syn-nodejs-puppeteer-18.0 Node.js 22.x puppeteer-core 25.11.0 Chromium, Firefox
syn-python-selenium-13.0 Python 3.12 Selenium 4.32.0 Chromium

AWS recommends always using the most recent runtime, and it deprecates old ones regularly. If you already write Playwright end-to-end tests, the Playwright runtime will feel familiar, and it’s what we’ll use in the tutorial. Just know that it doesn’t support some of the console blueprints (more on that next).

Blueprints: canaries you don’t have to write

If you create a canary in the console, you don’t have to start from a blank file. There are seven blueprints (AWS docs):

Blueprint What it does Playwright support
Heartbeat monitoring Loads one or more URLs, stores a screenshot and a HAR file Yes
API canary Tests the read and write functions of a REST API, with multi-step HTTP calls No
Broken link checker Collects the links on a page and checks them for 404s, timeouts, bad hosts and more No
Visual monitoring Compares screenshots against a baseline run and fails if they differ beyond a threshold No (Puppeteer only)
Canary recorder A Chrome extension that records your clicks and typing and generates a script No
GUI workflow builder Builds a UI flow from actions like Click, Input text and Verify text Yes
Multi checks Up to 10 HTTP, DNS, SSL and TCP checks from a JSON config, with auth via Secrets Manager Yes

The blueprints are a great way to get a heartbeat up in five minutes. For anything you plan to keep, you’ll want the script in version control and the canary in infrastructure as code. So let’s do that.

Tutorial: A Canary That Uses Your App Like a Human

The scenario

We’ll monitor a real, public web app: the TodoMVC demo that the Playwright team hosts for exactly this kind of testing. It’s a small app, but it has a proper user journey, and that’s what we care about:

  1. Open the app.
  2. Add two todos.
  3. Mark one of them as done.
  4. Check that the counter says 1 item left.

Notice what this catches that a health check never could. If the JavaScript bundle fails to load, step 2 fails. If a CSS change hides the checkbox, step 3 fails. If someone breaks the counter logic, step 4 fails. In every case the server would still happily return 200.

We’ll run the journey every five minutes, alarm when it fails, and send an email when the alarm fires.

What you’ll need

Step 1: Create the project

mkdir todo-canary && cd todo-canary
cdk init app --language typescript
mkdir canary

The canary folder holds the script that Synthetics will run. Everything else is the CDK app that deploys it.

Step 2: Write the canary script

Create canary/todo.mjs. The structure follows the multi-step Playwright sample from the AWS canary samples: launch a browser through the synthetics helper, wrap each part of the journey in executeStep, and always close the browser at the end.

import { synthetics } from '@aws/synthetics-playwright';

const APP_URL = 'https://demo.playwright.dev/todomvc/';

export async function handler(event, context) {
  try {
    const browser = await synthetics.launch();
    const browserContext = await browser.newContext();
    const page = await synthetics.getPage(browserContext);

    // Step 1: the app loads at all
    await synthetics.executeStep('open-app', async () => {
      await page.goto(APP_URL, { waitUntil: 'load' });
    });

    // Step 2: a user can add todos
    await synthetics.executeStep('add-todos', async () => {
      const input = page.getByPlaceholder('What needs to be done?');
      await input.fill('Feed the canary');
      await input.press('Enter');
      await input.fill('Check the alarms');
      await input.press('Enter');

      const count = await page.getByTestId('todo-item').count();
      if (count !== 2) {
        throw new Error(`Expected 2 todos, found ${count}`);
      }
    });

    // Step 3: a user can complete a todo
    await synthetics.executeStep('complete-todo', async () => {
      await page
        .getByTestId('todo-item')
        .first()
        .getByRole('checkbox', { name: 'Toggle Todo' })
        .check();
    });

    // Step 4: the app shows the right state back
    await synthetics.executeStep('verify-counter', async () => {
      const counter = (await page.getByTestId('todo-count').innerText()).trim();
      if (counter !== '1 item left') {
        throw new Error(`Expected "1 item left", got "${counter}"`);
      }
    });
  } finally {
    await synthetics.close();
  }
}

A few details worth calling out:

I picked the selectors by looking at the live app: the input’s placeholder is What needs to be done?, each todo is a data-testid="todo-item", the checkbox has the accessible name Toggle Todo, and the counter is data-testid="todo-count". Prefer selectors like these, test IDs and accessible roles, over CSS classes. They change far less often, and a canary that fails because someone renamed a class is a canary people learn to ignore.

Step 3: Define the canary, the alarm and the email

Replace the contents of lib/todo-canary-stack.ts:

import * as path from 'path';
import { Duration, Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as synthetics from 'aws-cdk-lib/aws-synthetics';
import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
import * as actions from 'aws-cdk-lib/aws-cloudwatch-actions';
import * as sns from 'aws-cdk-lib/aws-sns';
import * as subscriptions from 'aws-cdk-lib/aws-sns-subscriptions';

export class TodoCanaryStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const canary = new synthetics.Canary(this, 'TodoJourney', {
      canaryName: 'todo-journey',
      runtime: new synthetics.Runtime(
        'syn-nodejs-playwright-9.0',
        synthetics.RuntimeFamily.NODEJS
      ),
      test: synthetics.Test.custom({
        code: synthetics.Code.fromAsset(path.join(__dirname, '..', 'canary')),
        handler: 'todo.handler',
      }),
      schedule: synthetics.Schedule.rate(Duration.minutes(5)),
      timeout: Duration.minutes(1),
      maxRetries: 1,
      provisionedResourceCleanup: true,
    });

    const topic = new sns.Topic(this, 'CanaryAlerts');
    topic.addSubscription(
      new subscriptions.EmailSubscription('you@example.com')
    );

    const alarm = new cloudwatch.Alarm(this, 'TodoJourneyFailing', {
      metric: canary.metricSuccessPercent({ period: Duration.minutes(5) }),
      evaluationPeriods: 2,
      threshold: 90,
      comparisonOperator: cloudwatch.ComparisonOperator.LESS_THAN_THRESHOLD,
      treatMissingData: cloudwatch.TreatMissingData.BREACHING,
      alarmDescription: 'Users may not be able to add or complete todos',
    });
    alarm.addAlarmAction(new actions.SnsAction(topic));
  }
}

Replace you@example.com with your own address. Here’s what each piece is doing:

You didn’t have to create an S3 bucket or an IAM role. The Canary construct creates an artifacts bucket and an execution role for you. If you bring your own role, it needs a trust policy for lambda.amazonaws.com and permissions for s3:PutObject, s3:GetBucketLocation, s3:ListAllMyBuckets, cloudwatch:PutMetricData and the CloudWatch Logs actions (required permissions).

Step 4: Deploy

npx cdk deploy

Two things happen after it finishes:

  1. Check your inbox. SNS sends a subscription confirmation email, and you won’t get alerts until you click the link in it. Do it now, not during an incident.
  2. Open the Synthetics Canaries page in the CloudWatch console and find todo-journey. Within a few minutes the first run shows up.

Click into a run and you’ll see the four steps with a status and duration for each, the screenshots, the HAR file and the logs. This page is the best part of Synthetics, so take a look around while it’s green and boring.

Step 5: Break it on purpose

A monitor you’ve never seen fail is a monitor you don’t trust yet. So let’s break the “app”.

We can’t break the TodoMVC demo, but we can change what we expect from it. In canary/todo.mjs, change the expected counter text:

if (counter !== '2 items left') {

Deploy again with npx cdk deploy and wait. Here’s what you should see:

Now put the check back to '1 item left' and deploy. The canary goes green and the alarm returns to OK. You’ve just tested your alerting from end to end, which is more than a lot of production systems can say.

Step 6: Clean up

npx cdk destroy

Because we set provisionedResourceCleanup, the canary’s Lambda function and layers go with it. Check the S3 console for the artifacts bucket and the CloudWatch Logs console for /aws/lambda/cwsyn-todo-journey-* log groups, and delete anything you don’t want to keep.

What It Costs

The CloudWatch pricing page lists $0.0012 per canary run in its US East examples, with 100 canary runs per month in the free tier. Since you pay per run, the schedule is your main cost lever:

Schedule Runs per canary (31-day month) Canary run charges
Every hour 744 about $0.77
Every 5 minutes (our tutorial) 8,928 about $10.59
Every minute 44,640 about $53.57

The first two rows subtract the 100 free runs. The free tier is per account, so it only applies once no matter how many canaries you have.

That’s only the Synthetics part of the bill. Every run is also a Lambda invocation, writes logs, and puts objects in S3. AWS’s own worked example, five canaries running every five minutes with 20-second runs at 1 GB, adds about $14.89 of Lambda, $3.55 of CloudWatch Logs and $1.25 of S3 per month on top of the run charges. Long runs cost more, so a lean script is also a cheaper script.

Each retry is another Lambda invocation that writes more logs and artifacts, so a flaky canary that retries constantly costs more too. The honest guidance: run your most important journeys every one to five minutes, and everything else every 15 to 60 minutes.

Gotchas and Good Habits

1. Flaky canaries are worse than no canaries

If an alarm fires every Tuesday for no reason, your team learns to ignore it, and then it gets ignored on the day it matters.

Keep it stable:

Watch the retry metrics. When retries are on, Synthetics also publishes two extra metrics (metrics docs):

Metric What it tells you
RetryCount How many times the canary retried
SuccessPercentWithRetries The percentage of runs that succeed after all attempts

Put both on a dashboard. A rising retry count is an early warning that something is getting slower or less reliable, even while the journey still passes in the end. Once you’ve seen how they behave for your canary, decide which success metric your alarm should use.

2. Your canary is a real user, with real side effects

A canary that signs up, places an order or sends an email does it every five minutes, forever. So:

3. Never hard-code credentials

If the journey needs to log in, keep the credentials in AWS Secrets Manager and fetch them at runtime. The Multi checks blueprint does this for you, with Basic, API key, OAuth and SigV4 auth through Secrets Manager.

4. Private endpoints need networking

Canaries can run inside your VPC to test internal apps. To make that work (VPC docs):

5. Runtimes expire

AWS regularly deprecates old runtime versions.

6. The small print

Setting Default Tip
Artifact retention 31 days for both successful and failed runs Tune successRetentionPeriod and failureRetentionPeriod separately. Keeping failures longer than successes is a sensible default
Your own artifacts bucket n/a The bucket name can’t contain a period (.)

Synthetics vs. RUM: Do You Need Both?

CloudWatch also has Real User Monitoring (RUM), which collects performance and error data from real users’ browsers. They answer different questions:

RUM can only tell you something broke after real people have hit it. A canary can tell you before they do. Mature teams use both: canaries as the smoke detector, RUM as the full picture.

Quick FAQ

Key Takeaways

The miners didn’t keep a canary because they expected gas every day. They kept one because the day it mattered, they wanted to know before anyone got hurt. Your canary will spend most of its life passing, quietly, every five minutes. That’s not wasted money. That’s the sound of it singing.

References