Overview
Synthetic monitoring is "a monitoring technique that is done by using a simulation or scripted recordings of transactions" (synthetic-mon-wiki (opens in new window)); the scripts "run continuously at set intervals to measure performance metrics like functionality, availability, and response time - without requiring actual traffic." Per the ISTQB Glossary V4.7.1, shift right is "a test approach to test a system continuously in production," and synthetic monitors are its load-bearing primitive. This skill builds the configuration: which journey, how often, what to assert, when to page.
When to use
If real-user traffic is high and well-instrumented, real-user monitoring (RUM) is the complement.
Step 1 - Pick the journey
Synthetic monitors should target the highest-business-value journey the team would page on at 3am if it broke. Examples:
Target commonly used paths and critical business processes. Don't monitor every flow - pick the 3-5 hero flows that map to the team's SLOs.
Which journeys to monitor - the RUM-coverage gap method
Synthetic monitors verify journeys the team chose to script; Real User Monitoring records journeys users actually take. The gap between the two sets is where production breakage goes undetected: a journey with 40 k sessions per day but no monitor can fail silently for hours. When RUM is instrumented, derive the journey list from it instead of gut feel:
Hard-reject rule: no RUM source, no gap analysis. If neither Datadog RUM, Sentry Performance, nor CrUX data exists for the target, halt and say so. Do not estimate journey volume from a sitemap - it contains every URL, not the ones users visit, and produces a monitor list biased by developer assumptions. Two data caveats: CrUX only captures publicly discoverable pages (use Datadog RUM or Sentry for post-login journeys), and Datadog RUM session retention is 30 days, so pick a representative date range.
Step 2 - Pick the platform
| Platform | Notes |
|---|---|
| Datadog Synthetics | Named provider. Browser + API. Good for teams already on Datadog APM. |
| Checkly | Playwright-native browser checks; API checks; CI-as-code via checkly CLI. |
| Pingdom | Mature; well-known; uptime + transaction. |
| New Relic Synthetics | Synthetics-as-Code via JS scripts. |
| AWS CloudWatch Synthetics | Selenium-based; fits AWS-native stacks. |
| Smokescreen (open-source) | Self-hosted; for compliance-restricted environments. |
| F5 Distributed Cloud Synthetic | Named provider; browser + API. |
The platform decision typically follows the existing observability stack (Datadog APM → Datadog Synthetics; New Relic → New Relic Synthetics).
Step 3 - Author the script (browser check)
For browser checks, Playwright-style is the de-facto standard (Checkly natively, Datadog Synthetics increasingly). Drive the journey step by step with accessibility-first locators, then assert a confirmation state:
// monitors/checkout-journey.spec.ts (Checkly-style, excerpt)
import { test, expect } from '@playwright/test';
test('checkout journey - happy path', async ({ page }) => {
await page.goto('https://example.com/');
await page.getByRole('textbox', { name: 'Search' }).fill('BOOK-001');
// ...search, add to cart, sign in with a synthetic account,
// place order with a test-mode card...
await expect(page.getByRole('heading', { name: /Order confirmed/i })).toBeVisible();
});Full browser and API templates: references/monitor-templates.md.
Use accessibility-first locators (not CSS classes); synthetic monitors that depend on CSS classes break on every UI refactor.
Critical: synthetic monitors hit production with real APIs. Use dedicated synthetic test accounts (not real customer data) and test-mode payment processors so the script doesn't trigger real charges / orders.
Step 4 - Author the script (API check)
For API checks, HTTP-step format chains requests and asserts on each step - status code, response shape, and response time:
# monitors/api-orders-flow.yml (Checkly-style, excerpt)
- name: 2. List orders
method: GET
url: https://api.example.com/orders
headers: { Authorization: "Bearer {{TOKEN}}" }
assertions:
- { source: STATUS_CODE, comparison: EQUALS, target: 200 }
- { source: RESPONSE_TIME, comparison: LESS_THAN, target: 500 }
- { source: JSON_BODY, property: $.orders, comparison: IS_ARRAY }Full multi-step auth + list + fetch template: references/monitor-templates.md.
Per-step assertions distinguish "the API returned" from "the API returned the right thing" - distinguish status code, response shape, and response time.
Step 5 - Cadence
Default: 5 min - matches most user journeys and fits within a 99.9% uptime SLO budget (5-min monitor with 2-failure alert rule gives ~10 min to detection, well within ~9 hours/year of allowed downtime). Use 1 min for the highest-criticality flows (auth, payment, primary read) or when the SLO is 99.99%+. Use 15 min for expensive E2E browser checks. Use 1 hour for transactions that have side effects. Use daily for compliance / audit verification flows.
| Cadence | Use |
|---|---|
| 1 min | Highest-criticality flows (auth, payment, primary read). |
| 5 min | Most user journeys (default). |
| 15 min | Lower-priority or expensive (full E2E browser checks). |
| 1 hour | Synthetic transactions that have side effects (only as a sanity check). |
| Daily | Compliance / audit verification flows. |
Match the cadence to the SLO.
Step 6 - Alert thresholds
A single failure isn't an alert; a single failure is noise. Pattern:
# Alert config (Checkly-style)
alerts:
channels:
- id: pagerduty-checkout
filters:
steps: [4, 5] # only checkout/confirmation steps
- id: slack-eng
filters:
consecutiveFailures: 1 # any failure → Slack notify
escalation:
runBased: true
consecutiveFailures: 2
cooldownPeriod: 1hStep 7 - Locations
Run from multiple geographic regions (3-5 minimum):
Response time varies dramatically by region; multi-region monitoring catches CDN / DNS / TLS issues that single-region misses.
Step 8 - As-code lifecycle
Treat monitors as code:
monitors/
├── checkout-journey.spec.ts # browser check
├── api-orders-flow.yml # API check
├── auth-flow.spec.ts
├── checkly.config.ts # global config
└── README.mdCI pipeline (Checkly example):
- run: npm ci
- run: npx checkly test --reporter ci # smoke check before deploy
- run: npx checkly deploy --force # push the configsVersioning the monitors in git means: PR review on changes, rollback if a monitor becomes flaky after a change, audit trail for why a monitor was added / removed.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Real customer data in synthetic monitors | PII leakage; real charges; data corruption. | Dedicated synthetic test accounts (Step 3). |
| Production payments triggered by monitors | Real charges every minute add up; refunds are a nightmare. | Test-mode payment processor in production (Step 3). |
| Single-region monitoring | CDN / DNS / TLS / regional issues invisible. | 3-5 regions (Step 7). |
| Page on first failure | Flake = page; on-call burnout. | N consecutive failures (Step 6). |
| Single one-step alert for the whole journey | "Checkout failed" - but where? Triage takes longer than fix. | Per-step alerts (Step 6). |
| Brittle CSS-class selectors in browser checks | Monitor breaks on every UI refactor; team disables. | Accessibility-first locators (Step 3). |
Monitor that asserts only status_code = 200 | "200 OK" with empty body / wrong shape passes; bug ships. | Assert response shape too (Step 4). |
| One-hour cadence on a 99.99% SLO | SLO breach detected after the budget is gone. | Cadence matches SLO (Step 5 table). |
Limitations
References
The complete browser-check and API-check scripts the skill's Step 3 and Step 4 sketches are drawn from. Both are Checkly-style; adapt the syntax per platform.
Browser check (Playwright-style)
// monitors/checkout-journey.spec.ts (Checkly-style)
import { test, expect } from '@playwright/test';
test('checkout journey - happy path', async ({ page }) => {
// 1. Land on home page
await page.goto('https://example.com/');
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
// 2. Search and add to cart
await page.getByRole('textbox', { name: 'Search' }).fill('BOOK-001');
await page.getByRole('button', { name: 'Search' }).click();
await page.getByRole('link', { name: 'BOOK-001' }).click();
await page.getByRole('button', { name: 'Add to cart' }).click();
// 3. Complete checkout (with synthetic test account)
await page.getByRole('link', { name: 'Cart' }).click();
await page.getByRole('button', { name: 'Checkout' }).click();
// (Use a dedicated synthetic-test account; never user real customer data)
await page.getByLabel('Email').fill(process.env.SYNTHETIC_USER_EMAIL!);
await page.getByLabel('Password').fill(process.env.SYNTHETIC_USER_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
// 4. Place order with Stripe test card (in test mode in production!)
await page.getByLabel('Card number').fill('4242 4242 4242 4242');
await page.getByRole('button', { name: 'Place order' }).click();
// 5. Assert confirmation
await expect(page.getByRole('heading', { name: /Order confirmed/i })).toBeVisible();
});Use accessibility-first locators (not CSS classes); synthetic monitors that depend on CSS classes break on every UI refactor. Use dedicated synthetic test accounts and test-mode payment processors so the script doesn't trigger real charges or orders.
API check (HTTP-step)
# monitors/api-orders-flow.yml (Checkly-style; adapt per platform)
name: orders API journey
runtimeId: 2024.02
type: API
request:
- name: 1. Get auth token
method: POST
url: https://api.example.com/auth/token
headers:
Content-Type: application/json
body: |
{"email": "{{SYNTHETIC_USER_EMAIL}}", "password": "{{SYNTHETIC_USER_PASSWORD}}"}
assertions:
- source: STATUS_CODE
comparison: EQUALS
target: 200
- source: JSON_BODY
property: $.access_token
comparison: NOT_EMPTY
setup: |
// Save token for next request
vars.set('TOKEN', response.body.access_token);
- name: 2. List orders
method: GET
url: https://api.example.com/orders
headers:
Authorization: Bearer {{TOKEN}}
assertions:
- source: STATUS_CODE
comparison: EQUALS
target: 200
- source: RESPONSE_TIME
comparison: LESS_THAN
target: 500 # ms
- source: JSON_BODY
property: $.orders
comparison: IS_ARRAY
- name: 3. Get specific order
method: GET
url: https://api.example.com/orders/{{TEST_ORDER_ID}}
headers:
Authorization: Bearer {{TOKEN}}
assertions:
- source: STATUS_CODE
comparison: EQUALS
target: 200
- source: JSON_SCHEMA
target: schemas/order.jsonPer-step assertions distinguish "the API returned" from "the API returned the right thing" - assert status code, response shape, and response time.
Per-source instructions for pulling the top-N journey inventory for the RUM-coverage gap method in synthetic-monitor-author. Aim for the top 50 view paths (or transaction names) by session volume to avoid chasing long-tail pages with negligible traffic.
Datadog RUM
In the RUM Explorer (https://app.datadoghq.com/rum/explorer):
Query syntax shorthand: @view.url_path:* | count by @view.url_path | sort desc. RUM Explorer supports key:value pairs where custom attributes require a created facet first (Datadog RUM Search (opens in new window)).
Sentry Performance
Open the Performance module and use the Trace Explorer to slice by transaction name. Per Sentry Transaction Summary docs (opens in new window), the platform surfaces throughput as TPM (transactions per minute) and TPS (transactions per second) per named transaction. Sort by Total throughput to surface highest-volume journeys. Export the table.
GA4 + CrUX (public-facing sites)
For public pages, the Chrome User Experience Report provides origin-level and URL-level field data. Per CrUX methodology (opens in new window), pages must be publicly discoverable (HTTP 200, no noindex) and meet a minimum visitor threshold for statistical confidence; exact threshold is undisclosed. Access via:
Per web.dev Core Web Vitals (opens in new window), the three stable metrics are:
All thresholds apply at the 75th percentile of page loads (web.dev CWV (opens in new window)). CrUX field data is "the Google dataset of the Web Vitals program" (CrUX docs (opens in new window)).