End-to-End Checkout Journey

This example models a realistic e-commerce user journey as a single LPS plan:

browse the catalog -> add items to a cart -> sign in at checkout -> place the order

It is a stateful flow. Each step reads data produced by the previous one (product ids, a cart id, and an auth token) and carries it forward. Because every virtual client runs the whole journey independently, the captured data is session-scoped so clients never step on each other's cart or token.

It runs against fakestoreapi.com (a public mock store API), so you can execute it as-is. See the caveats section at the end.

What this example demonstrates

  • Top-level variables (global defaults) and environments overrides for load and quantity knobs.
  • Capturing responses as navigable JSON (capture: { to, as: Json }) and reading paths with ${name.Body.path}.
  • before and after hooks to prepare data between steps.
  • Declarative methods: $find, $counter, $uuid, $timestamp, and $set (including arithmetic).
  • Guarding steps with skipIf.
  • Capturing and reusing an auth token across requests (Authorization: Bearer ${auth.Body.token}).
  • Retry driven by this request's captured response (${order.StatusCode}).
  • Termination and failure rules on the iteration.
  • Variable scope (session vs. global) in practice.

The plan

name: ShopCheckoutJourney
variables:
  - name: vuCount        # global variable: number of virtual clients
    value: 10
  - name: arrivalDelay   # global variable: client start stagger (ms)
    value: 400
  - name: firstItemQty   # global variable: quantity for first selected item
    value: 1
  - name: secondItemQty  # global variable: quantity for second selected item
    value: 2
environments:
  - name: development
    variables:
      - name: vuCount
        value: 1
      - name: arrivalDelay
        value: 400
      - name: firstItemQty
        value: 1
      - name: secondItemQty
        value: 2
  - name: staging
    variables:
      - name: vuCount
        value: 25
      - name: arrivalDelay
        value: 250
      - name: firstItemQty
        value: 2
      - name: secondItemQty
        value: 3
rounds:
  - name: CheckoutRound
    numberOfClients: ${vuCount}   # each client runs the whole journey. If omitted, defaults to 1.
    arrivalDelay: ${arrivalDelay} # stagger client start by 400ms
    runInParallel: false          # false is the default, so you can omit it; iterations run in order per client (browse -> cart -> order)
    iterations:

      # ---------- STEP 1: browse the catalog ----------
      - name: browseProducts
        requestCount: 1  # 1 is the default, so you can omit it; number of requests this client makes for this step (iteration)
        httpRequest:
          url: https://fakestoreapi.com/products
          method: GET
          capture:
            to: products           # session-scoped (makeGlobal defaults to false)
            as: Json               # ${products.Body} is now navigable JSON
        after:
          # pick the two cheapest in-stock items to buy, and remember how many were affordable
          - '$find(source=$products.Body, where=item.price < 50, select=item.id, match=first, variable=firstItemId)'
          - '$find(source=$products.Body, where=item.price < 50, select=item.id, match=index:1, variable=secondItemId)'
          - '$find(source=$products.Body, where=item.price < 50, match=count, variable=affordableCount)'
          # a per-session idempotency key for the order we are about to build
          - '$uuid(prefix=order-, variable=orderKey)'
          # a per-session user id from a session-scoped counter (stores straight into ${userId})
          - '$counter(start=1, reset=1000, step=1, name=userIdSeq, variable=userId)'

      # ---------- STEP 2: add the selected items to the cart ----------
      - name: addToCart
        requestCount: 1
        httpRequest:
          # don't try to build a cart if step 1 found nothing to buy
          skipIf: '${affordableCount} < 2'
          url: https://fakestoreapi.com/carts
          method: POST
          headers:
            Content-Type: application/json
          payload:
            type: Raw
            raw: |
              {
                "userId": ${userId},
                "products": [
                  { "productId": ${firstItemId},  "quantity": ${firstItemQty} },
                  { "productId": ${secondItemId}, "quantity": ${secondItemQty} }
                ]
              }
          capture:
            to: cart                # session-scoped: this client's cart only
            as: Json
        after:
          # compute total quantity from configurable variables (as=int evaluates the expression)
          - '$set(variable=totalQuantity, value=${firstItemQty}+${secondItemQty}, as=int)'

      # ---------- STEP 3: checkout - sign in to obtain a bearer token ----------
      - name: checkout
        requestCount: 1
        httpRequest:
          # no point authenticating if there is no cart to check out
          skipIf: '"${cart.Body.id}" == ""'
          url: https://fakestoreapi.com/auth/login
          method: POST
          headers:
            Content-Type: application/json
          payload:
            type: Raw
            raw: |
              {
                "username": "mor_2314",
                "password": "83r5^_"
              }
          capture:
            to: auth            # session-scoped: this client's login token
            as: Json            # token is at ${auth.Body.token}

      # ---------- STEP 4: place the order (finalize the cart, authenticated) ----------
      - name: placeOrder
        requestCount: 1
        httpRequest:
          # only order if a cart was actually created in step 2
          skipIf: '"${cart.Body.id}" == ""'
          # fakestoreapi has NO /orders endpoint; "placing the order" here finalizes the cart
          # by updating it (PUT /carts/{id}). Swap in your real checkout endpoint for a real test.
          url: https://fakestoreapi.com/carts/${cart.Body.id}
          method: PUT
          headers:
            Content-Type: application/json
            Idempotency-Key: '${orderKey}'
            Authorization: 'Bearer ${auth.Body.token}'
          payload:
            type: Raw
            raw: |
              {
                "userId": ${userId},
                "date": "$timestamp(format=yyyy-MM-dd)",
                "totalQuantity": ${totalQuantity},
                "products": [
                  { "productId": ${firstItemId},  "quantity": ${firstItemQty} },
                  { "productId": ${secondItemId}, "quantity": ${secondItemQty} }
                ]
              }
          capture:
            to: order
            as: Json
          retry:
            # ${order} is THIS request's captured response (there is no built-in ${LastResponse});
            # retry only on server errors, never on 2xx/4xx.
            if: ${order.StatusCode} >= 500
            maxRetries: 3
            delayInMs: 500
            strategy: Fixed
        # guardrails for this step (termination/failure rules live on the iteration, not the round)
        terminationRules:
          - metric: "ErrorRate > 0.25"
            gracePeriod: "00:00:10"
        failureRules:
          # fail this iteration if the checkout is too slow or too many requests fail
          - metric: "TotalTime.P90 > 800"     # 90th-percentile latency over 800 ms
          - metric: "ErrorRate > 0.05"         # more than 5% of requests failed

Running the plan

Run against a specific environment:

lps run fshoppingexample.yaml --environment development

If you do not pass an environment, LPS uses the top-level variable defaults in the file. In this example, that means vuCount: 10 (plus the other top-level values).

How it works, step by step

The top-level variables block defines run-wide defaults, and environments overrides those values per target profile. In this example, vuCount, arrivalDelay, firstItemQty, and secondItemQty can vary by environment (for example, development vs staging) without changing the round body. Note that several properties are optional because they have defaults: numberOfClients -> 1, requestCount -> 1, and runInParallel -> false.

The round runs each of its four iterations in order per client (runInParallel: false), so the journey flows top to bottom for every virtual client.

Step 1: browseProducts

GET /products captures the catalog as products (session-scoped JSON). The after hook then prepares data for later steps:

  • $find(... match=first ...) and $find(... match=index:1 ...) pick two affordable item ids into firstItemId and secondItemId.
  • $find(... match=count ...) stores affordableCount.
  • $uuid(prefix=order-, ...) mints an idempotency key in orderKey.
  • $counter(... variable=userId) produces userId.

Step 2: addToCart

POST /carts builds a cart from values found in step 1.

  • skipIf: '${affordableCount} < 2' prevents sending a cart request when there are fewer than two matching products.
  • The response is captured as cart, so ${cart.Body.id} is available downstream.
  • The after hook shows arithmetic from variables via $set(variable=totalQuantity, value=${firstItemQty}+${secondItemQty}, as=int).

Step 3: checkout

POST /auth/login authenticates and captures the response as auth. The token is available at ${auth.Body.token}. A skipIf guard prevents login when no cart exists.

Step 4: placeOrder

The final step updates /carts/${cart.Body.id} to model checkout completion on this mock API.

  • URL uses the captured cart id.
  • Headers include idempotency key (${orderKey}) and bearer token (${auth.Body.token}).
  • Body uses ${userId}, ${totalQuantity}, selected product ids with ${firstItemQty} and ${secondItemQty}, and $timestamp(format=yyyy-MM-dd).
  • Retry is based on ${order.StatusCode} and only retries on server errors (>= 500).

Important: LPS has no built-in ${LastResponse}. You can only reference response data that you explicitly captured (as this example does with order).

Concepts worth calling out

Session scope keeps clients isolated

Captured values and method-stored variables are session-scoped by default. With 25 clients, no one overwrites another client's cart, auth, or userId. If you need shared values, use global scope explicitly.

There is no built-in ${clientId}

LPS does not expose a per-virtual-client id placeholder. Generate per-user values with methods such as $counter, $randomnumber, or $uuid.

Termination and failure rules run at run level

terminationRules and failureRules evaluate aggregate metrics with no session context. If you need a method-produced value in a rule, it must be global (isGlobal=true).

Caveats about the mock API

fakestoreapi.com is a mock and does not persist real commerce state:

  • POST /carts always returns id: 11.
  • PUT /carts/{id} echoes back the body you send (plus id: 11).
  • POST /auth/login returns a token, but that token is not enforced on /carts.

The example still correctly demonstrates placeholder resolution, capture-and-reuse, step chaining, retries, and per-session isolation. For a real checkout test, replace the final PUT /carts/{id} with your real order endpoint.