What you'll build
By the end of this guide you will take an existing JMeter test plan (.jmx) and run it through PerfScale — first locally via the CLI, then on the platform where the plan executes on a PerfScale agent and reports into the same dashboard as your k6-style tests. PerfScale doesn't replace JMeter: it runs your plan with the real jmeter binary, streams the output live, and translates the final console summary into the k6-compatible metrics the rest of the tooling understands.
You'll need:
- Apache JMeter installed and on
PATH(jmeter --versionshould work; JMeter needs a JRE) - A perfscale binary (OSS build is enough for the CLI part)
- A running PerfScale platform (controlplane + agent) for Steps 4–5 — the agent Docker image ships with JMeter 5.6.3, so agents need no extra setup
Step 1 — Install and verify JMeter
Download JMeter from jmeter.apache.org and put its bin/ on PATH, or use a package manager:
# macOS
brew install jmeter
# any OS with the archive: add apache-jmeter-*/bin to PATH
jmeter --version
PerfScale shells out to jmeter -n -t <plan> — the plain jmeter command must resolve. If it doesn't, the run fails immediately with a clear error:
jmeter not found in PATH — install from https://jmeter.apache.org/download_jmeter.cgi (requires a JRE)
Step 2 — Run an existing .jmx plan
Take a trimmed plan: 5 threads × 20 loops issuing GET / against a local server on port 18999, with a response assertion on status 200. Save it as plan.jmx:
<?xml version="1.0" encoding="UTF-8"?>
<jmeterTestPlan version="1.2" properties="5.0" jmeter="5.6.3">
<hashTree>
<TestPlan guiclass="TestPlanGui" testclass="TestPlan" testname="demo" enabled="true">
<elementProp name="TestPlan.user_defined_variables" elementType="Arguments" guiclass="ArgumentsPanel" testclass="Arguments" testname="User Defined Variables" enabled="true">
<collectionProp name="Arguments.arguments"/>
</elementProp>
</TestPlan>
<hashTree>
<ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="users" enabled="true">
<stringProp name="ThreadGroup.num_threads">5</stringProp>
<stringProp name="ThreadGroup.ramp_time">1</stringProp>
<elementProp name="ThreadGroup.main_controller" elementType="LoopController" guiclass="LoopControlPanel" testclass="LoopController" testname="Loop Controller" enabled="true">
<boolProp name="LoopController.continue_forever">false</boolProp>
<stringProp name="LoopController.loops">20</stringProp>
</elementProp>
<stringProp name="ThreadGroup.on_sample_error">continue</stringProp>
</ThreadGroup>
<hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="GET /" enabled="true">
<stringProp name="HTTPSampler.domain">127.0.0.1</stringProp>
<stringProp name="HTTPSampler.port">18999</stringProp>
<stringProp name="HTTPSampler.protocol">http</stringProp>
<stringProp name="HTTPSampler.path">/</stringProp>
<stringProp name="HTTPSampler.method">GET</stringProp>
<hashTree>
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="status is 200" enabled="true">
<collectionProp name="Asserion.test_strings">
<stringProp name="49586">200</stringProp>
</collectionProp>
<stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
<intProp name="Assertion.test_type">8</intProp>
</ResponseAssertion>
<hashTree/>
</hashTree>
</HTTPSamplerProxy>
<hashTree/>
</hashTree>
</hashTree>
</hashTree>
</jmeterTestPlan>
Start a trivial target server, then run the plan through PerfScale:
python3 -m http.server 18999 & # any local HTTP server works
perfscale run --jmeter plan.jmx
During the run you see JMeter's raw output streamed live — startup log, periodic summary + progress lines, and the final summary = aggregate. When the process exits, PerfScale appends a translated k6-compatible summary block so JMeter results land in the same format as every other engine:
summary = 100 in 00:00:01 = 109.2/s Avg: 4 Min: 3 Max: 72 Err: 0 (0.00%)
http_req_duration......: avg=4.00ms min=3.00ms max=72.00ms
http_req_failed........: 0.00%
http_reqs..............: 100 109.2/s
No percentiles. JMeter's console summary only carries the run totals, so the translated
http_req_durationline has avg/min/max only — no p50/p95/p99. If you need percentiles from a JMeter plan, have the plan write its own result file (-l/ a results listener) and analyze that separately, or re-express the scenario as a native perfscale config where percentiles come from the engine itself.
Two more behavior notes worth knowing:
- Sample failures don't fail the run. JMeter exits non-zero on plan/startup errors, but failed samples (like assertion errors) don't change the exit code unless the plan itself gates on them — watch
http_req_failedin the summary. - JMeter owns the load shape. Thread counts, loops, and timers all come from the plan; perfscale passes no
-Jproperties (next step).
Step 3 — Parameterize the plan with ${__P(...)}
PerfScale intentionally passes no -J properties to JMeter — parameterize inside the plan itself so defaults live next to the values they tune. JMeter's ${__P(name, default)} function reads a property with a fallback, so the same plan runs standalone and can still be overridden when you wrap the invocation:
<stringProp name="ThreadGroup.num_threads">${__P(vus,5)}</stringProp>
<stringProp name="LoopController.loops">${__P(loops,20)}</stringProp>
<stringProp name="HTTPSampler.port">${__P(port,18999)}</stringProp>
If you need to inject values at runtime, wrap the jmeter call in a small script that sets -J flags and point perfscale run at the same plan — the plan stays the single source of truth either way.
Step 4 — Move the plan to the platform
Local CLI runs are for development; scheduled, tracked runs live on the platform:
- In the controlplane, create a new test and pick the JMeter test type.
- Paste the plan XML — the plan is stored in the test config, not as a loose file.
- Run the test. It's dispatched to an agent via
POST /api/v1/run/jmeter; the agent Docker image already includes JMeter 5.6.3, so the plan just runs. - Watch the live log streaming in the run view — the same raw JMeter output you saw locally, plus the translated summary at the end.
- The finished run shows the k6-compatible summary in the same dashboard as your k6 tests:
http_reqs,http_req_failed, andhttp_req_duration(avg/min/max).
This is the main payoff: JMeter plans and native perfscale configs become comparable in one place — same run history, same summary shape, same dashboards.
Step 5 — CI usage
Local runs gate like any other command — JMeter exits non-zero on startup/plan errors, so a broken plan fails the pipeline:
# .github/workflows/jmeter-smoke.yml
- name: JMeter smoke test
run: perfscale run --jmeter plan.jmx
Remember what the exit code does and doesn't cover: a plan that runs but whose requests all fail still exits 0 — gate on http_req_failed from the translated summary (or add result-gating inside the plan) if you want sample failures to fail the build. The CI runner also needs jmeter on PATH; on the platform side this is a non-issue since the agent image bundles JMeter 5.6.3.
Troubleshooting
jmeter not found in PATH— install JMeter and make sure itsbin/directory is onPATHfor the user running perfscale; verify withjmeter --version.- Run passes but
http_req_failedis high — expected: JMeter's exit code reflects plan/startup errors, not failed samples. Read the error percentage in the translated summary and gate on it yourself. - No translated summary at the end — the plan produced no final
summary =console line (e.g. it crashed mid-run or the summary was redirected); you'll see ano jmeter 'summary =' line capturednotice in the output. Check the raw JMeter log above it for the real error. - Missing percentiles in the dashboard — by design: JMeter's console summary carries avg/min/max only, and that's all the translated block reports.
- XML edits rejected by JMeter — note that XML comments in
.jmxfiles may not contain double hyphens (--), and keep thehashTreenesting structure intact when hand-editing.