Pingtower Docs
Docs The service Browser checks

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.

It is a check like any other, with the addon browsercheck: the same quorum, confirmation and alert_if, and results that arrive as events for your rules.

The script

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 scriptReported as
A top-level test.stepOne step, up or down with its error. A nested step is part of its parent.
test.step.skipA skipped step.
A test with no stepsOne step named for the test.
A test that fails outside its steps, such as an expect after themOne more down step named for the test.
A file that doesn’t compile or holds no testOne 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 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 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

ParameterDefaultMeaning
urlREQUIREDAbsolute http or https URL the script starts at.
scriptREQUIREDThe Playwright test file, up to 256 KiB.
max_steps20Most steps reported, 1 to 20.
Check fieldLimit
interval_secondsAt least 300 on every plan, at most 86,400.
timeout_secondsDefaults 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.
envUp to 50 variables, 32 KiB in all. See Environment variables.

A browser check weighs as much as twenty HTTP checks against your plan’s check capacity. 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.

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": "[email protected]", "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. 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.

  • 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 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.
  • 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.
  • 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:

ReasonMeans
K to a private, loopback, link-local or metadata addressThe host resolved to an address that hosted checks may not reach. See 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 portThe 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 tunnelsAn HTTPS or WebSocket connection past either connection limit.
K at the run's 256 MiB traffic limit1 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:

VitalMeaning
LCPLargest Contentful Paint, in milliseconds.
CLSCumulative Layout Shift. A page that never shifts is 0.
INPInteraction 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:

KeyMeaning
stepsSteps reported
steps_failedSteps that were down
total_msThe whole run, in milliseconds
step.N.name, step.N.status, step.N.msStep N, numbered from 1
step.N.errorStep N’s error, when it failed
{"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:

ArtifactFormatUp to
ScreenshotPNG5 MiB
VideoWebM50 MiB
TracePlaywright trace, .zip50 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. 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 given it. For a member limited to some projects, 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:

FreeProEnterprise
Browser minutes per month1003,000no 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:

"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 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.

Last updated 11 Oct 2026 Report a problem with this page