Browser / transaction synthetic
Status: shipped and schedulable.
browseris a first-class synthetic test type in the REST API, CLI, web UI, test schema, and agent registry. Both the non-rendering HTTP transaction driver and the rendered Playwright driver are connected to the shipped agent. Each agent selects one driver at startup and each test names the semantics it requires; a mismatch is rejected rather than silently substituting HTTP for a rendered browser. Both paths enforce the private-target SSRF guard.
What it is
This is the canary (probectl's name for one scheduled synthetic test type)
that drives a scripted multi-step transaction — a login, a checkout — and
reports per-step timings plus a page-load waterfall (the per-request timing
ladder: when each resource's DNS lookup, connection, TLS handshake, and first
byte happened — a Gantt chart of the page load). The ordinary agent defaults to
the Go-native HTTP driver. The dedicated probectl-browser-agent release image
packages that same tenant-bound Go agent with the listener-free Playwright
worker for DOM/paint timings and visual screenshots.
%%{init: {'theme':'base','themeVariables':{'background':'#0d1117','primaryColor':'#161b22','primaryTextColor':'#e6edf3','primaryBorderColor':'#3b82f6','lineColor':'#8b949e','secondaryColor':'#21262d','tertiaryColor':'#0d1117','clusterBkg':'#161b22','clusterBorder':'#30363d','fontFamily':'ui-monospace, SFMono-Regular, Menlo, monospace'},'flowchart':{'curve':'basis','nodeSpacing':55,'rankSpacing':55,'padding':12}}}%%
flowchart LR
S[transaction script] --> F[Fleet: cap · isolate · recycle]
F -->|Driver| H[HTTPDriver\nGo-native, real waterfall]
F -->|Driver| P[Playwright worker\nfull DOM/paint + PNG]
F -- failure artifact --> O[(object store\ntenant-prefixed)]
F --> R[canary.Result → pipeline → TSDB / incidents]
Two drivers, one contract
Both drivers implement the same Script → Result contract (the
internal/browser.Driver interface). browser.driver in agent YAML chooses
http or browser; params.browser_driver on a test declares the required
semantics. Legacy tests without the parameter mean http. A rendered agent
therefore refuses a legacy/HTTP test, and an ordinary agent refuses a rendered
test. This explicit pairing prevents a green check from lying about whether a
page was actually rendered.
| HTTPDriver (default) | Playwright worker | |
|---|---|---|
| Runtime | Go-native, no browser | headless Chromium (browser-worker/) |
| Waterfall | real, per request (DNS / connect / TLS / TTFB / total) | real, per resource |
| DOM/paint timings | – | yes |
| Screenshot | the failed page's HTML body | a visual PNG |
| Shipped runtime | ordinary probectl-agent |
probectl-browser-agent image |
| Test parameter | browser_driver: http (default) |
browser_driver: browser |
(Playwright is the browser-automation framework the worker is built on — it
drives a real Chrome engine from code; headless Chromium is that engine run
without a visible window.) The two drivers are a table read versus a full dress
rehearsal: the HTTPDriver reads the script as raw HTTP — every request,
timing, and status real, nothing rendered; the Playwright worker stages it in
a real browser, adding what only rendering can show (DOM/paint timings, a
visual screenshot). Browser rendering is delegated to a child worker process
over the ExecDriver stdin/stdout contract. There is no worker listener or
plaintext sidecar API: the tenant-bound agent starts one bounded worker for one
transaction, sends JSON on stdin, reads JSON on stdout, and kills the process on
timeout.
Transaction script format
A script is JSON, parsed and validated by internal/browser/script.go:
{
"name": "login",
"start_url": "https://app.example/login",
"steps": [
{"action": "goto"},
{"action": "fill", "selector": "[name=username]", "field": "username", "value": "alice"},
{"action": "fill", "selector": "[name=password]", "field": "password", "value": "secret"},
{"action": "click", "selector": "button[type=submit]"},
{"action": "assert_text", "value": "Welcome"},
{"action": "assert_status", "status": 200}
]
}
The full action vocabulary: goto, fill, click, submit, wait_text,
assert_text, assert_status, screenshot. The two drivers read the fields
they each need — the browser driver uses selector (a DOM element), the HTTP
driver uses field (a form field name) plus url (the submit target).
Result fields
Each run produces a Result (internal/browser/result.go): success/error,
total_ms, steps[] (each with name / action / success / duration),
waterfall[] (each request's url / method / status plus DNS / connect / TLS /
TTFB / total), dom (DOMContentLoaded / load / first-paint / first-contentful-
paint when a rendering driver supplies it), and a screenshot reference when a
failure artifact is stored. The run is then mapped onto the canonical
canary.Result (type browser), so it flows through the same pipeline → TSDB
/ incident path as every other canary: total time, resource counts, and
transaction.step.<n>.duration_ms become metrics; step name/action/success and
the screenshot key become attributes.
Fleet: isolation, concurrency, recycling
Because browser workers are CPU- and memory-heavy, the Fleet
(internal/browser/fleet.go):
- caps concurrency — a worker pool of
MaxConcurrency; extra runs block until a worker is free; - isolates each run — a
RunTimeoutcontext bounds every run (default 60s); for the Playwright worker, a timeout kills the worker process (viaexec.CommandContext); - recycles workers — after
RecycleAfterruns, or after any failed run, the driver isClose()d and rebuilt (this bounds resource leaks and restarts a crashed browser); - degrades safely — a panicking run is caught and the worker recycled, rather than taking the fleet down.
Screenshots → object store
A failure artifact is uploaded to the pluggable object store — a key → blob
store: Put bytes under a string key, Get them back (internal/objectstore).
The fleet writes through a tenant-bound object handle: it passes only the
relative artifact path (browser/<script>-<ts>.png), and the store adapter
prepends the tenant namespace (tenant/<id>/... or a routed silo/<id>/...).
That keeps one tenant's artifacts isolated from another's at the storage layer
(siloed tenants get their own prefix via isolation routing; a routing failure
stores nothing — fail closed).
Three implementations ship: S3/MinIO for durable deployments,
filesystem for local/single-node development, and in-memory for tests.
All implement the same bounded Store interface
(Put/Get/Stat/List/DeletePrefix). Remote S3-compatible endpoints use
verified HTTPS and SigV4; the durable decision and failure policy are recorded
in the artifact-storage ADR.
Successful runs store nothing by default (to bound storage); set
StoreOnSuccess to keep them. Object-lifecycle / retention policy is applied at
the store itself.
Retrieving a failure artifact
Open Targets & Tests → Results on the failed browser test. Stored failure artifact shows the exact object reference and content type when the agent successfully saved one. Copy that reference to your authorized storage operator. It is a storage key, not a URL: the console does not fetch it, embed captured HTML, or offer an HTTP download from an agent-local filesystem. If the section is absent, inspect the agent's artifact-storage logs; an unsuccessful upload does not produce a reference.
The operator uses the producing agent's artifact_store configuration:
- Filesystem: locate the object beneath
artifact_store.dir, preserving the completetenant/<id>/browser/...orsilo/<id>/browser/...reference. For an agent container, copy that exact file from its mounted object-store directory with your existing container administration tools. For example, when the configured directory is/var/lib/probectl/objects, a reference oftenant/<tenant-id>/browser/<artifact>.pngresolves to/var/lib/probectl/objects/tenant/<tenant-id>/browser/<artifact>.png. - S3/MinIO: use the operator's existing authenticated storage client against the configured HTTPS endpoint and bucket. The object key is the configured S3 prefix followed by the complete displayed reference. Keep certificate validation enabled; do not turn the object public or place credentials in a URL to retrieve it.
Check that the reference's tenant matches the authorized request before reading or sharing it. Keep retrieved files private. A rendered driver's artifact is PNG; the HTTP driver's artifact is captured HTML, which should be inspected as untrusted text rather than opened as an active webpage. Retention can remove a previously stored object, so a reference is evidence of storage at execution time, not a promise that the file remains available. Sharing the agent and control-plane object backend supports lifecycle export/erase; it does not add a browser-artifact download route.
Deploy
For non-rendering HTTP transactions, no extra process is required:
probectl-agent registers browser, builds a one-slot browser Fleet, and runs
the HTTPDriver with the shared canary target guard. Configure:
browser:
driver: http
When artifact_store.mode: s3 and its S3 fields are set, the agent opens the
operator's durable S3/MinIO store. artifact_store.dir (or
PROBECTL_AGENT_OBJECTSTORE_DIR) selects the filesystem fallback. In both cases
the agent passes its mTLS tenant into the browser fleet, so failed transaction
artifacts are written under tenant/<id>/browser/... — or under
silo/<id>/browser/... when artifact_store.isolation declares the tenant
hybrid or siloed (isolation.md). Point it at the same
backend, bucket, and prefix as the control plane when tenant lifecycle
export/erase must inventory and delete those artifacts. To create one
from the CLI, either omit script and let the agent create a default
goto target + assert HTTP 200 transaction, or pass the script JSON explicitly:
probectl test create \
--name login-browser \
--type browser \
--target https://app.example/login \
--param browser_driver=http \
--param 'script={"name":"login","start_url":"https://app.example/login","steps":[{"action":"goto"},{"action":"assert_status","status":200}]}'
For rendered transactions, deploy the probectl-browser-agent image and use:
browser:
driver: browser
worker:
command: node
path: /worker/worker.mjs
step_timeout: 15s
canaries:
- type: browser
target: https://app.example/login
interval: 60s
timeout: 60s
params:
browser_driver: browser
The image is built from deploy/docker/Dockerfile.browser-agent, runs as
Playwright's non-root pwuser (uid 1001 in the pinned base; the Helm DaemonSet
runs as that uid), ships its worker files with explicit world-readable modes
regardless of the build host's checkout (DPR-062), and is included in release
and air-gap component manifests. Its worker is configuration-time required: missing command/script or
an invalid timeout prevents agent startup. Compose exposes it only through the
opt-in browser-synthetic profile in eval-synthetic.yml. Helm exposes it as
the opt-in browserAgent DaemonSet in the main chart; enabling it requires an
immutable image digest, a Secret containing agent.yml plus the mTLS
certificate/key/CA, and an explicit non-empty egress allow-list. Neither deploy
renders a Service because the worker listens on nothing.
CI also runs scripts/check_browser_image.sh <local-image-tag> [platform]. It
builds the core browser-agent image from the checkout for the selected platform
(default linux/amd64), then tests that local image with pulling disabled. A
failed build stops the check before smoke execution. The check exercises the shipped
image. It mounts only the smoke driver and exercises the image's own worker,
timing helper, Node and Chromium: a successful local login, a failure screenshot,
and private-target refusal. The container has no external network. This catches
missing runtime files that syntax checks and source-based worker tests cannot.
The worker applies the target policy to the start URL, redirects, and every page
subresource after DNS resolution. Loopback, RFC1918/ULA, link-local/cloud
metadata, CGNAT, multicast, and numeric-address bypasses are denied by default.
allow_private_targets: true remains a per-test, permission-gated, audited
override and is passed to the worker only after the Go-side guard accepts it.
For the surrounding stack — bringing up the control plane and bus, and the
per-producer deployment journeys — start at
getting-started.md and
deploying-agents.md.
Notes
- Integration status. CI runs the real Playwright worker in its pinned
Chromium image, then constructs the shipped agent factory with
browser.driver=browserand asserts the agent receives waterfall and DOM timings. The worker smoke separately proves private-target refusal and both success/failure artifacts. - Architecture choice. The script format, result model, object-store upload,
and fleet isolation/concurrency/recycling all live in Go (
internal/browser, fully tested); only rendering is delegated to the packaged Playwright child. This keeps Chromium out of the portable single-binary agent while making the browser-capable release image a complete runnable producer. - Out of scope. Real-user monitoring (
rum.md) and endpoint browser-session capture are separate features. Note that some sites detect headless browsers; for those, configure a realistic user-agent / browser context.