# Browser checks

Source: https://www.pingtower.com/docs/service/browser-checks/

A Playwright test that walks through your site in a real Chromium, run on a schedule from Pingtower's browser runners, each run in its own gVisor sandbox. Each step is reported up or down with its time, and a failed run keeps its screenshot and video, plus its trace when the check has no environment variables.

A browser check is a Playwright Test file. Pingtower runs it in headless Chromium against a start URL, on the check's interval, from the regions you pick. Each top-level `test.step` is reported as a step with its status and duration, and the run records the page's Core Web Vitals. A failed run keeps a screenshot and a video for a week, and a Playwright trace too when the check has no [environment variables](#environment-variables).

It is a check like any other, with the addon `browsercheck`: the same [quorum, confirmation and `alert_if`](/docs/service/checks/#regions-and-quorum), and results that arrive as events for your [rules](/docs/service/rules/).

## The script

```ts
import { test, expect } from "@playwright/test";

test("checkout", async ({ page }) => {
  await test.step("Open the store", async () => {
    await page.goto("/");
    await expect(page.getByRole("heading", { name: "Acme Store" })).toBeVisible();
  });

  await test.step("Sign in", async () => {
    await page.getByRole("link", { name: "Sign in" }).click();
    await page.getByLabel("Email").fill(process.env.SHOP_USER);
    await page.getByLabel("Password").fill(process.env.SHOP_PASSWORD);
    await page.getByRole("button", { name: "Sign in" }).click();
    await expect(page.getByText("Your account")).toBeVisible();
  });

  await test.step("Add to cart", async () => {
    await page.goto("/products/blue-mug");
    await page.getByRole("button", { name: "Add to cart" }).click();
    await expect(page.getByTestId("cart-count")).toHaveText("1");
  });
});
```

- Import `test` and `expect` from `@playwright/test`. Playwright is pinned at **1.63.0**, with the Chromium build that ships with it.
- The check's `url` is the test's `baseURL`, so `page.goto("/")` opens the start URL and other paths resolve against it.
- The file runs as a TypeScript test file, in one worker, with no retries. A file may hold more than one test; they run in order.
- Chromium runs headless, and HTTPS errors are not ignored: a page with a bad certificate fails its step.
- Environment variables you set on the check are in `process.env`.

### Steps

| In the script | Reported as |
| --- | --- |
| A top-level `test.step` | One step, **up** or **down** with its error. A nested step is part of its parent. |
| `test.step.skip` | A **skipped** step. |
| A test with no steps | One step named for the test. |
| A test that fails outside its steps, such as an `expect` after them | One more **down** step named for the test. |
| A file that doesn't compile or holds no test | One **down** step named `script`. |

A step's name is cut to 128 bytes and its error to 512 bytes, the first line of Playwright's message. The values of the check's [environment variables](#environment-variables) are replaced with `[redacted]` in both. At most `max_steps` steps are kept, 20 by default and at most. A failure past the cap still makes the run **down**.

A run where every step passes is **up**: `3 steps passed in 4.2s`. Any failed step makes it **down** and names the first one: `step "Sign in" failed: …`. Running out of time, memory or [scratch space](#scratch-space) is **down** too. If the runner could not run the script at all, for example because the sandbox would not start, the run is a check error tagged `status:error`. That says nothing about your site and never votes toward an alert.

## Parameters

| Parameter | Default | Meaning |
| --- | --- | --- |
| `url`<span class="req">REQUIRED</span> | | Absolute http or https URL the script starts at. |
| `script`<span class="req">REQUIRED</span> | | The Playwright test file, up to 256 KiB. |
| `max_steps` | `20` | Most steps reported, 1 to 20. |

| Check field | Limit |
| --- | --- |
| `interval_seconds` | At least 300 on every plan, at most 86,400. |
| `timeout_seconds` | Defaults to 60, at most 120, and below the interval. The test itself stops a little before the timeout, so its video, and its trace if it keeps one, are still written. |
| `env` | Up to 50 variables, 32 KiB in all. See [Environment variables](#environment-variables). |

A browser check weighs as much as twenty HTTP checks against your plan's [check capacity](/docs/service/checks/#intervals). On Free, that means an interval of at least 400 seconds from one region.

Every save that changes the script stores a new version. The last 50 versions are kept, and each run records which version it ran.

```sh
curl -s https://api.pingtower.com/v1/projects/web/checks \
  -H "Authorization: Bearer ptk_…" \
  -d '{
    "name": "checkout",
    "addon": "browsercheck",
    "with": {"url": "https://shop.acme.example", "script": "import { test, expect } from \"@playwright/test\";\n…"},
    "interval_seconds": 600,
    "sources": ["<region>", "<region>"],
    "env": {"SHOP_USER": "probe@acme.example", "SHOP_PASSWORD": "…"},
    "alert_if": {}
  }'
```

Browser checks run on their own regions. `GET /v1/regions?pool=browser` lists them, and a check naming a region without browser runners is refused with `source "<region>": no browser runners in this region`.

## Environment variables

Use them for credentials and anything else the script shouldn't hold. They are sealed at rest and write-only: reading the check back lists their names in `env_keys`, never their values. Leaving `env` out of an update keeps them, and `{}` clears them. An update that changes `url` in any way, even only the path, or adds a region or source to `sources`, must send `env` again or is refused with a `422`, such as `env: sources now include <names> the env was not sealed for; re-supply env (or send {} to clear it)`. Removing or reordering sources keeps them. Minting a new agent token for a source stops the check running from that source until you send `env` again: see [Secrets](/docs/service/checks/#a-new-agent-token-withholds-secrets). They reach the sandbox's Node process as its environment and nowhere else.

A value that turns up in a run's results is shown as `[redacted]`: in step names, step errors, the run's error and the event's `step.N.*` keys. It is caught in each of these forms:

- as written, or JSON-escaped;
- URL-encoded, as `encodeURIComponent` or `URLSearchParams` writes it or as the browser spells it in a URL, in all upper- or all lower-case hex, such as a token in a failed `page.goto` URL;
- base64-encoded, standard or URL-safe, padded or not, also inside a longer encoded text such as a `Basic` authorization header, where a character or two shared with the text around it can remain;
- HTML-escaped.

The same goes for the error of a run whose sandbox did not start. A value under 4 bytes is left as it is, and so is a value your script changes some other way: cut short, reversed, split, upper- or lower-cased as a whole, encoded twice, or run through some other encoding, such as a hex dump.

A run made while the check has environment variables keeps no trace, and its screenshot and video need `checks:write` to open. See [Screenshot, video and trace](#screenshot-video-and-trace).

- A name matches `^[A-Z_][A-Z0-9_]{0,63}$`. A value is at most 4 KiB and may not begin with `enc:`.
- Reserved, whatever the case: `PATH`, `HOME`, `TMPDIR`, `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`, `ALL_PROXY`, and any name starting with `NODE_`, `PLAYWRIGHT_`, `PW_`, `BROWSERCHECK_` or `LD_`. These belong to the sandbox, and setting one would break the run or route it around the network guard.

## The sandbox

Each run gets a fresh [gVisor](https://gvisor.dev/) sandbox on a runner box kept for browser checks alone, separate from the servers that run every other check.

- A read-only root image holds Node, Playwright and Chromium at pinned versions. The only writable space is that run's own scratch directory, `/work`.
- The script runs as an unprivileged user with no capabilities.
- Each sandbox has 1 vCPU and 1.5 GiB of memory. Its `/tmp` and `/dev/shm` are 256 MiB each and count against that memory.
- `/work` holds up to 512 MiB and 32,768 files. It is space of its own and doesn't count against the memory. See [Scratch space](#scratch-space).
- The sandbox has no network of its own. Its only way out is a proxy that dials through the same guard as every other check, so the browser can't reach a private, loopback, link-local or metadata address. It reaches only ports 80 and 443, plus the start URL's own port on the start URL's host when that port is a web port, within per-run limits. See [Network](#network).
- When the run ends, the sandbox is deleted and its scratch directory unmounted and removed.

### Scratch space

Playwright writes the run's test results into `/work` while it runs, its video and trace among them, and a failed run's screenshot, video and trace, kept from there, can take up to 105 MiB. Whatever else the script saves there, such as a download or a screenshot of its own, shares the same 512 MiB and 32,768 files.

A run that fills either is **down**, even if the script catches the write error and carries on. The message is:

```
script exceeded the scratch space limit of /work (512 MiB)
```

It is put in front of the first failed step's error, as `script exceeded the scratch space limit of /work (512 MiB); ` followed by Playwright's message, or in front of the run's error when no step failed. When the script was stopped before it finished, it is the run's whole error.

To stay under it, don't keep large files in `/work`: check a download's size or contents and delete it, and prefer a screenshot of an element to a full-page one. Don't write files in bulk. Moving writes to `/tmp` doesn't help, since it holds only 256 MiB and counts against the sandbox's memory.

### Network

Everything the browser and the script send leaves through the sandbox's proxy, and the proxy lets through only:

- **Ports 80 and 443 on any public host, and the start URL's own port on its host.** The start URL's port is the one it names, or its scheme's default when it names none. Its host is compared as written, ignoring case, so another name for the same address doesn't count. With a start URL of `https://shop.example.com:8443/`, the run can reach `shop.example.com:8443`, but not `other.example.com:8443`. The start URL's own port opens only when it is 1024 or above and isn't a port that other services use. These ports are never opened, even when the start URL names them: every port below 1024 except 80 and 443, and 1433, 1521, 1719, 1720, 1723, 2049, 2375, 2376, 3306, 3389, 3659, 4045, 4190, 5060, 5061, 5432, 5672, 5900, 6000, 6379, 6566, 6665 to 6669, 6679, 6697, 9042, 9200, 9300, 10080, 11211 and 27017. A check whose start URL names one of them, such as `http://mail.example.com:25/`, still saves and runs, but its first navigation fails with a port refusal. WebSockets (`ws://` and `wss://`) follow the same rule. A request to any other port is refused: an HTTPS or WebSocket connection gets a `403` from the proxy, and a plain `http://` request is hung up without a response. Neither reaches the network.
- **32 connections open at once, and 256 per run.** Each HTTPS or WebSocket connection the proxy tries counts, even one that then fails to connect. One past either limit is refused with a `403`. A connection that closes frees a place under the first limit, but not under the second.
- **256 MiB of traffic per run**, in both directions together. This counts every byte of an HTTPS or WebSocket connection, and the bodies of plain `http://` requests and responses. Once a run crosses it, its open connections close and the proxy refuses new ones.

To the browser a refusal looks like a network error. So that it doesn't read as an outage at your site, a run with refusals says how many there were and why:

```
the egress guard refused 5 request(s) (3 to a port other than 80, 443 or the check URL's own port on its host, which opens only from 1024 up and never to a non-web service port; 1 over the run's limit of 32 open or 256 total tunnels; 1 at the run's 256 MiB traffic limit)
```

It lists only the reasons that occurred, in this order:

| Reason | Means |
| --- | --- |
| `K to a private, loopback, link-local or metadata address` | The host resolved to an address that hosted checks may not reach. See [Troubleshooting](/docs/help/troubleshooting/) for the ranges. |
| `K to a port other than 80, 443 or the check URL's own port on its host, which opens only from 1024 up and never to a non-web service port` | The port isn't open to that host. This includes the start URL's own port when it is one of the ports listed under the first limit above. |
| `K over the run's limit of 32 open or 256 total tunnels` | An HTTPS or WebSocket connection past either connection limit. |
| `K at the run's 256 MiB traffic limit` | 1 for crossing the limit, plus 1 for each request refused after it. The connections it closes aren't counted again. |

The note goes in front of the first failed step's error, followed by `; `, or becomes the run's error when no step failed and the run has no error of its own. A check whose page loads from a third port, such as an API on `api.example.com:8080` behind a start URL on 443, fails here and needs that API on 80 or 443.

## Results

Each run records its steps (name, status, time and error), its total time, the script version, and three Core Web Vitals:

| Vital | Meaning |
| --- | --- |
| LCP | Largest Contentful Paint, in milliseconds. |
| CLS | Cumulative Layout Shift. A page that never shifts is 0. |
| INP | Interaction to Next Paint, in milliseconds. Only measured when the script interacts with the page. |

Each is the worst value over the pages the test opened. One that couldn't be measured is empty. Runs are kept for 30 days. The vitals are on the run, not on the event, so a rule can't threshold them.

The event carries these keys:

| Key | Meaning |
| --- | --- |
| `steps` | Steps reported |
| `steps_failed` | Steps that were **down** |
| `total_ms` | The whole run, in milliseconds |
| `step.N.name`, `step.N.status`, `step.N.ms` | Step N, numbered from 1 |
| `step.N.error` | Step N's error, when it failed |

```json
{"name":"checkout-slow","match":{"tags":["check:checkout"]},
 "conditions":[{"key":"total_ms","op":"gte","value":20000}]}
```

### Screenshot, video and trace

A failed run keeps the first failed test's artifacts:

| Artifact | Format | Up to |
| --- | --- | --- |
| Screenshot | PNG | 5 MiB |
| Video | WebM | 50 MiB |
| Trace | Playwright trace, `.zip` | 50 MiB, and only for a run made while the check had no environment variables |

They stay on the runner box that ran the check and are fetched through Pingtower when you open one. They are kept for 7 days, or less if that box's artifact space fills, when the oldest go first. While the box is offline they can't be fetched. Open a trace in [Playwright's trace viewer](https://trace.playwright.dev/). A passing run keeps none.

A trace records every value the script typed and every request it sent, so a run made while the check has environment variables keeps no trace. Its screenshot and video can still show what was typed into the page, so opening them needs `checks:write`, which members, admins and owners have, as does a [custom role](/docs/getting-started/concepts/#custom-roles) given it. For a member [limited to some projects](/docs/getting-started/concepts/#per-project-roles), it is their grant role on the check's project that needs it. A viewer or responder gets a `403` `this check has sealed env, so its runs' artifacts need checks:write`, with `reason` `artifacts_need_checks_write`. That stays true for those runs after the variables are cleared, and while a check has environment variables it applies to all of its runs. Anyone with `checks:read` can open the artifacts of other runs.

### Run now

The web app can run a browser check's saved script once, straight away, in one of its regions, to try an edit without waiting for the interval. Only one such run per check is pending at a time. It is metered like any other run.

## Browser minutes

Runs on Pingtower's runners use browser minutes, metered from each run's time, including runs you start by hand and confirmation re-runs:

| | Free | Pro | Enterprise |
| --- | ---: | ---: | ---: |
| Browser minutes per month | 100 | 3,000 | no cap |

The time is measured by the runner, never by the script, and a run counts at most its timeout plus 30 seconds. A run that ends in a check error still counts once the runner has launched its sandbox, even if the sandbox then failed to start, and if the runner reports no time for it, it counts as the check's full timeout. A run the runner turns away before launching the sandbox, for example because it is busy or the request is invalid, uses no minutes.

The month is the calendar month in UTC. Usage is shown in whole minutes, rounded up, under Account → Plan & usage and at `GET /v1/tenants/{tid}/plan` as `usage.browser_minutes` (`used`, `limit`, `resets_at`).

When the month's minutes are used up, your browser checks on Pingtower's runners stop being run until 00:00 UTC on the 1st. Nothing is deleted, and other checks keep running. Reading a paused check shows why:

```json
"paused": {
  "reason": "browser_minutes_exhausted",
  "message": "this plan's 100 browser minutes for the month are used; browser checks on the fleet resume 2026-11-01T00:00:00Z",
  "resumes_at": 1793491200
}
```

## In the apps

The [web app](https://app.pingtower.com) is where browser checks are written: a script editor, environment variables, the interval, timeout and regions, the runs with their steps and Web Vitals, run now, and each failed run's screenshot, video and, for a check without environment variables, trace. The iOS app shows browser checks read-only: their runs, steps, Web Vitals and failure screenshots, and the month's browser minutes.

## Where it runs

Browser checks are built for Pingtower's runners. The addons agent never runs a browser itself: it hands each run to a separate browser runner service on the same box. On an agent box without that service, every run of a browser check is a check error (`browser runner not configured on this box`), not **down**. Run now and the failure artifacts are available only for runs on Pingtower's runners, and a self-hosted tower meters no browser minutes.
