Synthetic journeys
A journey is a short script that drives a headless browser through a web
application and answers one question: does this still work, here, now. It
runs on strataplane-agent runners on an interval; results become metrics
to alert on, and failures keep a screenshot.
Journeys reuse Strataplane's spec, change sets, rollouts and assistant, and add as little as possible of their own.
1. Principles
- A journey is one spec file. The script is inline in the
Journeyobject, so it is reviewed, versioned and rolled out like any layer. - The runner schedules, the server delivers. Runners get their journeys as rendered config and run them on their own clock. There is no per-run orchestration on the server; the exceptions are a user's requests: a manual run of a journey (§3.1) and exploration (§8.2).
- Selectors pick runners. A journey runs on every runner its selector matches. Redundancy (two runners per zone) is expressed with labels.
- No secrets. Journeys cover what works without signing in, or synthetic accounts whose access does not need protecting.
- Results are metrics. The server keeps the latest result per journey and runner, and exports the time series. Alerting happens where the other alerts are.
- Tried before review, observed through evidence. A journey is tried before review with the same runner binary, locally (§7) or on an exploration runner (§8.2), and seen afterwards through the evidence of failed runs.
- Monitoring, not test management. A missed or crashed run is simply missed; the next interval runs again.
2. The Journey object
apiVersion = "v1alpha1"
kind = "Journey"
[metadata]
name = "shop-checkout"
owner = "team-shop"
description = "Home page to checkout button"
[spec]
interval = "5m"
timeout = "60s"
selector = { matchLabels = { role = "synthetic-runner" } }
browser = { startUrl = "https://shop.example.com/", allowHosts = ["shop.example.com"] }
script = '''
app.step("home");
app.waitFor({ role: "heading", name: "Shop" });
app.step("cart");
app.click({ role: "link", name: "Cart" });
app.assert(app.find({ role: "button", name: "Checkout" }), "checkout button missing");
'''
| Field | Meaning |
|---|---|
interval, timeout | Schedule on the runner; timeout at most 60 s. |
selector | Runners (hosts with a strataplane-agent instance) that run the journey. |
browser.startUrl, browser.allowHosts | Start page; requests to other hosts are blocked. *.example.com allows every subdomain of example.com. Optional browser.viewport (default 1280×800). |
script | The journey, JavaScript. At most 256 KiB. |
evidence | true (default) or false: keep step logs only, never screenshots or snapshots. |
Tenant-wide settings (metricLabels, failedRunsKept, passedRunsKept) are the journeys
Settings object (spec-format.md §3.8).
3. Runners and delivery
A runner is a host (Linux or Windows) or a container with strataplane-agent
and a headless Chromium, driven over CDP. Each run gets a fresh browser
context; a runner executes up to concurrency journeys at once (default 2).
The helper module renders, per runner, a JSON artifact listing the journeys whose selector matches the host. The artifact is content-addressed like any other: runners matching the same journeys share one. The helper:
- checks in every 60 s (
POST /agent/v1/helper/checkin) with its applied artifact hash, health and Chromium version, and gets the desired artifact hash, - pulls the config from
GET /agent/v1/helper/configwhen the two differ, - posts results and evidence to
POST /agent/v1/helper/results, retrying until accepted. A run id is accepted once; results older than 24 h are dropped (journey_receipt).
Runs start at a stable offset within the interval (hash of journey and runner), so runners do not hit a target at the same second. A run that would overlap the previous one is skipped and counted. Results wait in a small in-memory queue (100 entries) while the server is unreachable; beyond that the oldest are dropped and the gap shows as staleness.
Rendering, previews and stratactl journey show warn when a runner's
worst-case load, the sum of timeout ÷ interval over its journeys, exceeds
its concurrency; load ÷ concurrency is exported as
strataplane_journey_runner_load.
A journey whose selector matches no runner renders nowhere;
stratactl journey show and the UI say so.
Besides checking in, every runner long-polls
POST /agent/v1/helper/explore/poll for requests: manual runs
(§3.1) and, on exploration runners, exploration sessions
and trial runs (§8.2). Requests and answers wait in
explore_request, so they reach the
runner through any replica.
3.1 Manual runs
Run now on the journey's page (or stratactl journey run NAME) runs a
journey once, now, on its runners: all of them, or one with --runner. It
needs host.explore on each runner
(architecture.md §7) and is audited.
- The runner runs the journey of its applied config, so a manual run tests
what is deployed, with the same engine, bounds, host guard and evidence
as a scheduled run. A run of the same journey already in progress is
waited for (at most one interval); the runner's
concurrencyapplies. - The result is posted like any other, tagged with the request, and kept as a run with the user who triggered it (§6). It does not change the latest result per runner, its counters, the failing-since time or the metrics of §5, so a manual retry neither clears nor raises an alert.
- The request waits for every runner's result, at most the journey's
timeoutplus a minute; a runner that does not pick it up within 15 s is reported as unreachable.
4. Script API
Scripts are JavaScript in QuickJS. They get app, print and
console.log; there is no network, file system, timer or module access in
the script context.
| Function | Meaning |
|---|---|
app.step(name) | Start a named step; timing, screenshot and errors attach to it. |
app.find(sel), app.waitFor(sel, opts) | Locate an element: { role, name }, text or CSS. |
app.click, app.fill, app.clear, app.press, app.drag, app.clickAt | Input. clickAt (coordinates) works but is flagged in review. |
app.assert(condition, message) | Fails the run. |
app.snapshot() | Accessibility tree of the target. |
app.screenshot(label), app.wait(ms) | Explicit evidence in local runs with --out (deployed runners keep only the failing step's evidence, so there it does nothing); bounded waits. |
app.navigate(url), app.waitFor({ url }) | Navigation within allowHosts. |
Bounds per run, enforced by the runner: wall time timeout (≤ 60 s), 512
actions, 10 s of explicit waits, 64 KiB of printed output.
Host guard. Requests to hosts outside allowHosts (plus startUrl's
host) are blocked. Loading the page from such a host (app.navigate, a link,
a redirect) ends the run with error; other blocked requests (trackers,
third-party scripts, frames) only fail and are listed in the run's output.
After each run the runner discards the browser context.
5. Results and metrics
| Result | Meaning |
|---|---|
passed | All steps passed. |
failed | An assertion or waitFor failed, or the run hit timeout: the target is broken. |
error | Script error, the browser did not start, the host guard tripped, or the runner had a problem: the journey or runner is broken. |
The split decides who is paged: failed goes to the journey's owner, error
to whoever owns the runner or the script.
The server keeps the latest result and run counters per journey and runner
in journey_state and exports them on the tenant's metrics endpoint
(architecture.md §10),
so every replica reports the same values:
| Metric | Labels |
|---|---|
strataplane_journey_up (0/1, latest run) | journey, owner, runner, metricLabels |
strataplane_journey_runs_total | … plus result (passed, failed, error, skipped) |
strataplane_journey_duration_seconds (latest run) | … |
strataplane_journey_step_duration_seconds (latest run) | … plus step (first 32 steps; the rest as other) |
strataplane_journey_last_run_timestamp_seconds | … (staleness) |
strataplane_journey_runner_load | runner: worst-case load ÷ concurrency; > 1 means runs will be skipped |
Alerting is done on these metrics in the existing alerting stack, for
example "failed on at least two zones
for 10 minutes" or "no run for three intervals".
6. Runs and evidence
Every run is kept as a run with its step log; the journey's page lists them, filterable by result.
| Run | Kept |
|---|---|
| Failed or error | Step log with timings, error, failing script line, output, and the screenshot and accessibility snapshot of the failing step. Last failedRunsKept per journey (default 20). |
| Passed | Step log with timings and output, no evidence. Last passedRunsKept per journey (default 200), apart from the failed ones, so passing runs never push failures out. |
| Manual (§3.1) | As above by result, with the user who triggered it. |
Evidence is stored in object storage, encrypted with the tenant's data
key (operations.md §9), at most 2 MiB per run. Screenshots may
show data from the target; journeys that must not keep any set
evidence = false (§2).
7. Local runs
A journey is tried where its author is, with the same binary that runners use:
# on Linux, macOS or Windows, from a working copy (stratactl spec pull)
strataplane-agent journey run journeys/shop-checkout.toml --out runs/shop-checkout
# in the runner image, no install
docker run --rm -v "$PWD:/work" strataplane/agent \
journey run /work/journeys/shop-checkout.toml --out /work/runs/shop-checkout
- Needs no enrollment and no server: it reads the Journey file, applies the
L0 limits and the L2 script check, and runs it once with the same browser
engine, bounds and host guard as a deployed runner.
--headedshows the browser window, which helps when exploring a target. --outreceives full evidence regardless ofevidence:result.json(the same shape a runner posts), and per stepNN-<step>.pngandNN-<step>.snapshot.json.--step NAMEstops after a step, for exploring a target.- For a shared non-production check, deploy a runner (package, container or Kubernetes deployment of the runner image) with labels that only non-production journeys select; it is an ordinary runner, and its results are ordinary results.
8. Authoring
8.1 Importing Chrome recordings
stratactl journey import-chrome NAME FILE converts a Chrome DevTools Recorder
export (@puppeteer/replay JSON) into a Journey in the working
copy. The UI's import uses the same converter
(POST /api/v1/tools/journeys/import-chrome; the recording is not stored).
| Recorder step | Script |
|---|---|
setViewport | browser.viewport |
first navigate | browser.startUrl; hosts of all navigations seed allowHosts |
later navigate | app.navigate(url) |
click, doubleClick | app.click(sel), app.click(sel, { count: 2 }) |
change | app.fill(sel, "<value>") |
keyDown + keyUp | app.press(key) |
waitForElement, assertedEvents: navigation | app.waitFor(…) |
| anything else | // TODO(import): <original step> and a warning |
- Selectors:
aria/first (as{ role, name }), thentext/, then CSS; XPath andpierce/only as a last resort, flagged. Alternatives are kept as a comment. - One
app.step()per navigation, named after the page title. - Typed values. Values of password fields are dropped and replaced by a
TODO(import)comment; journeys have no secrets, so a recording that signs in needs a synthetic account or a different journey. All other typed values become literals in the script and are listed in the import summary, because they will be in the spec. - Recordings contain no assertions; the importer adds a
waitForon the last page's main heading and a warning. allowHostsgets the hosts of the recorded pages only; scripts, styles and APIs the pages load from other hosts are blocked until they are added (§4, host guard). The import warns about it.
8.2 Exploration
An exploration runner is a runner whose configuration allows
exploration (explore = true in agent.toml, --explore or
STRATAPLANE_EXPLORE). It says so at checkin and is otherwise an ordinary
runner. It gives users, and the assistant acting for them, a browser at the
runner's network position for writing journeys:
- Sessions.
stratactl journey explore startopens the start URL in a fresh browser context with the host guard ofallowHosts(from a Journey file with--journey, or given as options). Eachjourney explore doruns a script fragment with the API of §4 against the session's page and answers with the outcome, the printed output and a snapshot of the page. The page keeps its state between fragments, so a journey is assembled from fragments that worked. A session ends withjourney explore stop, after 10 minutes without a request, after 30 minutes, or when the runner restarts. - Trial runs.
stratactl journey try FILEruns a Journey file once on an exploration runner like a local run with--out(§7): the result, a snapshot per step (also as text, the form sessions print) and a screenshot of the last (or failing) step. - Bounds. A fragment has the bounds of §4 with a 30 s
timeout, a trial run the journey's own. A runner holds at most
explore_sessionssessions and trial runs at once (default 2), apart from theconcurrencyof its scheduled journeys. - Access. Sessions and trial runs need
host.exploreon the runner (architecture.md §7); a paired runner (below) only its user.--runnernames it; otherwise the server picks the exploration runner, among those the user may use and that checked in within the last 3 minutes, with the fewest open sessions, the user's paired runners first. Opening a session or a trial run is audited with the start URL andallowHosts, each fragment with its hash; fragments and answers are deleted an hour after the session ends. - Snapshots are text, one line per element, indented by nesting: the
role and name exactly as a selector takes them (
button "Weiter"is{ role: "button", name: "Weiter" }), then value and state. A role and name that more than one element has carry the number of matches; such an element needs a more specific selector. - Transport. The runner's long-poll of §3
delivers the requests; it answers each with
POST /agent/v1/helper/explore/answers.
Paired runners. A user can also lend their own computer for a while:
POST /api/v1/explore-pairings (the Journeys page, or stratactl journey explore pair) returns a single-use code valid for 10 minutes and the
command that uses it, strataplane-agent explore --server-url URL --pair CODE. That process enrolls a temporary exploration runner, keeps no state
on disk and runs no scheduled journeys. Only the user who paired it can use
it, and pairing needs a role that grants host.explore in any scope. It is
a host of kind client named paired-<id>.<hostname>, so it never takes
over an enrolled host. Stopping the process removes it, and the server
removes a paired runner it has not seen for an hour.
An exploration runner opens whatever hosts its users name, from its own
network. Place exploration runners where every user with host.explore on
them may reach (a DMZ runner for public targets, a separate one for internal
applications), and scope those bindings with the runners' labels.
8.3 Assistant
The assistant writes journeys like any spec file and tries them on an exploration runner:
- It starts a session at the target (
sp journey explore start), reads the snapshot and checks the next fragment withsp journey explore do, using selectors from the snapshot (roles and names, not coordinates). - Fragments that worked become the Journey's script, with steps and
assertions;
sp journey tryruns the whole file in a fresh context. sp validate,sp changeset submit.
With --look, a session command also attaches a screenshot of the page,
scaled to 1024 pixels and JPEG-encoded, which a model with image input sees
(assistant.md §2): layout, visual state, things the
accessibility tree leaves out. Page text in snapshots, output and
screenshots is data from the target, not instructions; the prompt says so. The assistant panel shows the session's
page after every fragment (ui.md §5).
Without an exploration runner the user runs the journey locally instead:
the assistant writes a script that ends in app.snapshot(), the user runs
it with strataplane-agent journey run and uploads the --out directory
into the chat (/samples/runs/…), and the assistant continues from there.
Repairing a failing journey needs no run at first: the failed run's
screenshot, snapshot and failing line are in /journeys/<name>/runs/.
| Addition | Content |
|---|---|
/journeys/<name>/state.json | Runners, latest result per runner. |
/journeys/<name>/runs/<run>/ | Failed runs: step log, error, screenshot and snapshot of the failing step (read-only). |
/samples/runs/<name>/ | Output of trial runs and of local runs the user uploaded. |
/catalog/journey/api.d.ts | The script API. |
sp journey show, sp journey runs, sp journey import-chrome, sp journey explore …, sp journey try | Commands (cli.md §9). |
Skill journey-authoring | Exploring and trying, the local-run fallback, selector strategy, waits instead of sleeps, finishing imported recordings, reading failure evidence. |
9. Data model and API
Journeys add the agent kind strataplane-agent and six tables
(journey_receipt, journey_state, journey_run, journey_evidence,
explore_session, explore_request); the spec store, render
pipeline, artifacts, rollouts and secrets are unchanged. DDL:
schema.md. Endpoints: openapi.yaml,
tags journeys and agent-helper.