Skip to main content

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 Journey object, 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");
'''
FieldMeaning
interval, timeoutSchedule on the runner; timeout at most 60 s.
selectorRunners (hosts with a strataplane-agent instance) that run the journey.
browser.startUrl, browser.allowHostsStart page; requests to other hosts are blocked. *.example.com allows every subdomain of example.com. Optional browser.viewport (default 1280×800).
scriptThe journey, JavaScript. At most 256 KiB.
evidencetrue (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/config when 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 concurrency applies.
  • 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 timeout plus 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.

FunctionMeaning
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.clickAtInput. 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​

ResultMeaning
passedAll steps passed.
failedAn assertion or waitFor failed, or the run hit timeout: the target is broken.
errorScript 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:

MetricLabels
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_loadrunner: 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.

RunKept
Failed or errorStep log with timings, error, failing script line, output, and the screenshot and accessibility snapshot of the failing step. Last failedRunsKept per journey (default 20).
PassedStep 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. --headed shows the browser window, which helps when exploring a target.
  • --out receives full evidence regardless of evidence: result.json (the same shape a runner posts), and per step NN-<step>.png and NN-<step>.snapshot.json. --step NAME stops 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 stepScript
setViewportbrowser.viewport
first navigatebrowser.startUrl; hosts of all navigations seed allowHosts
later navigateapp.navigate(url)
click, doubleClickapp.click(sel), app.click(sel, { count: 2 })
changeapp.fill(sel, "<value>")
keyDown + keyUpapp.press(key)
waitForElement, assertedEvents: navigationapp.waitFor(…)
anything else// TODO(import): <original step> and a warning
  • Selectors: aria/ first (as { role, name }), then text/, then CSS; XPath and pierce/ 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 waitFor on the last page's main heading and a warning.
  • allowHosts gets 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 start opens the start URL in a fresh browser context with the host guard of allowHosts (from a Journey file with --journey, or given as options). Each journey explore do runs 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 with journey explore stop, after 10 minutes without a request, after 30 minutes, or when the runner restarts.
  • Trial runs. stratactl journey try FILE runs 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_sessions sessions and trial runs at once (default 2), apart from the concurrency of its scheduled journeys.
  • Access. Sessions and trial runs need host.explore on the runner (architecture.md §7); a paired runner (below) only its user. --runner names 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 and allowHosts, 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:

  1. It starts a session at the target (sp journey explore start), reads the snapshot and checks the next fragment with sp journey explore do, using selectors from the snapshot (roles and names, not coordinates).
  2. Fragments that worked become the Journey's script, with steps and assertions; sp journey try runs the whole file in a fresh context.
  3. 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/.

AdditionContent
/journeys/<name>/state.jsonRunners, 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.tsThe script API.
sp journey show, sp journey runs, sp journey import-chrome, sp journey explore …, sp journey tryCommands (cli.md §9).
Skill journey-authoringExploring 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.