Guide · CloudWatch Synthetics

CloudWatch Synthetics API Canary Scripts — Node.js handler, synthetics module, steps

A CloudWatch Synthetics canary script runs inside a managed Lambda environment and must export a handler function. The runtime provides two built-in modules: Synthetics (for making HTTP requests and reporting named steps) and SyntheticsLogger (for structured log output). The script package is a zip file uploaded to S3 and referenced in CreateCanaryCommand as Code.S3Bucket + Code.S3Key + Code.Handler. The key rule: the handler field must match the filename and export — if the zip contains api-check.js exporting handler, the Handler field is "api-check.handler". The zip structure must place scripts inside a nodejs/node_modules/ path prefix, not at the root.

TL;DR

Create a file api-check.js that exports handler. Use const synthetics = require('Synthetics'); (built-in — do not npm install). Call synthetics.executeStep('stepName', async () => { /* HTTP check + assertions */ }). Package as a zip with the file at nodejs/node_modules/api-check.js (the nodejs/node_modules/ path is required). Upload the zip to S3. Reference it in CreateCanaryCommand as Code: { S3Bucket, S3Key, Handler: 'api-check.handler' }.

Canary script structure

All canary scripts follow the same pattern: a handler async function that calls synthetics.executeStep() for each check. Steps are named, appear individually in the CloudWatch console, and each step failure marks the entire canary run as failed.

// api-check.js
// This runs inside the CloudWatch Synthetics Lambda environment.
// 'Synthetics' and 'SyntheticsLogger' are built-in — do not npm install them.
const synthetics = require('Synthetics');
const log = require('SyntheticsLogger');
const https = require('https');

exports.handler = async () => {
  // Each executeStep call creates a named step in the run report.
  // If the step function throws, the step is marked FAILED and the run fails.
  await synthetics.executeStep('check-health-endpoint', async () => {
    const response = await httpsGet('https://api.example.com/health');

    log.info('Health check response', { statusCode: response.statusCode, body: response.body });

    // Assert expected status code
    if (response.statusCode !== 200) {
      throw new Error(`Expected 200, got ${response.statusCode}. Body: ${response.body}`);
    }

    // Assert response body contains expected field
    const json = JSON.parse(response.body);
    if (json.status !== 'ok') {
      throw new Error(`Expected status:'ok', got: ${JSON.stringify(json)}`);
    }
  });

  // Second step: check a specific API endpoint
  await synthetics.executeStep('check-api-response', async () => {
    const response = await httpsGet('https://api.example.com/v1/ping');

    if (response.statusCode >= 500) {
      throw new Error(`Server error on /v1/ping: ${response.statusCode}`);
    }

    // Check response latency via elapsed time (measure manually)
    // Synthetics records step duration automatically in the run report
  });
};

// Minimal https.get wrapper returning { statusCode, body }
function httpsGet(url) {
  return new Promise((resolve, reject) => {
    const req = https.get(url, (res) => {
      let body = '';
      res.on('data', chunk => { body += chunk; });
      res.on('end', () => resolve({ statusCode: res.statusCode, body }));
    });
    req.on('error', reject);
    req.setTimeout(10000, () => {
      req.destroy();
      reject(new Error('Request timeout after 10s'));
    });
  });
}

The built-in Synthetics module uses CommonJS (require), not ESM (import). Even if you use ESM for the rest of your project, the canary script must use CommonJS — the Synthetics runtime does not support ES module syntax.

Using synthetics.makeHttpStep for simpler checks

For basic HTTP health checks, synthetics.makeHttpStep is a higher-level helper that handles the HTTP request and standard assertions in one call. It is part of the syn-nodejs-puppeteer-9.x runtime API.

const synthetics = require('Synthetics');

exports.handler = async () => {
  // makeHttpStep handles request + status code assertion automatically
  // Fails the step if the HTTP response status is 4xx or 5xx
  const stepConfig = {
    includeRequestHeaders: true,
    includeResponseHeaders: true,
    includeRequestBody: false,
    includeResponseBody: true,
    restrictedHeaders: ['Authorization', 'x-api-key'],  // redacted in artifacts
    continueOnStepFailure: false,
  };

  await synthetics.executeHttpStep(
    'verify-api-health',               // step name
    {
      hostname: 'api.example.com',
      method: 'GET',
      path: '/health',
      port: 443,
      protocol: 'https:',
      headers: {
        'Accept': 'application/json',
        // Add auth headers here — they will be redacted in artifacts per restrictedHeaders
        'x-api-key': process.env.API_KEY ?? '',
      },
    },
    null,  // no request body for GET
    stepConfig,
  );
};

executeHttpStep automatically asserts a 2xx status code. For custom assertions (e.g., response body content, specific headers), use the manual executeStep + https.get pattern and throw on assertion failure.

Reading environment variables in canary scripts

Environment variables for canaries are set via RunConfig.EnvironmentVariables in CreateCanaryCommand. Access them in the script with process.env — the same as any Lambda function.

// In CreateCanaryCommand — set env vars for the canary
const response = await synthetics.send(new CreateCanaryCommand({
  Name: 'my-api-canary',
  RunConfig: {
    TimeoutInSeconds: 60,
    EnvironmentVariables: {
      TARGET_URL: 'https://api.example.com',
      EXPECTED_VERSION: 'v2.1.0',
      // Note: for secrets, use SSM Parameter Store or Secrets Manager
      // and fetch from the canary script using the AWS SDK — do not hardcode secrets
    },
  },
  // ... other required fields
}));
// In the canary script — read env vars
const synthetics = require('Synthetics');
const https = require('https');

const TARGET_URL = process.env.TARGET_URL ?? 'https://api.example.com';
const EXPECTED_VERSION = process.env.EXPECTED_VERSION;

exports.handler = async () => {
  await synthetics.executeStep('check-version', async () => {
    const response = await httpsGet(`${TARGET_URL}/version`);
    const json = JSON.parse(response.body);
    if (EXPECTED_VERSION && json.version !== EXPECTED_VERSION) {
      throw new Error(`Version mismatch: expected ${EXPECTED_VERSION}, got ${json.version}`);
    }
  });
};

Avoid putting secrets directly in EnvironmentVariables — they appear in the canary configuration and CloudWatch console. Use the AWS SDK inside the canary script to fetch secrets from Secrets Manager or SSM Parameter Store at runtime (add the appropriate permissions to the execution role).

Using npm packages in canary scripts

The canary environment does not have internet access from the Lambda function by default. All npm dependencies must be bundled in the zip file. Place them in the nodejs/node_modules/ directory inside the zip.

# Build the canary package
mkdir -p canary-package/nodejs/node_modules

# Copy the script
cp api-check.js canary-package/nodejs/node_modules/

# Install dependencies into the nodejs/node_modules directory
cd canary-package/nodejs
npm install --prefix . got@12  # example: install got for HTTP requests

# Create the zip (the root of the zip must be the 'nodejs' directory)
cd ..
zip -r ../api-check.zip nodejs/

# Upload to S3
aws s3 cp ../api-check.zip s3://my-canary-scripts/api-check.zip

# In CreateCanaryCommand:
# Code: { S3Bucket: 'my-canary-scripts', S3Key: 'api-check.zip', Handler: 'api-check.handler' }

The nodejs/node_modules/ path prefix is required by the Synthetics runtime. The Handler field resolves relative to nodejs/node_modules/ — so "api-check.handler" maps to nodejs/node_modules/api-check.js exporting handler. A common mistake is placing the script at the zip root instead of inside nodejs/node_modules/, which causes a module-not-found error at canary run time.

Uploading canary scripts with the AWS SDK from your MCP server

To deploy a new canary script from an MCP tool, zip the script in memory, upload it to S3 with PutObjectCommand, and then call CreateCanaryCommand or UpdateCanaryCommand referencing the new S3 key.

import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { SyntheticsClient, CreateCanaryCommand } from '@aws-sdk/client-synthetics';
import archiver from 'archiver';
import { Writable } from 'stream';

const s3 = new S3Client({ region: process.env.AWS_REGION ?? 'us-east-1' });
const synthetics = new SyntheticsClient({ region: process.env.AWS_REGION ?? 'us-east-1' });

export async function deployCanary(params: {
  canaryName: string;
  scriptContent: string;    // the canary script source code
  scriptFilename: string;   // e.g. 'api-check.js'
  targetUrl: string;
  executionRoleArn: string;
  artifactBucket: string;
  scriptBucket: string;
}): Promise<string> {
  // 1. Zip the script into the required nodejs/node_modules/ structure
  const zipBuffer = await zipCanaryScript(params.scriptContent, params.scriptFilename);

  // 2. Upload zip to S3
  const s3Key = `canaries/${params.canaryName}/${Date.now()}.zip`;
  await s3.send(new PutObjectCommand({
    Bucket: params.scriptBucket,
    Key: s3Key,
    Body: zipBuffer,
    ContentType: 'application/zip',
  }));

  // 3. Create the canary referencing the uploaded script
  const response = await synthetics.send(new CreateCanaryCommand({
    Name: params.canaryName,
    Code: {
      S3Bucket: params.scriptBucket,
      S3Key: s3Key,
      Handler: `${params.scriptFilename.replace('.js', '')}.handler`,
    },
    ArtifactS3Location: `s3://${params.artifactBucket}/canary-artifacts/${params.canaryName}/`,
    ExecutionRoleArn: params.executionRoleArn,
    RuntimeVersion: 'syn-nodejs-puppeteer-9.1',
    Schedule: { Expression: 'rate(5 minutes)' },
    RunConfig: {
      TimeoutInSeconds: 60,
      EnvironmentVariables: { TARGET_URL: params.targetUrl },
    },
    SuccessRetentionPeriodInDays: 14,
    FailureRetentionPeriodInDays: 30,
  }));

  return response.Canary?.Arn ?? '';
}

async function zipCanaryScript(content: string, filename: string): Promise<Buffer> {
  return new Promise((resolve, reject) => {
    const chunks: Buffer[] = [];
    const archive = archiver('zip');
    archive.on('data', chunk => chunks.push(chunk));
    archive.on('end', () => resolve(Buffer.concat(chunks)));
    archive.on('error', reject);

    // The runtime requires the script at nodejs/node_modules/<filename>
    archive.append(content, { name: `nodejs/node_modules/${filename}` });
    archive.finalize();
  });
}

Runtime version reference

Select the runtime version based on your Node.js target and testing needs. API canaries typically use the syn-nodejs-puppeteer-9.x series; browser UI canaries require a Puppeteer-enabled runtime at the same series.

Runtime versionNode.jsPuppeteerStatus
syn-nodejs-puppeteer-9.118.x21.xCurrent (recommended)
syn-nodejs-puppeteer-8.018.x19.xSupported
syn-nodejs-puppeteer-7.018.x19.xSupported
syn-nodejs-puppeteer-6.216.x19.xDeprecated — migrate to 9.x
syn-python-selenium-4.0——Python 3.8 + Selenium 4 (browser only)

AWS periodically deprecates older runtime versions. When a runtime is deprecated, existing canaries stop running and must be updated via UpdateCanaryCommand to a supported version. Check the AWS documentation for the current deprecation schedule. For pure API health checks with no browser automation, any syn-nodejs-puppeteer-* runtime works — the Puppeteer dependency is present but unused.

Debugging failed canary runs

When a canary run fails, the artifacts in the configured S3 bucket contain the execution logs. The standard artifact structure is:

s3://my-canary-artifacts/canary-artifacts/my-canary/
  YYYY/MM/DD/HH/MM/SS/              # timestamp of the run
    api-check/                      # step name directory
      log.json                      # structured log from SyntheticsLogger
      result.json                   # step outcome, duration, error message
    canary-run.log                  # full canary run log
    canary-take-screenshot-1.png    # (browser canaries only)
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';

export async function getCanaryRunLog(
  artifactBucket: string,
  canaryName: string,
  runTimestamp: string,  // e.g. '2026/11/06/10/30/00'
): Promise<string> {
  const s3 = new S3Client({ region: process.env.AWS_REGION ?? 'us-east-1' });
  const key = `canary-artifacts/${canaryName}/${runTimestamp}/canary-run.log`;

  const response = await s3.send(new GetObjectCommand({
    Bucket: artifactBucket,
    Key: key,
  }));

  return response.Body?.transformToString() ?? '';
}

The result.json file in each step directory contains the structured outcome. The log.json file contains everything logged via SyntheticsLogger. Logs are also streamed to CloudWatch Logs under the /aws/lambda/cwsyn-{canaryName}-* log group.

Related pages