Plugins, Custom Components, JMeter APIs, and Extension Strategy: Guided Hands-On Workflow
The mandatory lab installs no third-party plugin. Instead it creates one harmless custom Java sampler whose domain logic has no JMeter dependency. This makes the logic easy to unit-test and keeps the JMeter adapter small enough to review against the official public API.
Learning objectives
- Execute a bounded hands-on workflow for Plugins, Custom Components, JMeter APIs, and Extension Strategy using the course's current runtime and authorized local or synthetic resources.
- Build and verify the concrete lab artifacts step by step instead of treating configuration snippets as isolated examples.
- Preserve the JTL, jmeter.log, target, generator, and configuration evidence required by the workflow before interpreting results.
- Distinguish configured state from achieved behavior, and stop when safety, count, environment, or generator-validity conditions are not met.
- Explain how the completed workflow prepares the configuration and trade-off analysis in the next lesson.
1. Disposable project layout
p32-extension-lab/
├── src/main/java/devops/academy/p32/SafeIdCore.java
├── src/main/java/devops/academy/p32/SafeIdSampler.java
├── src/test/java/devops/academy/p32/SafeIdCoreTest.java
├── extensions/
├── locks/
├── plans/
│ ├── core-smoke.jmx
│ └── extension-demo.jmx
├── config/extension.properties
├── fixtures/extension_target.py
├── tools/
│ ├── inventory_extensions.py
│ ├── lock_extension.py
│ ├── check_compatibility.py
│ └── map_jmx_dependencies.py
├── build/
└── results/
127.0.0.1:8032, 2 threads ×5 loops,
10 target requests, 10 local Java samples and 20 JTL rows maximum.
Abort on unexpected classpath JAR, hash/version mismatch,
ClassNotFound/NoSuchMethodError, non-loopback target, >10 target
requests, generator saturation or missing evidence.
2. Create the loopback target
fixtures/extension_target.py:
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse, parse_qs
import argparse, json, re, threading, time
FIXTURE_VERSION = "prompt32-extension-fixture-v1"
SAFE = re.compile(r"^[A-Za-z0-9_.:-]{1,96}$")
lock = threading.Lock()
event_log = None
max_requests = 10
state = {"requests": 0, "successes": 0, "rejections": 0}
def snapshot():
with lock:
return {
"fixture_version": FIXTURE_VERSION,
"max_requests": max_requests,
**state,
}
def write_event(event):
with lock:
with event_log.open("a", encoding="utf-8") as h:
h.write(json.dumps(event, sort_keys=True) + "\n")
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def send_json(self, status, payload):
raw = json.dumps(payload, sort_keys=True).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.send_header("X-Fixture-Version", FIXTURE_VERSION)
self.end_headers()
self.wfile.write(raw)
def do_GET(self):
parsed = urlparse(self.path)
if parsed.path == "/health":
self.send_json(200, {"status": "ok", "state": snapshot()})
return
if parsed.path == "/stats":
self.send_json(200, {"status": "ok", "state": snapshot()})
return
if parsed.path != "/work":
self.send_json(404, {"status": "not_found"})
return
q = parse_qs(parsed.query)
run_id = q.get("run_id", [""])[0]
seq = q.get("seq", [""])[0]
if not SAFE.fullmatch(run_id) or not SAFE.fullmatch(seq):
self.send_json(400, {"status": "invalid_metadata"})
return
with lock:
state["requests"] += 1
request_no = state["requests"]
if request_no > max_requests:
with lock:
state["rejections"] += 1
self.send_json(429, {"status": "sample_ceiling"})
return
time.sleep(0.020)
with lock:
state["successes"] += 1
ended = int(time.time() * 1000)
self.send_json(200, {"status": "ok", "synthetic": True, "seq": seq})
write_event({
"ts_ms": ended,
"operation": "work",
"status": 200,
"run_id": run_id,
"seq": seq,
"service_ms": 20,
})
def log_message(self, format, *args):
return
def main():
p = argparse.ArgumentParser()
p.add_argument("--host", default="127.0.0.1")
p.add_argument("--port", type=int, default=8032)
p.add_argument("--max-requests", type=int, default=10)
p.add_argument("--log", required=True)
args = p.parse_args()
if args.host != "127.0.0.1":
raise SystemExit("fixture may bind only to loopback")
if not 1 <= args.max_requests <= 10:
raise SystemExit("max-requests must be 1..10")
global event_log, max_requests
max_requests = args.max_requests
event_log = Path(args.log).resolve()
event_log.parent.mkdir(parents=True, exist_ok=True)
event_log.write_text("", encoding="utf-8")
print(f"fixture_version={FIXTURE_VERSION}", flush=True)
print(f"listen=http://127.0.0.1:{args.port}", flush=True)
print(f"max_requests={max_requests}", flush=True)
ThreadingHTTPServer((args.host, args.port), Handler).serve_forever()
if __name__ == "__main__":
main()
This fixture exists to keep the chapter tied to the safe-load-test contract from Chapter31. The custom sampler itself performs local deterministic work; the sibling HTTP sample verifies that the JMX still executes against an authorized bounded target.
3. Inventory core versus external JARs
tools/inventory_extensions.py:
import argparse, hashlib, json, zipfile
from pathlib import Path
def sha256(path):
h = hashlib.sha256()
with Path(path).open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def manifest_fields(path):
fields = {}
try:
with zipfile.ZipFile(path) as z:
raw = z.read("META-INF/MANIFEST.MF").decode("utf-8", errors="replace")
current = None
for line in raw.splitlines():
if line.startswith(" ") and current:
fields[current] += line[1:]
continue
if ": " in line:
k, v = line.split(": ", 1)
current = k
fields[k] = v
except Exception:
pass
return fields
def classify(path, jmeter_home, project_ext):
name = path.name
try:
rel = path.resolve().relative_to(jmeter_home.resolve())
rel_text = str(rel).replace("\\", "/")
except ValueError:
rel_text = ""
if project_ext and project_ext.resolve() in path.resolve().parents:
return "project-custom-extension"
if rel_text.startswith("lib/ext/") and name.startswith("ApacheJMeter_"):
return "apache-jmeter-distribution-component"
if rel_text.startswith("lib/ext/"):
return "external-jmeter-extension-or-plugin"
if rel_text.startswith("lib/"):
return "jmeter-utility-or-dependency"
return "other"
def main():
p = argparse.ArgumentParser()
p.add_argument("--jmeter-home", required=True)
p.add_argument("--project-extensions")
p.add_argument("--out", required=True)
args = p.parse_args()
home = Path(args.jmeter_home).resolve()
ext = Path(args.project_extensions).resolve() if args.project_extensions else None
roots = [home / "lib" / "ext", home / "lib"]
if ext:
roots.append(ext)
seen = set()
jars = []
for root in roots:
if not root.exists():
continue
for jar in sorted(root.glob("*.jar")):
key = str(jar.resolve())
if key in seen:
continue
seen.add(key)
mf = manifest_fields(jar)
jars.append({
"path": key,
"classification": classify(jar, home, ext),
"sha256": sha256(jar),
"bytes": jar.stat().st_size,
"implementation_title": mf.get("Implementation-Title"),
"implementation_version": mf.get("Implementation-Version"),
"bundle_version": mf.get("Bundle-Version"),
})
result = {
"jmeter_home": str(home),
"project_extensions": str(ext) if ext else None,
"jars": jars,
}
Path(args.out).write_text(json.dumps(result, indent=2), encoding="utf-8")
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()
Run before modification:
python .\tools\inventory_extensions.py `
--jmeter-home $env:JMETER_HOME `
--project-extensions .\extensions `
--out .\results\inventory-before.json
Anything in lib/ext that does not belong to the Apache
distribution deserves explanation. Do not infer “Apache core” merely
because a JAR appears in JMeter's menu.
4. Write unit-testable logic with no JMeter API
SafeIdCore.java:
package devops.academy.p32;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
public final class SafeIdCore {
private SafeIdCore() {
}
public static String makeId(String prefix, String input) {
String safePrefix = requireSafe(prefix, "prefix");
String safeInput = requireSafe(input, "input");
byte[] digest;
try {
digest = MessageDigest.getInstance("SHA-256")
.digest((safePrefix + "|" + safeInput).getBytes(StandardCharsets.UTF_8));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 unavailable", e);
}
StringBuilder hex = new StringBuilder(64);
for (byte b : digest) {
hex.append(String.format("%02x", b & 0xff));
}
return safePrefix + "-" + hex.substring(0, 12);
}
private static String requireSafe(String value, String field) {
if (value == null || !value.matches("[A-Za-z0-9_.:-]{1,96}")) {
throw new IllegalArgumentException(field + " must match [A-Za-z0-9_.:-]{1,96}");
}
return value;
}
}
The class is stateless, deterministic and uses only JDK17 APIs. It has no target/network/file/static cache. That makes correctness independent of JMeter lifecycle.
5. Unit-test the core before compiling the adapter
SafeIdCoreTest.java:
package devops.academy.p32;
public final class SafeIdCoreTest {
public static void main(String[] args) {
String a = SafeIdCore.makeId("p32", "thread-1:1");
String b = SafeIdCore.makeId("p32", "thread-1:1");
String c = SafeIdCore.makeId("p32", "thread-2:1");
if (!a.equals(b)) {
throw new AssertionError("same input must be deterministic");
}
if (a.equals(c)) {
throw new AssertionError("different input should change the generated id");
}
if (!a.matches("p32-[0-9a-f]{12}")) {
throw new AssertionError("unexpected id shape: " + a);
}
boolean rejected = false;
try {
SafeIdCore.makeId("p32", "unsafe value with spaces");
} catch (IllegalArgumentException expected) {
rejected = true;
}
if (!rejected) {
throw new AssertionError("unsafe input should be rejected");
}
System.out.println("PASS SafeIdCoreTest " + a);
}
}
PowerShell:
New-Item -ItemType Directory -Force .\build\classes | Out-Null
javac --release 17 `
-d .\build\classes `
.\src\main\java\devops\academy\p32\SafeIdCore.java `
.\src\test\java\devops\academy\p32\SafeIdCoreTest.java
java -cp .\build\classes devops.academy.p32.SafeIdCoreTest
Require PASS SafeIdCoreTest ... before JMeter is
involved.
6. Add the thin JMeter adapter
SafeIdSampler.java:
package devops.academy.p32;
import java.nio.charset.StandardCharsets;
import org.apache.jmeter.protocol.java.sampler.AbstractJavaSamplerClient;
import org.apache.jmeter.protocol.java.sampler.JavaSamplerContext;
import org.apache.jmeter.samplers.SampleResult;
public final class SafeIdSampler extends AbstractJavaSamplerClient {
private String prefix;
@Override
public void setupTest(JavaSamplerContext context) {
// Instance field: JMeter creates a JavaSamplerClient instance per user/thread.
this.prefix = context.getParameter("prefix", "p32");
}
@Override
public SampleResult runTest(JavaSamplerContext context) {
SampleResult result = new SampleResult();
result.setSampleLabel("SafeIdSampler");
result.sampleStart();
try {
String input = context.getParameter("input", "synthetic");
String generated = SafeIdCore.makeId(prefix, input);
result.setResponseData(generated.getBytes(StandardCharsets.UTF_8));
result.setDataType(SampleResult.TEXT);
result.setResponseCode("200");
result.setResponseMessage("OK");
result.setSuccessful(true);
} catch (RuntimeException e) {
result.setResponseCode("500");
result.setResponseMessage(e.getClass().getSimpleName() + ": " + e.getMessage());
result.setSuccessful(false);
} finally {
result.sampleEnd();
}
return result;
}
}
The only mutable field is prefix, populated in
setupTest() on the per-thread JavaSamplerClient
instance. runTest() creates one SampleResult and
delegates deterministic work to SafeIdCore. No static
mutable state, singleton client, file handle or cross-thread cache
exists.
7. Compile against JMeter5.6.3 public API
PowerShell:
$JMeterApi = @(
"$env:JMETER_HOME\lib\ext\ApacheJMeter_core.jar",
"$env:JMETER_HOME\lib\ext\ApacheJMeter_java.jar",
".\build\classes"
) -join ";"
javac --release 17 `
-cp $JMeterApi `
-d .\build\classes `
.\src\main\java\devops\academy\p32\SafeIdSampler.java
New-Item -ItemType Directory -Force .\extensions | Out-Null
jar --create `
--file .\extensions\p32-safeid-sampler-1.0.0.jar `
-C .\build\classes devops\academy\p32
Linux/macOS-like shell uses : instead of
; in the compiler classpath. The adapter intentionally
uses only classes documented in the current public Javadocs.
8. Generate the extension compatibility lock
tools/lock_extension.py:
import argparse, hashlib, json
from pathlib import Path
def sha256(path):
h = hashlib.sha256()
with Path(path).open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def main():
p = argparse.ArgumentParser()
p.add_argument("--jar", required=True)
p.add_argument("--core-source", required=True)
p.add_argument("--adapter-source", required=True)
p.add_argument("--test-source", required=True)
p.add_argument("--out", required=True)
args = p.parse_args()
jar = Path(args.jar).resolve()
sources = [Path(args.core_source).resolve(), Path(args.adapter_source).resolve(), Path(args.test_source).resolve()]
result = {
"schema_version": 1,
"jmeter": {"version": "5.6.3"},
"java": {"compile_release": 17, "runtime_major": 17},
"custom_extensions": [{
"id": "p32-safeid-sampler",
"version": "1.0.0",
"jar": str(jar),
"sha256": sha256(jar),
"source_origin": "chapter-local-source",
"entry_class": "devops.academy.p32.SafeIdSampler",
"public_api_boundary": [
"org.apache.jmeter.protocol.java.sampler.AbstractJavaSamplerClient",
"org.apache.jmeter.protocol.java.sampler.JavaSamplerContext",
"org.apache.jmeter.samplers.SampleResult"
],
"source_sha256": {str(s): sha256(s) for s in sources}
}],
"third_party_plugins": [],
"notes": [
"Mandatory lab installs no third-party plugin.",
"Keep only one active jar containing the entry_class in the extension search path."
]
}
Path(args.out).write_text(json.dumps(result, indent=2), encoding="utf-8")
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()
python .\tools\lock_extension.py `
--jar .\extensions\p32-safeid-sampler-1.0.0.jar `
--core-source .\src\main\java\devops\academy\p32\SafeIdCore.java `
--adapter-source .\src\main\java\devops\academy\p32\SafeIdSampler.java `
--test-source .\src\test\java\devops\academy\p32\SafeIdCoreTest.java `
--out .\locks\extensions.lock.json
The lock contains the actual built JAR hash and source hashes; there is no hand-written checksum placeholder.
9. Verify runtime compatibility before load
tools/check_compatibility.py:
import argparse, hashlib, json, os, re, shutil, subprocess
from pathlib import Path
def sha256(path):
h = hashlib.sha256()
with Path(path).open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def run_jmeter(exe):
exe = str(exe)
if os.name == "nt" and exe.lower().endswith((".bat", ".cmd")):
cmdline = subprocess.list2cmdline([exe, "-v"])
return subprocess.run(["cmd.exe", "/d", "/s", "/c", cmdline], capture_output=True, text=True, timeout=20)
return subprocess.run([exe, "-v"], capture_output=True, text=True, timeout=20)
def java_major(text):
m = re.search(r'version\s+"(\d+)(?:\.(\d+))?', text)
if not m:
return None
first = int(m.group(1))
if first == 1 and m.group(2):
return int(m.group(2))
return first
def main():
p = argparse.ArgumentParser()
p.add_argument("--lock", required=True)
p.add_argument("--jmeter-home", required=True)
p.add_argument("--out", required=True)
args = p.parse_args()
lock = json.loads(Path(args.lock).read_text(encoding="utf-8"))
home = Path(args.jmeter_home).resolve()
jmeter = home / "bin" / ("jmeter.bat" if os.name == "nt" else "jmeter")
j = run_jmeter(jmeter)
jmeter_text = (j.stdout or "") + (j.stderr or "")
java = subprocess.run(["java", "-version"], capture_output=True, text=True, timeout=20)
java_text = (java.stdout or "") + (java.stderr or "")
checks = []
expected_jmeter = lock["jmeter"]["version"]
checks.append({
"name": "jmeter_version",
"pass": j.returncode == 0 and expected_jmeter in jmeter_text,
"expected": expected_jmeter
})
expected_java = int(lock["java"]["runtime_major"])
actual_java = java_major(java_text)
checks.append({
"name": "java_major",
"pass": actual_java == expected_java,
"expected": expected_java,
"actual": actual_java
})
for ext in lock["custom_extensions"]:
jar = Path(ext["jar"])
checks.append({
"name": f"jar_sha256:{ext['id']}",
"pass": jar.exists() and sha256(jar) == ext["sha256"],
"expected": ext["sha256"],
"actual": sha256(jar) if jar.exists() else None
})
result = {
"status": "PASS" if all(c["pass"] for c in checks) else "FAIL",
"checks": checks,
"jmeter_version_output": jmeter_text.strip(),
"java_version_output": java_text.strip(),
}
Path(args.out).write_text(json.dumps(result, indent=2), encoding="utf-8")
print(json.dumps(result, indent=2))
raise SystemExit(0 if result["status"] == "PASS" else 3)
if __name__ == "__main__":
main()
python .\tools\check_compatibility.py `
--lock .\locks\extensions.lock.json `
--jmeter-home $env:JMETER_HOME `
--out .\results\compatibility.json
Require exact JMeter5.6.3, Java17 and current JAR hash before the extension-dependent JMX is started.
10. Author the JMX and dependency map
JMX tree:
Test Plan
├── HTTP Request Defaults -> 127.0.0.1:8032
└── Thread Group — extension compatibility
2 threads × 5 loops
Action after Sampler error = Stop Thread
├── Counter -> SEQ (per user)
├── Java Request — SafeIdSampler
│ Classname: devops.academy.p32.SafeIdSampler
│ prefix = p32
│ input = T${__threadNum}:${SEQ}
└── HTTP Request — LocalTarget
GET /work?run_id=p32-check&seq=T${__threadNum}:${SEQ}
├── Constant Timer 150 ms
└── Response Assertion: HTTP response code = 200
Configured ceiling:
- 10 custom Java samples
- 10 loopback HTTP samples
- 20 JTL rows total
- 10 target requests maximum
config/extension.properties:
target.host=127.0.0.1
target.port=8032
threads=2
loops=5
pacing.ms=150
jmeter.save.saveservice.output_format=csv
jmeter.save.saveservice.print_field_names=true
jmeter.save.saveservice.timestamp_format=ms
jmeter.save.saveservice.time=true
jmeter.save.saveservice.label=true
jmeter.save.saveservice.response_code=true
jmeter.save.saveservice.response_message=true
jmeter.save.saveservice.thread_name=true
jmeter.save.saveservice.successful=true
jmeter.save.saveservice.bytes=true
jmeter.save.saveservice.sent_bytes=true
jmeter.save.saveservice.thread_counts=true
jmeter.save.saveservice.latency=true
jmeter.save.saveservice.connect_time=true
jmeter.save.saveservice.response_data=false
jmeter.save.saveservice.response_data.on_error=false
jmeter.save.saveservice.samplerData=false
jmeter.save.saveservice.responseHeaders=false
jmeter.save.saveservice.requestHeaders=false
jmeter.save.saveservice.url=false
tools/map_jmx_dependencies.py:
import argparse, json, re
from pathlib import Path
CLASS_PATTERNS = [
r"devops\.academy\.p32\.[A-Za-z0-9_$]+",
r"org\.jmeterplugins\.[A-Za-z0-9_.$]+",
r"kg\.apc\.[A-Za-z0-9_.$]+",
r"com\.blazemeter\.[A-Za-z0-9_.$]+",
]
def main():
p = argparse.ArgumentParser()
p.add_argument("--jmx", required=True)
p.add_argument("--lock", required=True)
p.add_argument("--out", required=True)
args = p.parse_args()
text = Path(args.jmx).read_text(encoding="utf-8", errors="replace")
lock = json.loads(Path(args.lock).read_text(encoding="utf-8"))
classes = sorted({
match
for pattern in CLASS_PATTERNS
for match in re.findall(pattern, text)
})
locked = {
ext["entry_class"]: {
"id": ext["id"],
"version": ext["version"],
"sha256": ext["sha256"],
"jar": ext["jar"],
}
for ext in lock.get("custom_extensions", [])
}
mapped = []
unresolved = []
for cls in classes:
if cls in locked:
mapped.append({"class": cls, **locked[cls]})
else:
unresolved.append(cls)
result = {
"jmx": str(Path(args.jmx).resolve()),
"external_or_custom_classes": classes,
"locked_dependencies": mapped,
"unresolved_classes": unresolved,
"status": "PASS" if not unresolved else "FAIL"
}
Path(args.out).write_text(json.dumps(result, indent=2), encoding="utf-8")
print(json.dumps(result, indent=2))
raise SystemExit(0 if result["status"] == "PASS" else 4)
if __name__ == "__main__":
main()
Run the dependency map against the saved JMX. It must map
devops.academy.p32.SafeIdSampler to the locked custom
JAR with no unresolved third-party class.
11. Clean startup without the extension
First prove the base JMeter installation remains healthy:
& "$env:JMETER_HOME\bin\jmeter.bat" -v
# Run a core-only local smoke JMX that contains no custom class.
& "$env:JMETER_HOME\bin\jmeter.bat" `
-n -t .\plans\core-smoke.jmx `
-l .\results\core-only.jtl `
-j .\results\core-only-jmeter.log
Then deliberately run extension-demo.jmx
without search_paths. Expect the custom Java
class to be unavailable and preserve the failed
JTL/jmeter.log. This demonstrates that JMX
compatibility really depends on the locked extension.
12. Run with the isolated extension path
Start the fixture with max10, then:
$ExtPath = (Resolve-Path .\extensions).Path
& "$env:JMETER_HOME\bin\jmeter.bat" `
"-Jsearch_paths=$ExtPath" `
-n `
-t .\plans\extension-demo.jmx `
-q .\config\extension.properties `
-l .\results\extension\results.jtl `
-j .\results\extension\jmeter.log
Expected: 10 SafeIdSampler samples +10 LocalTarget samples=20 JTL rows, target stats=10 successes, zero rejections. The Java sample's response body is not retained by the lean result policy.
13. Inventory after and prove rollback
python .\tools\inventory_extensions.py `
--jmeter-home $env:JMETER_HOME `
--project-extensions .\extensions `
--out .\results\inventory-after.json
# Rollback: run the core-only plan again without -Jsearch_paths.
& "$env:JMETER_HOME\bin\jmeter.bat" `
-n -t .\plans\core-smoke.jmx `
-l .\results\rollback-core.jtl `
-j .\results\rollback-core-jmeter.log
Because no JAR was copied into the shared JMeter home, rollback is explicit and reversible.
14. Optional Plugins Manager boundary
No third-party plugin is required. If your organization later uses the maintained JMeter-Plugins.org Plugins Manager, install/pin it only in an isolated JMeter image/home, review its proposed dependency changes, record the exact manager/plugin versions and hashes, and decide whether its documented anonymous usage reporting fits policy. Do not let “Install latest” replace the compatibility lock.
15. Challenge
A helper needs only ten lines of string transformation used in one JMX. Should you immediately publish a custom plugin?
Probably not. Prefer core functions or a cached JSR223 Groovy helper if it remains small and local. Choose custom Java when reuse, testability, performance, API/lifecycle control or distribution discipline justify the additional JAR/upgrade burden.
Knowledge check
Why compile SafeIdCore before the JMeter adapter?
It proves the reusable logic independently and keeps JMeter lifecycle/classpath failures separate from domain-logic failures.
Why use a project search_paths directory instead of copying the JAR to shared lib/ext?
It makes activation/rollback explicit and reduces pollution/conflict in the base JMeter installation.
How many target requests does the checkpoint-style plan generate?
2 threads ×5 loops ×1 HTTP sampler =10 target requests; the local Java sampler adds 10 JTL samples but no network traffic.
What proves JMX compatibility?
The dependency map resolves the custom class to the exact locked JAR/hash and the compatibility check matches JMeter/Java versions.
What should happen if the extension-dependent plan is run without the custom JAR?
Preserve the class-loading failure as evidence; do not copy random JARs until it starts.
Official references and version notes
- Apache JMeter downloads — current stable JMeter 5.6.3 and Java 8+ requirement.
- JMeter current changes — Java 17+ recommendation for the 5.6.x line.
-
JMeter Getting Started / classpath
—
lib/ext,lib,search_paths,user.classpathand classpath behavior. - JMeter Properties Reference — current classpath properties and result fields.
- AbstractJavaSamplerClient Javadocs — recommended adapter base class and setup/run/teardown lifecycle.
- JavaSamplerClient Javadocs — per-thread instance behavior and lifecycle.
- AbstractFunction Javadocs — function lifecycle/thread-safety notes.
- JMeter Best Practices — JSR223/Groovy and compiled-script caching guidance.
- JMeter Remote Testing — identical JMeter/Java versions across nodes and distributed runtime behavior.
- JMeter Plugins Manager — optional third-party plugin-management tooling maintained outside Apache JMeter.
Version-sensitive statements were rechecked against current
primary/maintainer documentation on 2026-09-05. The mandatory
runtime is Apache JMeter 5.6.3 with Java 17 and
no third-party plugin. JMeter discovers JMeter component/plugin
jars in JMETER_HOME/lib/ext; dependency/utility jars
belong in JMETER_HOME/lib. Additional
component/plugin paths can be provided with
search_paths; utility/dependency paths use
user.classpath or
plugin_dependency_paths. The
CLASSPATH environment variable does not affect normal
JMeter startup because JMeter is launched with
java -jar. Official Javadocs encourage custom Java
samplers to extend AbstractJavaSamplerClient rather
than implement JavaSamplerClient directly. JMeter
creates a JavaSamplerClient instance per user/thread and calls
setup/run/teardown around that thread's lifecycle. Functions
differ: a function occurrence can be executed by multiple threads,
so mutable non-thread-safe function state needs synchronization or
thread-local design. Current JMeter best practices continue to
prefer cached JSR223 Groovy over BeanShell for intensive
scripting. The JMeter Plugins Manager is maintained by
JMeter-Plugins.org, not by the Apache JMeter project; the
mandatory lab intentionally installs none. If a team adopts it or
any managed plugin, record the exact version, source and SHA-256
and review install/update changes before execution.
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.