Chapter 32Lesson 02~360 minutes

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.

JAR inventoryPure Java coreAbstractJavaSamplerClientCompatibility lockRollback

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/
Lab ceiling: JMeter5.6.3/Java17, no external plugin, target only 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?

Why use a project search_paths directory instead of copying the JAR to shared lib/ext?

How many target requests does the checkpoint-style plan generate?

What proves JMX compatibility?

What should happen if the extension-dependent plan is run without the custom JAR?

Next lesson

Choose the smallest extension mechanism

Lesson3 compares core components, third-party plugins, cached JSR223 Groovy, custom Java, GUI-only helpers and release/source strategies.

Official references and version notes

Version and compatibility note

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.

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