Chapter 13Lesson 02~230 minutes

Recording Web Traffic, HTTP(S) Test Script Recorder, and Certificate Setup: Guided Hands-On Workflow

The workflow deliberately separates four states: local fixture TLS, browser recorder-proxy settings, browser trust in JMeter's temporary CA, and the final JMeter replay plan. Keeping these states separate is what makes certificate cleanup and correlation diagnosis safe.

Disposable FirefoxLocal HTTPSRecorder filtersClean JMXSecret scan

Learning objectives

  • Start a reproducible HTTP+HTTPS fixture using only the Java 17 JDK.
  • Create a disposable Firefox profile and route it only through the local recorder.
  • Capture HTTP first, then HTTPS with the temporary recorder CA trusted only in that profile.
  • Filter static resources and preserve the raw recording.
  • Refactor the capture into two Transaction Controllers with defaults, Cookie Manager, and correlation.
  • Remove proxy/trust state before executing a bounded CLI replay.

1. Hard safety envelope

Authorized targets: only http://localhost:8080 and https://localhost:8443. Recorder listens only on 127.0.0.1:8888. Use one disposable browser profile. Clean replay uses 1 thread × 3 loops maximum. Close unrelated browser windows/tabs/apps before enabling the proxy. Abort immediately if any non-local host appears in the recorded tree.

2. Create the fixture's short-lived localhost certificate

This certificate belongs to the target fixture, not the recorder. The Java JDK's keytool creates a PKCS#12 keystore:

PowerShell:

New-Item -ItemType Directory -Force fixtures, results | Out-Null

keytool -genkeypair `
  -alias localfixture `
  -keyalg RSA `
  -keysize 2048 `
  -validity 2 `
  -storetype PKCS12 `
  -keystore fixtures\fixture.p12 `
  -storepass changeit `
  -keypass changeit `
  -dname "CN=localhost, OU=JMeter Lab, O=DevOps Academy, C=US" `
  -ext "SAN=dns:localhost,ip:127.0.0.1"

Bash:

mkdir -p fixtures results
keytool -genkeypair   -alias localfixture   -keyalg RSA   -keysize 2048   -validity 2   -storetype PKCS12   -keystore fixtures/fixture.p12   -storepass changeit   -keypass changeit   -dname "CN=localhost, OU=JMeter Lab, O=DevOps Academy, C=US"   -ext "SAN=dns:localhost,ip:127.0.0.1"

The password is a disposable lab value. Do not copy this pattern to production certificates.

3. Compile and start the dual HTTP/HTTPS fixture

Save as fixtures/RecorderFixture.java:

import com.sun.net.httpserver.Headers;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import com.sun.net.httpserver.HttpsConfigurator;
import com.sun.net.httpserver.HttpsServer;

import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyStore;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;

public final class RecorderFixture {
    private static final AtomicInteger SEQ = new AtomicInteger();
    private static final Map<String, Flow> FLOWS = new ConcurrentHashMap<>();
    private static Path eventLog;

    private record Flow(String flowId, String csrf) {}

    public static void main(String[] args) throws Exception {
        if (args.length != 3) {
            System.err.println("usage: RecorderFixture <fixture.p12> <password> <event-log.jsonl>");
            System.exit(2);
        }
        Path keyStorePath = Path.of(args[0]).toAbsolutePath();
        char[] password = args[1].toCharArray();
        eventLog = Path.of(args[2]).toAbsolutePath();
        Files.createDirectories(eventLog.getParent());
        Files.writeString(eventLog, "", StandardCharsets.UTF_8);

        SSLContext sslContext = sslContext(keyStorePath, password);

        HttpServer http = HttpServer.create(new InetSocketAddress("127.0.0.1", 8080), 0);
        HttpsServer https = HttpsServer.create(new InetSocketAddress("127.0.0.1", 8443), 0);
        https.setHttpsConfigurator(new HttpsConfigurator(sslContext));

        configure(http);
        configure(https);

        http.setExecutor(Executors.newFixedThreadPool(4));
        https.setExecutor(Executors.newFixedThreadPool(4));
        http.start();
        https.start();

        System.out.println("http_fixture=http://localhost:8080/begin");
        System.out.println("https_fixture=https://localhost:8443/begin");
        System.out.println("event_log=" + eventLog);
    }

    private static SSLContext sslContext(Path p12, char[] password) throws Exception {
        KeyStore ks = KeyStore.getInstance("PKCS12");
        try (var in = Files.newInputStream(p12)) {
            ks.load(in, password);
        }
        KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
        kmf.init(ks, password);
        SSLContext context = SSLContext.getInstance("TLS");
        context.init(kmf.getKeyManagers(), null, null);
        return context;
    }

    private static void configure(HttpServer server) {
        server.createContext("/health", exchange -> send(exchange, 200, "application/json",
            "{\"status\":\"ok\"}".getBytes(StandardCharsets.UTF_8)));
        server.createContext("/begin", new BeginHandler());
        server.createContext("/finish", new FinishHandler());
        server.createContext("/static/site.css", exchange -> send(exchange, 200, "text/css",
            "body{font-family:sans-serif}.card{max-width:42rem;margin:3rem auto}".getBytes(StandardCharsets.UTF_8)));
        server.createContext("/static/app.js", exchange -> send(exchange, 200, "application/javascript",
            "console.log('synthetic recorder fixture');".getBytes(StandardCharsets.UTF_8)));
        server.createContext("/static/pixel.gif", exchange -> send(exchange, 200, "image/gif",
            new byte[]{'G','I','F','8','9','a'}));
        server.createContext("/stats", exchange -> {
            String body = "{\"active_flows\":" + FLOWS.size() + ",\"sequence\":" + SEQ.get() + "}";
            send(exchange, 200, "application/json", body.getBytes(StandardCharsets.UTF_8));
        });
    }

    private static final class BeginHandler implements HttpHandler {
        @Override
        public void handle(HttpExchange exchange) throws IOException {
            int n = SEQ.incrementAndGet();
            String flowId = "FLOW-" + String.format("%05d", n);
            String csrf = "CSRF-" + String.format("%05d", n);
            String session = "LABSESSION-" + String.format("%05d", n);
            FLOWS.put(session, new Flow(flowId, csrf));

            Headers h = exchange.getResponseHeaders();
            h.add("Set-Cookie", "LABSESSION=" + session + "; Path=/; Secure; HttpOnly; SameSite=Lax");

            String html = """
                <!doctype html>
                <html><head>
                  <meta charset="utf-8">
                  <title>Recorder Fixture</title>
                  <link rel="stylesheet" href="/static/site.css">
                  <script src="/static/app.js"></script>
                </head><body>
                  <main class="card">
                    <h1>Synthetic checkout</h1>
                    <img src="/static/pixel.gif" alt="" width="1" height="1">
                    <form method="post" action="/finish">
                      <input type="hidden" name="flow_id" value="%s">
                      <input type="hidden" name="csrf" value="%s">
                      <label>Email <input name="email" value="student@example.invalid"></label>
                      <button type="submit">Complete</button>
                    </form>
                  </main>
                </body></html>
                """.formatted(flowId, csrf);

            log(exchange, 200, flowId, session, "begin");
            send(exchange, 200, "text/html; charset=utf-8", html.getBytes(StandardCharsets.UTF_8));
        }
    }

    private static final class FinishHandler implements HttpHandler {
        @Override
        public void handle(HttpExchange exchange) throws IOException {
            if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) {
                log(exchange, 405, "", "", "method_not_allowed");
                send(exchange, 405, "application/json",
                    "{\"status\":\"method_not_allowed\"}".getBytes(StandardCharsets.UTF_8));
                return;
            }

            String requestBody = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
            Map<String, String> form = parseForm(requestBody);
            String flowId = form.getOrDefault("flow_id", "");
            String csrf = form.getOrDefault("csrf", "");
            String session = cookie(exchange.getRequestHeaders().getFirst("Cookie"), "LABSESSION");

            Flow expected = FLOWS.get(session);
            boolean ok = expected != null
                && expected.flowId().equals(flowId)
                && expected.csrf().equals(csrf)
                && "student@example.invalid".equals(form.get("email"));

            if (ok) {
                FLOWS.remove(session);
                log(exchange, 200, flowId, session, "accepted");
                String html = "<!doctype html><html><body><h1>Accepted</h1><p id=\"status\">accepted</p></body></html>";
                send(exchange, 200, "text/html; charset=utf-8", html.getBytes(StandardCharsets.UTF_8));
            } else {
                log(exchange, 409, flowId, session, "correlation_failure");
                String body = "{\"status\":\"rejected\",\"reason\":\"correlation_or_cookie_mismatch\"}";
                send(exchange, 409, "application/json", body.getBytes(StandardCharsets.UTF_8));
            }
        }
    }

    private static Map<String, String> parseForm(String body) {
        Map<String, String> out = new HashMap<>();
        if (body == null || body.isBlank()) return out;
        for (String pair : body.split("&")) {
            String[] parts = pair.split("=", 2);
            String key = URLDecoder.decode(parts[0], StandardCharsets.UTF_8);
            String value = parts.length == 2
                ? URLDecoder.decode(parts[1], StandardCharsets.UTF_8)
                : "";
            out.put(key, value);
        }
        return out;
    }

    private static String cookie(String header, String name) {
        if (header == null) return "";
        for (String part : header.split(";")) {
            String trimmed = part.trim();
            int idx = trimmed.indexOf('=');
            if (idx > 0 && name.equals(trimmed.substring(0, idx))) {
                return trimmed.substring(idx + 1);
            }
        }
        return "";
    }

    private static void send(HttpExchange exchange, int status, String contentType, byte[] body) throws IOException {
        exchange.getResponseHeaders().set("Content-Type", contentType);
        exchange.getResponseHeaders().set("Cache-Control", "no-store");
        exchange.sendResponseHeaders(status, body.length);
        try (OutputStream out = exchange.getResponseBody()) {
            out.write(body);
        }
    }

    private static synchronized void log(HttpExchange exchange, int status, String flow, String session, String note)
            throws IOException {
        String json = "{"
            + "\"ts\":\"" + Instant.now() + "\","
            + "\"method\":\"" + escapeJson(exchange.getRequestMethod()) + "\","
            + "\"path\":\"" + escapeJson(exchange.getRequestURI().getPath()) + "\","
            + "\"status\":" + status + ","
            + "\"flow\":\"" + escapeJson(flow) + "\","
            + "\"session\":\"" + escapeJson(session) + "\","
            + "\"note\":\"" + escapeJson(note) + "\""
            + "}\n";
        Files.writeString(eventLog, json, StandardCharsets.UTF_8,
            java.nio.file.StandardOpenOption.CREATE,
            java.nio.file.StandardOpenOption.APPEND);
    }

    private static String escapeJson(String s) {
        if (s == null) return "";
        return s.replace("\\", "\\\\").replace("\"", "\\\"");
    }
}

Compile and run:

javac -d fixtures fixtures\RecorderFixture.java
java -cp fixtures RecorderFixture fixtures\fixture.p12 changeit results\server-events.jsonl

The fixture exposes /begin on ports 8080 and 8443. The page loads synthetic CSS/JS/image noise and submits a dynamic flow_id + csrf form to /finish. A secure LABSESSION cookie binds the flow.

4. Preflight target and tool state

java -version
javac -version
keytool -help | Select-Object -First 5

(Invoke-WebRequest -UseBasicParsing http://localhost:8080/health).Content

# For HTTPS preflight, curl may require -k because the fixture certificate is intentionally self-signed.
curl.exe -k https://localhost:8443/health

-k is used only for the local disposable target preflight. It is not a recommendation to disable verification for real environments.

5. Create a disposable Firefox profile

Use a dedicated profile directory that contains no normal browsing state. PowerShell example:

$Profile = Join-Path $env:TEMP "jmeter-recorder-profile"
Remove-Item -Recurse -Force $Profile -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force $Profile | Out-Null

# Adjust path if Firefox is installed elsewhere.
$Firefox = "$env:ProgramFiles\Mozilla Firefox\firefox.exe"
& $Firefox -no-remote -profile $Profile

Linux example:

PROFILE="$(mktemp -d)"
firefox -no-remote -profile "$PROFILE"
Do not use your normal profile. Close unrelated browser windows. Disable extensions/sync in the lab profile. This prevents unrelated requests and credentials from crossing the recorder proxy.

6. Create the recorder authoring tree

In JMeter GUI, the Recording template is acceptable, or build:

Test Plan
├── HTTP Request Defaults
│   Server: localhost
├── HTTP Cookie Manager          # for later replay; browser handles cookies during recording
├── Thread Group — "Clean Replay"
│   └── Recording Controller — "Raw Capture Target"
└── HTTP(S) Test Script Recorder
    Port: 8888
    Target Controller: Raw Capture Target

Do not run the Clean Replay Thread Group while recording. Recorder is an authoring proxy; meaningful load execution comes after refactoring.

7. Configure Firefox proxy narrowly

In the disposable Firefox profile → Network Settings:

  • Manual proxy configuration.
  • HTTP Proxy: 127.0.0.1, Port 8888.
  • HTTPS Proxy: 127.0.0.1, Port 8888 (or use the “also use for HTTPS” option).
  • Clear localhost/127.0.0.1 from “No proxy for” so the local fixture actually crosses JMeter.

If the browser still works against the fixture while the recorder is stopped, the browser is bypassing the recorder—fix the proxy path before continuing.

8. First capture: local HTTP without CA trust

Recorder filters for the first tiny capture:

Include: localhost:8080/.*
Exclude: (leave empty for this first observation)

Start Recorder, navigate to http://localhost:8080/begin, and submit the form once. Stop Recorder immediately.

Expected raw capture includes business requests plus /static/site.css, /static/app.js, and /static/pixel.gif. This draft proves why filtering is necessary. Save it as raw-http-unfiltered.jmx; do not load-test it.

9. Add capture filters before HTTPS recording

Clear or use a fresh Recording Controller. Configure:

Include:
localhost:8443/.*

Exclude:
.*\.(?i:css|js|png|gif|svg|ico)(\?.*)?

Remember: patterns are checked against host+port+path+query, not a full URL containing https://.

10. Start Recorder and inspect the generated CA before trusting it

Start the recorder. JMeter generates recorder certificate material when needed and shows its root CA details. With JMeter launched from its bin directory, the exported file is normally there:

keytool -printcert -file .\ApacheJMeterTemporaryRootCA.crt

Compare subject, validity, and fingerprint/details with the JMeter dialog. Do not import a different file just because the filename looks familiar.

11. Import recorder CA only into the disposable Firefox profile

Firefox profile → Settings → Privacy & Security → Certificates → View Certificates → Authorities → Import. Choose the exact ApacheJMeterTemporaryRootCA.crt just inspected, verify details, and allow it to identify websites only inside this disposable profile.

Never use the Windows/OS “Install Certificate” wizard for this lab. That can place the recorder CA in a wider system trust store. If your browser relies only on OS-managed roots and cannot isolate this trust, use disposable Firefox instead.

12. Record the short HTTPS journey

With Recorder running and filters active:

  1. Navigate to https://localhost:8443/begin.
  2. Confirm the synthetic page appears through the proxy.
  3. Click Complete once.
  4. Stop Recorder immediately.
  5. Save this untouched draft as raw-https-filtered.jmx.

Expected stored business requests: GET /begin and POST /finish. Static assets crossed the proxy but were excluded from capture.

13. Remove proxy and CA trust before refactoring

  1. Stop the JMeter Recorder.
  2. Set Firefox Network Settings back to No proxy/System proxy.
  3. Remove the JMeter recorder CA from the disposable profile Authorities list, or close and delete the entire disposable profile directory.
  4. Verify the normal browser profile was never changed.

Do this before running the cleaned load plan. Recorder trust is authoring state, not load-runtime state.

14. Inventory the raw JMX

Search for:

  • literal FLOW-##### and CSRF-##### values in POST parameters;
  • any literal LABSESSION-#####, Cookie, Authorization, bearer token, password, or non-local host;
  • duplicate host/protocol/port repeated on every sampler;
  • static resources or redirects that survived filtering;
  • recorded browser headers that are not needed for the performance question.

15. Refactor into a small reusable plan

Test Plan
├── User Defined Variables
│   LAB_HOST = localhost
│   LAB_PORT = 8443
├── HTTP Request Defaults
│   Protocol=https; Server=${LAB_HOST}; Port=${LAB_PORT}
├── HTTP Cookie Manager
└── Thread Group — 1 user × 3 loops maximum
    ├── Transaction Controller — "Start Flow"
    │   └── Begin — GET /begin
    │       ├── CSS Selector Extractor
    │       │   FLOW_ID = input[name=flow_id] -> value
    │       └── CSS Selector Extractor
    │           CSRF_TOKEN = input[name=csrf] -> value
    └── Transaction Controller — "Complete Flow"
        └── Finish — POST /finish
            flow_id=${FLOW_ID}
            csrf=${CSRF_TOKEN}
            email=student@example.invalid

Add correctness assertions: Begin must expose both hidden fields; Finish must return HTTP 200 and contain the synthetic accepted marker. The cookie comes from HTTP Cookie Manager, not a hard-coded Cookie header.

16. Scan raw and cleaned plans

Save as tools/scan_jmx.py:

import re
import sys
from pathlib import Path

if len(sys.argv) < 2:
    raise SystemExit("usage: scan_jmx.py <plan.jmx> [--clean]")

path = Path(sys.argv[1])
clean_mode = "--clean" in sys.argv[2:]
text = path.read_text(encoding="utf-8", errors="replace")

checks = {
    "literal_flow": re.compile(r"FLOW-\d{5}"),
    "literal_csrf": re.compile(r"CSRF-\d{5}"),
    "literal_session": re.compile(r"LABSESSION-\d{5}"),
    "authorization_header": re.compile(r"(?i)\bAuthorization\b"),
    "bearer_value": re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+/\-=]{8,}"),
    "nonlocal_http_target": re.compile(
        r"https?://(?!(?:localhost|127\.0\.0\.1)(?::\d+)?(?:/|$))[A-Za-z0-9.-]+",
        re.I,
    ),
}

hits = []
for name, pattern in checks.items():
    found = sorted(set(pattern.findall(text)))
    if found:
        hits.append((name, found[:10]))

print(f"file={path}")
print(f"mode={'clean' if clean_mode else 'inventory'}")
if hits:
    for name, found in hits:
        print(f"HIT {name}: {found}")
else:
    print("No configured suspicious patterns found.")

if clean_mode and hits:
    raise SystemExit(
        "Clean-plan scan failed. Investigate each hit; do not merely weaken the scanner."
    )

Inventory raw capture:

python tools/scan_jmx.py raw-https-filtered.jmx

Require clean plan to pass:

python tools/scan_jmx.py cleaned-two-transactions.jmx --clean

A scanner is a guardrail, not proof that a plan contains no secret. Review Header Managers, parameters, filenames, properties, and external data sources manually too.

17. Preserve a cleaned-JMX diff

git diff --no-index -- raw-https-filtered.jmx cleaned-two-transactions.jmx > cleaned-jmx.diff

Expected conceptual diff: remove static/noise/browser-specific headers; centralize protocol/host/port; add Cookie Manager; replace literal dynamic values with variables; add extractors/assertions; group requests into two business transactions.

18. Execute only the cleaned plan in CLI mode

jmeter.bat -n `
  -t cleaned-two-transactions.jmx `
  -l results\cleaned.jtl `
  -j results\cleaned-jmeter.log

Maximum 1 thread × 3 loops. Preserve JTL, matching jmeter.log, and target events. Do not replay the raw recording as a load test.

19. Analyze target evidence

Save as tools/analyze_recorder_events.py:

import json
import sys
from collections import Counter
from pathlib import Path

path = Path(sys.argv[1] if len(sys.argv) > 1 else "results/server-events.jsonl")
events = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]

print(f"events={len(events)}")
print(f"methods={dict(Counter(e['method'] for e in events))}")
print(f"paths={dict(Counter(e['path'] for e in events))}")
print(f"statuses={dict(Counter(e['status'] for e in events))}")
print(f"notes={dict(Counter(e['note'] for e in events))}")

accepted = [e for e in events if e["note"] == "accepted"]
failed = [e for e in events if e["note"] == "correlation_failure"]
print(f"accepted_finishes={len(accepted)}")
print(f"correlation_failures={len(failed)}")

For a 3-loop cleaned run, expected business target events are three /begin and three accepted /finish events; no static assets are explicitly replayed by the cleaned plan.

20. Challenge

The recorder captured ten requests: two business API calls, four static assets, two analytics beacons, one third-party font, and one redirect. What should determine the final plan?

Start from the performance question and business causality. Keep/construct only requests needed to reproduce the intended server-side journey, correlation, and protocol state. Do not preserve requests merely because the browser happened to make them.

Knowledge check

Why record HTTP once before HTTPS?

Which certificate is imported into Firefox?

Why is the raw POST not replay-safe?

Why must the recorder stop and trust be removed before CLI replay?

What proves cleaned correlation works?

Next lesson

Choose recorder use deliberately

Lesson 3 compares manual construction with recorder bootstrap, HTTP with HTTPS, filter depth, embedded-resource strategies, profile isolation, and immutable raw captures versus repeated recording.

Official references and version notes

Version and compatibility note

Version-sensitive statements were rechecked against current Apache JMeter primary documentation on 2026-09-05. The course baseline remains Apache JMeter 5.6.3 with a Java 17 JDK and no third-party JMeter plugins; JMeter 5.6.3 requires Java 8+. A JDK is preferred here because HTTPS recording needs the keytool utility. HTTP(S) Test Script Recorder is a local HTTP/HTTPS proxy, default port 8888. Include/Exclude patterns are regular expressions evaluated against the full host/port/path/query string; requests still pass through the proxy even when a filter prevents them from being stored. With Java 8+ dynamic certificate mode, JMeter generates per-host certificates signed by its temporary root CA; the default recorder certificate validity is 7 days via proxy.cert.validity. The exported ApacheJMeterTemporaryRootCA.crt is created when recorder certificates are generated and must be removed from browser trust after use. Anyone who can access the recorder keystore/private-key material while a browser trusts that CA has a powerful interception capability, so the mandatory lab imports it only into a disposable Firefox profile. During recording, the browser manages cookies; the cleaned JMeter replay plan needs an HTTP Cookie Manager. JMeter's HTTP samplers accept server certificates broadly for test flexibility, so the synthetic localhost target can use its own short-lived self-signed fixture certificate without importing that target certificate into the browser or OS trust store.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.