Recording a Test Plan from Browser Traffic

The record command launches a real browser, captures the HTTP traffic while you browse your application, and turns it into a starting LPS test plan (.yaml or .json). It is the fastest way to bootstrap a plan: browse the flow you want to load-test, and LPS writes the requests for you to refine.

Recording is powered by Playwright using its native HAR capture. Because the browser hands LPS the already-decrypted traffic, no proxy and no root certificate installation are required.

The generated plan is a starting draft, not a finished test. Review it, parameterize secrets, and adjust the load shape before running at scale.


Prerequisites

The first time you use record, install the browser Playwright drives (a one-time ~150 MB download of Chromium):

lps record --install

Basic Usage

lps record app.yaml --url https://www.example.com

This opens a browser at the given URL. Browse the flow you want to test, then stop the recording (see below). LPS filters the captured traffic and writes the plan to app.yaml.

The output file is optional and defaults to RecordedPlan.yaml:

lps record

The file extension decides the format: use .yaml/.yml for YAML or .json for JSON.


Stopping the Recording

Recording stops on the first of these three signals:

SignalHow
Close the browserClose the browser window
Press EnterFocus the terminal and press Enter
CancelPress Ctrl+C

When recording stops, LPS reads the capture, applies your filters, and writes the plan once.


Options

OptionAliasDescriptionDefault
<output>Output plan file (.yaml, .yml, or .json)RecordedPlan.yaml
--name-nName for the generated plan (defaults to the output file name)RecordedPlan
--url-uStart URL to open when the browser launches
--headlessRun the browser without a visible windowfalse
--installInstall the Playwright Chromium browser and exitfalse
--prompt-files-pfAfter recording, ask for a local path for each captured file uploadfalse
--append-aAppend captured requests to the existing plan file instead of overwriting itfalse
--update-upUpdate matching requests (by method + URL + body) instead of adding duplicates; new requests are appendedfalse
--round-rnRound for new requests (with --append or --update); also the round to createMain
--number-of-clients-ncVirtual clients per round in the generated plan1
--arrival-delay-adDelay in milliseconds between client arrivals0
--run-in-parallel-ripRun the round's clients in parallelfalse
--request-count-rcRequests per client for each iteration (request-count mode)1
--duration-dRun each iteration for this many seconds (switches to duration mode)
--only-host-ohKeep only requests to these host(s)
--ignore-host-ihIgnore requests to these host(s)
--ignore-path-ipIgnore requests whose URL path/query contains any of these substrings
--ignore-content-type-ictIgnore responses with these content-type(s)
--ignore-extension-iextIgnore URLs ending in these file extension(s)
--ignore-method-imIgnore these HTTP method(s)
--ignore-resource-type-irtIgnore these Playwright resource type(s)

All --ignore-*, --only-host, and --ignore-host options accept multiple space-separated values, for example -irt image font media.

Shaping the load

By default the plan is generated with a single client and one request per iteration. Pass the load-shape options to bake in your own numbers, so you don't have to hand-edit the plan afterward:

lps record api.yaml -u https://www.example.com --only-host example.com --number-of-clients 5000 --arrival-delay 100 --run-in-parallel

Provide --duration <seconds> to generate duration-based iterations instead of request-count ones.


Appending to an Existing Plan

By default, record overwrites the output file. With --append, it merges the captured requests into an existing plan instead, so you can build a multi-flow plan across several sessions, each flow as its own round.

Use --round <name> to control where the requests land:

CommandResult
lps record plan.yamlOverwrite the file; one round named Main
lps record plan.yaml --round LoginOverwrite; the round is named Login
lps record plan.yaml --append --round Checkout (round not in file)Add a new round Checkout (load-shape flags apply to it)
lps record plan.yaml --append --round Login (round already exists)Add the captured requests into the existing Login round
lps record plan.yaml --append (no --round)Add a new auto-named round (Round2, Round3, …)

Example: build a plan flow by flow

lps record app.yaml --round Login --only-host myapp.com
lps record app.yaml --append --round Checkout --only-host myapp.com --number-of-clients 500
lps record app.yaml --append --round Login --only-host myapp.com   # adds more requests to Login

Notes:

  • Iteration name collisions are resolved automatically (GET_productsGET_products_2).
  • When you append into an existing round, that round keeps its own numberOfClients / arrivalDelay / runInParallel. The load-shape flags are ignored (with a warning), since they would only reshape a round you are adding to.
  • If --append or --update is set but the existing file cannot be parsed, LPS aborts rather than overwriting it. (An empty or blank file is treated as a fresh start.)

Updating an Existing Plan

--append always adds the captured requests, which can create duplicates if you re-record a flow that revisits the same endpoints. --update instead upserts: if a captured request already exists in the plan, it is refreshed in place; only genuinely new requests are added.

lps record app.yaml --update --only-host myapp.com

A request is considered "the same" when its method, URL, and body all match. So GET /products/1 recorded twice updates one iteration, while two POST /graphql calls with different query bodies stay separate (they are different operations).

Recorded requestWith --appendWith --update
Already in the planAdded again (duplicate)Existing iteration refreshed in place
NewAddedAdded
Recorded twice in one sessionBoth keptCollapsed to one

An update keeps the matched iteration's:

  • Name, so references and edits you made survive.
  • Load shape (mode, requestCount, duration) and any failure/termination rules. Only the captured request (URL, method, headers, body) is refreshed.

New (unmatched) requests are added to the target round: --round if given, otherwise the first existing round, otherwise a new Main round. Because the match is plan-wide, an existing request is updated wherever it lives, even if it sits in a different round than the target.

Round-level load-shape flags (--number-of-clients, --arrival-delay, --run-in-parallel) are ignored in --update mode (with a warning). Updating refreshes requests, not the load shape. If both --append and --update are given, --update wins.

Example: refresh a plan after the app changed

# Original capture
lps record app.yaml --only-host myapp.com

# Later, the app added an endpoint and changed a response - refresh without duplicating
lps record app.yaml --update --only-host myapp.com

Existing requests are refreshed in place, the new endpoint is appended, and your tuned numberOfClients and rules are left intact.


Filtering Out Noise

A few minutes of browsing can generate hundreds of requests: static assets, analytics, and CDN infrastructure. LPS removes most of this for you and lets you trim the rest.

Built-in defaults

Out of the box, LPS drops:

  • Static assets by resource type (image, stylesheet, font, media, manifest) and by extension (.css, .js, .png, .svg, .woff2, .map, …).
  • Infrastructure paths: anything containing /cdn-cgi/ (Cloudflare challenge/RUM traffic).

Your --ignore-* values are added on top of these defaults.

The most useful filter: --only-host

Third-party analytics and tracking live on other hosts (e.g. analytics.example.com, j.clarity.ms). An allowlist drops them all in one shot:

lps record api.yaml --url https://www.example.com --only-host example.com

Only requests whose host exactly matches are kept, so analytics.example.com and third-party domains are excluded. List every host you want to keep:

lps record api.yaml -u https://www.example.com --only-host example.com api.example.com

--only-host and --ignore-host also accept full URLs: --only-host https://example.com/ is normalized to the host example.com.

Which signal each filter reads

FilterReads from
--ignore-methodRequest method
--ignore-extensionURL path
--ignore-content-typeResponse content-type
--ignore-resource-typePlaywright resource type (document, xhr, fetch, image, …)
--only-host / --ignore-hostRequest host (exact match)
--ignore-pathURL path/query substring

Examples

Keep only your API calls and drop CORS preflights:

lps record api.yaml -u https://www.example.com --only-host example.com --ignore-method OPTIONS

Deny specific trackers without an allowlist, and strip infrastructure paths:

lps record api.yaml -u https://www.example.com -ih j.clarity.ms google-analytics.com -ip /cdn-cgi/ /collect

If everything is filtered out, LPS writes nothing and reports how many requests were captured versus kept. Check your filters if you see that message.


What Gets Generated

Each kept request becomes one iteration inside a single round:

name: RecordedPlan
rounds:
- name: Main
  numberOfClients: 1
  arrivalDelay: 0
  iterations:
  - name: GET_products
    httpRequest:
      url: https://api.example.com/products
      method: GET
      headers:
        accept: application/json
    mode: R
    requestCount: 1
  - name: POST_login
    httpRequest:
      url: https://api.example.com/login
      method: POST
      headers:
        content-type: application/json
      payload:
        raw: '{"user":"demo","password":"..."}'
    mode: R
    requestCount: 1

Notes on the output:

  • Volatile and pseudo-headers (host, content-length, connection, HTTP/2 :authority, …) are dropped.
  • Load-shape fields (numberOfClients, arrivalDelay, mode, requestCount) are kept with their defaults so they are easy to tune. Change numberOfClients: 1 to the load you want.
  • Rarely-edited transport defaults (httpVersion, supportH2C) are omitted for clarity.

Request Bodies and File Uploads

Request bodies are mapped by content type:

Request Content-TypeGenerated payload
application/json, text/*, application/x-www-form-urlencodedraw
multipart/form-datamultipart with fields and files

For multipart forms, text fields are captured in full. Uploaded file bytes are not captured: the browser only exposes the file name, so each file part is written with a placeholder path for you to fill in:

payload:
  multipart:
    fields:
      - name: title
        value: My Photo
    files:
      - name: avatar
        contentType: image/png
        path: files/me.png      # placeholder - set this to a real local file

This is usually what you want anyway: load tests should point at a controlled test file, not whatever was uploaded while browsing.

--prompt-files

To fill the paths interactively at the end of a recording, add --prompt-files:

lps record upload.yaml -u https://www.example.com --prompt-files
1 file upload(s) were captured. Enter a local path for each (blank keeps the placeholder):
  [POST_upload] field 'avatar' (was 'me.png'): C:\test-data\avatar.png

Without the flag, LPS prints a summary of every file field that still needs a path.


Running the Recorded Plan

Once you have reviewed the plan and filled in any file paths, run it like any other LPS plan:

lps run app.yaml

Tune the round before running at scale, for example set numberOfClients, arrivalDelay, and iteration mode/requestCount to shape the load.


Tips and Limitations

  • Review captured headers before sharing. Recorded requests include cookies and Authorization headers exactly as sent. Treat the generated plan as sensitive, and replace secrets with variables before committing it.
  • Correlation is not automatic yet. Dynamic values (CSRF tokens, session IDs) captured from one response are not automatically wired into later requests. Use capture together with $find/$findxml to correlate them by hand.
  • Re-filtering means re-recording. Filters are applied while generating the plan; to change them, run record again.
  • Chromium only. Recording uses the Chromium browser provisioned by --install.

See Also