Extending JMeter

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

JMeter was designed so that almost everything in it can be replaced or extended by a jar dropped into lib/ext: samplers, config elements, timers, assertions, listeners, functions, Backend Listener clients and the charts of the HTML dashboard. This page shows how to write each of the extension points a load-testing team is likely to need, how to build them with Maven against the JMeter 5.6.3 jars, how JMeter 5.6 finds them, how to deploy them on every load generator and how to publish them so that others can install them with the JMeter Plugins Manager.

Versions this page was written against, re-checked on 2026-10-14:

  • Apache JMeter 5.6.3, the latest release. It runs on Java 8 or later; Java 17 or later is recommended and the examples are compiled with release 17 and run with a current JDK. Its Javadoc is published at https://jmeter.apache.org/api/index.html and matches 5.6.3 (it already documents the 5.6.3 method JMeterGUIComponent.makeTestElement).

  • JMeter 6.0 is not released. The changes planned for it come from xdocs/changes.xml on the apache/jmeter master branch and are described as planned, not as shipped behaviour. The ones that matter for extensions are summarised in From the books to 5.6 to 6.0.

  • Maven 3.9 and the JMeter artifacts org.apache.jmeter:ApacheJMeter_core, ApacheJMeter_java and ApacheJMeter_components 5.6.3 from Maven Central.

  • JMeter Plugins Manager 2.0 and the jmeter-maven-plugin 3.8.0, for installing the result.

Every API on this page comes from the JMeter user manual chapter How to write a plugin for JMeter, the Javadoc and the 5.6.3 source code (tag rel/v5.6.3 of apache/jmeter); where the manual is silent the page says that the behaviour was read from the source. The example project on this page, bookshelf-jmeter-ext, was compiled against the 5.6.3 artifacts and run in CLI mode on a JMeter 5.6.3 installation against a local stand-in for the Bookshelf API; the outputs shown are from those runs.

Do you need Java at all? Many one-off needs are met faster without a plugin: a JSR223 Sampler or PostProcessor in Groovy (see Scripting with JSR223 & Groovy), the __groovy function, or an existing plugin from the catalogue (see Plugins & Plugins Manager). Write a Java extension when the code is reused across plans and teams, needs a proper form in the GUI, must be unit tested and versioned like any other library, or must be as cheap as possible on the load generator.

Choosing an extension point

You need Extension point Base class to extend Found by

To call a Java client library (vendor SDK, proprietary protocol) with parameters, without writing a GUI

A Java Request client

org.apache.jmeter.protocol.java.sampler.AbstractJavaSamplerClient

ServiceLoader and class scanning

A reusable sampler (or config element, timer, assertion, pre/post-processor) with its own form in the GUI, generated from bean properties

A TestBean

org.apache.jmeter.samplers.AbstractSampler (or ConfigTestElement, …​) plus TestBean, a BeanInfoSupport subclass and a resource bundle

Class scanning

The same, with a hand-made Swing form

A test element plus a GUI class

AbstractSampler and org.apache.jmeter.samplers.gui.AbstractSamplerGui (or the matching GUI base class)

Class scanning

A new ${__name(...)} function usable in any field

A function

org.apache.jmeter.functions.AbstractFunction

ServiceLoader and class scanning

To send results somewhere while the test runs (a database, a queue, an observability backend)

A Backend Listener client

org.apache.jmeter.visualizers.backend.AbstractBackendListenerClient

ServiceLoader and class scanning

A new chart in the HTML dashboard report

A graph consumer

org.apache.jmeter.report.processor.graph.AbstractOverTimeGraphConsumer (or AbstractGraphConsumer)

Class name in user.properties

GUI visualizers (AbstractVisualizer, GraphListener) are deliberately left out. The tutorial itself now opens those sections by saying that load testing in GUI mode is a bad practice, that such plugins should not be developed, and that the web report and real-time results through a BackendListenerClient are the up-to-date alternatives; this page covers exactly those two.

The extension points at a glance

The class diagram shows the types a plugin author touches. Classes in the com.example.bookshelf.jmeter packages are the examples built on this page; everything else is JMeter 5.6.3 API.

classDiagram direction TB class TestElement { <<interface>> +getPropertyAsString(String) String +setProperty(String, String) } class Sampler { <<interface>> +sample(Entry) SampleResult } class TestBean { <<interface>> } class JavaSamplerClient { <<interface>> +setupTest(JavaSamplerContext) +runTest(JavaSamplerContext) SampleResult +teardownTest(JavaSamplerContext) +getDefaultParameters() Arguments } class Function { <<interface>> +execute(SampleResult, Sampler) String +setParameters(Collection) void +getReferenceKey() String +getArgumentDesc() List } class BackendListenerClient { <<interface>> +setupTest(BackendListenerContext) +handleSampleResults(List, BackendListenerContext) +teardownTest(BackendListenerContext) +createSampleResult(BackendListenerContext, SampleResult) SampleResult } class AbstractTestElement { <<abstract>> } class AbstractSampler { <<abstract>> } class BeanInfoSupport { <<abstract>> #createPropertyGroup(String, String[]) #property(String) PropertyDescriptor } class AbstractJavaSamplerClient { <<abstract>> } class AbstractFunction { <<abstract>> #checkParameterCount(Collection, int, int) #addVariableValue(String, CompoundVariable[], int) } class AbstractBackendListenerClient { <<abstract>> #getSamplerMetric(String) SamplerMetric } class AbstractGraphConsumer { <<abstract>> #createGroupInfos() Map } class AbstractOverTimeGraphConsumer { <<abstract>> #createTimeStampKeysSelector() TimeStampKeysSelector } Sampler --|> TestElement AbstractTestElement ..|> TestElement AbstractSampler --|> AbstractTestElement AbstractSampler ..|> Sampler JavaSampler --|> AbstractSampler JavaSampler --> JavaSamplerClient : runs AbstractJavaSamplerClient ..|> JavaSamplerClient BookSearchClient --|> AbstractJavaSamplerClient BookLookupSampler --|> AbstractSampler BookLookupSampler ..|> TestBean BookLookupSamplerBeanInfo --|> BeanInfoSupport BookLookupSamplerBeanInfo ..> BookLookupSampler : describes TestBeanGUI ..> BeanInfoSupport : builds form from AbstractFunction ..|> Function RandomIsbnFunction --|> AbstractFunction BackendListener --> BackendListenerClient : feeds batches AbstractBackendListenerClient ..|> BackendListenerClient SlaBreachBackendListenerClient --|> AbstractBackendListenerClient AbstractOverTimeGraphConsumer --|> AbstractGraphConsumer SlowSamplesPerSecondGraphConsumer --|> AbstractOverTimeGraphConsumer

Three of the interfaces, JavaSamplerClient, Function and BackendListenerClient, carry the 5.6 annotation @org.apache.jorphan.reflect.JMeterService, which marks them as loadable through Java’s ServiceLoader (see Registration: ServiceLoader and class scanning). Samplers, TestBeans and GUI classes are still found by scanning the jars.

The project: a Maven build against JMeter 5.6.3

Which artifact holds which API

JMeter publishes each of its jars to Maven Central under the group org.apache.jmeter, with the same names as the files in lib/ext. A plugin compiles against the ones that hold the APIs it uses:

Artifact APIs used on this page

ApacheJMeter_core

TestElement, AbstractSampler, SampleResult, TestBean, BeanInfoSupport, AbstractSamplerGui, Function, AbstractFunction, CompoundVariable, the report generator (AbstractGraphConsumer and its helpers), JMeterUtils. Brings jorphan (home of JMeterService and ClassFinder) transitively.

ApacheJMeter_java

JavaSamplerClient, AbstractJavaSamplerClient, JavaSamplerContext (the Java Request).

ApacheJMeter_components

BackendListenerClient, AbstractBackendListenerClient, BackendListenerContext (the Backend Listener lives in the components jar, not in core).

ApacheJMeter_functions, ApacheJMeter_http, …​

Only if the plugin builds on a bundled function or protocol, for example by extending an HTTP sampler.

The pom.xml

All JMeter artifacts use provided scope: they are already in every JMeter installation, so the plugin jar must not bundle them (a second copy of JMeter classes in lib/ext causes class-loading conflicts). The manifest entry is explained in Registration: ServiceLoader and class scanning.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>com.example.bookshelf</groupId>
  <artifactId>bookshelf-jmeter-ext</artifactId>
  <version>1.0.0</version>
  <packaging>jar</packaging>

  <properties>
    <jmeter.version>5.6.3</jmeter.version>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>

  <dependencies>
    <!-- Already in JMeter's lib/ext: compile against them, never package them -->
    <dependency>
      <groupId>org.apache.jmeter</groupId>
      <artifactId>ApacheJMeter_core</artifactId>
      <version>${jmeter.version}</version>
      <scope>provided</scope>
    </dependency>
    <dependency>
      <groupId>org.apache.jmeter</groupId>
      <artifactId>ApacheJMeter_java</artifactId>
      <version>${jmeter.version}</version>
      <scope>provided</scope>
    </dependency>
    <dependency>
      <groupId>org.apache.jmeter</groupId>
      <artifactId>ApacheJMeter_components</artifactId>
      <version>${jmeter.version}</version>
      <scope>provided</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-jar-plugin</artifactId>
        <version>3.4.2</version>
        <configuration>
          <archive>
            <manifestEntries>
              <JMeter-Skip-Class-Scanning>true</JMeter-Skip-Class-Scanning>
            </manifestEntries>
          </archive>
        </configuration>
      </plugin>
    </plugins>
  </build>
</project>

Choosing the Java release. The bytecode level of the plugin sets the oldest Java that can load it. JMeter 5.6.3 itself still runs on Java 8, so a plugin meant for every 5.6.3 user would have to be compiled with release 8. release 17 matches the recommendation for 5.6.3 and the planned minimum of 6.0; a JMeter started on an older JVM then fails to load the class with an UnsupportedClassVersionError. The examples also use java.net.http, which needs Java 11.

Own dependencies. Libraries the plugin needs and JMeter does not ship use the default compile scope and are deployed next to it in lib (or shaded into the plugin jar with relocated packages). Never shade a library that JMeter already has in lib (Jackson, Commons, Log4j, Groovy): two versions on one class path fail in ways that depend on load order.

Layout and build

bookshelf-jmeter-ext/
├── pom.xml
└── src/main/
    ├── java/com/example/bookshelf/jmeter/
    │   ├── BookSearchClient.java                       Java Request client
    │   ├── sampler/BookLookupSampler.java              TestBean sampler
    │   ├── sampler/BookLookupSamplerBeanInfo.java      its GUI description
    │   ├── functions/RandomIsbnFunction.java           ${__randomIsbn(...)}
    │   ├── backend/SlaBreachBackendListenerClient.java Backend Listener client
    │   └── report/SlowSamplesPerSecondGraphConsumer.java dashboard graph
    └── resources/
        ├── com/example/bookshelf/jmeter/sampler/BookLookupSamplerResources.properties
        └── META-INF/services/
            ├── org.apache.jmeter.protocol.java.sampler.JavaSamplerClient
            ├── org.apache.jmeter.functions.Function
            └── org.apache.jmeter.visualizers.backend.BackendListenerClient
mvn -B clean package
[INFO] Building bookshelf-jmeter-ext 1.0.0
[INFO] --- resources:3.4.0:resources (default-resources) @ bookshelf-jmeter-ext ---
[INFO] Copying 4 resources from src/main/resources to target/classes
[INFO] --- compiler:3.15.0:compile (default-compile) @ bookshelf-jmeter-ext ---
[INFO] Compiling 6 source files with javac [debug release 17] to target/classes
[INFO] --- jar:3.4.2:jar (default-jar) @ bookshelf-jmeter-ext ---
[INFO] Building jar: ./target/bookshelf-jmeter-ext-1.0.0.jar
[INFO] BUILD SUCCESS

The jar is about 16 KB: six classes, one resource bundle and three service files, with no JMeter class inside.

JMeter itself is built with Gradle (./gradlew runGui, ./gradlew createDist), which the tutorial describes for contributors to JMeter. A plugin does not need JMeter’s build: it only compiles against the published jars, so Maven or Gradle both work. The official Maven artifacts are the same for both.

Java Request clients

The Java Request sampler and the BookSearchClient example are described field by field on Other Samplers; this section adds what a plugin author needs on top of that. The example client in that section is the first class of this project, unchanged.

Life cycle

Method When JMeter calls it

getDefaultParameters()

When the class is selected in the GUI and when JMeter needs to know the parameters; the names and default values returned fill the parameter table. JMeter may create extra instances just to call it.

setupTest(JavaSamplerContext)

Once per thread, on that thread’s own instance, when the test starts. Read and parse parameters and build clients here, not in runTest.

runTest(JavaSamplerContext)

Once per iteration. Must return a SampleResult with at least sampleStart()/sampleEnd() called, a label and the success flag.

teardownTest(JavaSamplerContext)

Once per thread at the end of the test, only if the class overrides it: the 5.6.3 JavaSampler checks whether teardownTest is declared in AbstractJavaSamplerClient and registers the instance for the end-of-test call only when a subclass redeclares it.

JavaSamplerContext gives access to the parameters and to JMeter’s state:

Method Returns

getParameter(name), getParameter(name, default)

The parameter as a string, after functions and variables were evaluated for this sample.

getIntParameter(name[, default]), getLongParameter(name[, default])

The parameter parsed as a number; the one-argument forms throw NumberFormatException.

containsParameter(name), getParameterNamesIterator()

Whether a parameter exists; all names.

getJMeterContext(), getJMeterVariables(), getJMeterProperties()

The thread’s context, its variables (username, token, bookId, …​) and the JMeter properties (threads, host, port, …​).

Registration

The Classname drop-down of the Java Request lists the JavaSamplerClient implementations found through the ServiceLoader and by scanning the jars. The service file of this project is one line:

# src/main/resources/META-INF/services/org.apache.jmeter.protocol.java.sampler.JavaSamplerClient
com.example.bookshelf.jmeter.BookSearchClient

A Java Request has no form beyond the parameter table, no field validation and no menu entry of its own. When the element is used by several teams, or users keep getting parameter names wrong, a TestBean is the next step.

Custom samplers as TestBeans

The TestElement contract

Every element in a test plan is two objects: a test element (the model, which is saved in the .jmx and runs) and a GUI component (the form in the tree). The tutorial insists on a strict split: the GUI class is stateless and never keeps a reference to the element, because JMeter reuses one GUI instance for every element of that type. The GUI copies values into the element in modifyTestElement and back into the form in configure.

The TestBean framework removes the GUI class altogether. A class that implements the marker interface org.apache.jmeter.testbeans.TestBean gets a generic form, TestBeanGUI, built by introspection from its JavaBean properties (public getter and setter pairs). The tutorial’s recipe is three files in the same package:

File Role

BookLookupSampler.java

The element. Implements TestBean and extends the abstract class for its kind of element: AbstractSampler for a sampler, ConfigTestElement for a config element, and so on. Its bean properties become the form fields.

BookLookupSamplerBeanInfo.java

Extends org.apache.jmeter.testbeans.BeanInfoSupport with a no-argument constructor that calls super(BookLookupSampler.class) and then describes groups, defaults and editors.

BookLookupSamplerResources.properties

Resource bundle with the display names and tooltips, loaded by BeanInfoSupport as <bean class name>Resources for the current locale. Translations go in files such as BookLookupSamplerResources_es.properties.

How a TestBean runs. The tutorial notes that JMeter clones the element when the test starts, so every thread gets its own instance. Before each sample, JMeterThread calls TestBeanHelper.prepare(sampler), which evaluates the element’s properties (functions and variables included) and copies the results into the bean’s fields through the setters. Fields therefore hold the values for this sample of this thread, and ${bookId} in a field follows the thread’s variable. JMeter 5.6 cached the bean property metadata in TestBeanHelper to make this cheaper (PR 5920).

Menu category. TestBeanGUI decides the menu from the interfaces the element implements: a Sampler appears under Add → Sampler, a ConfigElement under Add → Config Element, and so on. The menu label is the bundle’s displayName.

Example: a Bookshelf book lookup sampler

The sampler sends GET /api/books/{id} to the Bookshelf API with the JDK HTTP client and marks the sample successful when the status equals the expected one. It stands in for any client library that has no JMeter sampler:

package com.example.bookshelf.jmeter.sampler;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;

import org.apache.jmeter.samplers.AbstractSampler;
import org.apache.jmeter.samplers.Entry;
import org.apache.jmeter.samplers.SampleResult;
import org.apache.jmeter.testbeans.TestBean;

/** GET /api/books/{id} as a sampler with its own GUI form. */
public class BookLookupSampler extends AbstractSampler implements TestBean {

    private static final long serialVersionUID = 1L;

    // HttpClient is thread-safe: one instance shared by every thread
    private static final HttpClient CLIENT = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(5))
            .build();

    // TestBean properties: JMeter copies the element's properties into these fields
    private String baseUrl;
    private String bookId;
    private int timeoutMs;
    private String expectedStatus;

    @Override
    public SampleResult sample(Entry entry) {
        SampleResult result = new SampleResult();
        result.setSampleLabel(getName());
        String url = baseUrl + "/api/books/" + bookId;
        result.setSamplerData("GET " + url);
        HttpRequest request = HttpRequest.newBuilder(URI.create(url))
                .timeout(Duration.ofMillis(timeoutMs))
                .GET()
                .build();
        result.sampleStart();
        try {
            HttpResponse<String> response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
            result.sampleEnd();
            String code = String.valueOf(response.statusCode());
            result.setResponseCode(code);
            result.setResponseData(response.body(), StandardCharsets.UTF_8.name());
            result.setDataType(SampleResult.TEXT);
            result.setSuccessful(code.equals(expectedStatus));
            result.setResponseMessage(result.isSuccessful() ? "OK" : "Expected " + expectedStatus);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            result.sampleEnd();
            result.setSuccessful(false);
            result.setResponseMessage(e.toString());
        } catch (Exception e) {
            result.sampleEnd();
            result.setResponseCode("500");
            result.setResponseMessage(e.toString());
            result.setSuccessful(false);
        }
        return result;
    }

    public String getBaseUrl() { return baseUrl; }
    public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; }
    public String getBookId() { return bookId; }
    public void setBookId(String bookId) { this.bookId = bookId; }
    public int getTimeoutMs() { return timeoutMs; }
    public void setTimeoutMs(int timeoutMs) { this.timeoutMs = timeoutMs; }
    public String getExpectedStatus() { return expectedStatus; }
    public void setExpectedStatus(String expectedStatus) { this.expectedStatus = expectedStatus; }
}

The bean info groups the four properties into two panels and sets defaults; expectedStatus becomes a drop-down that only accepts the listed values:

package com.example.bookshelf.jmeter.sampler;

import java.beans.PropertyDescriptor;

import org.apache.jmeter.testbeans.BeanInfoSupport;

public class BookLookupSamplerBeanInfo extends BeanInfoSupport {

    public BookLookupSamplerBeanInfo() {
        super(BookLookupSampler.class);

        createPropertyGroup("server", new String[] {"baseUrl", "timeoutMs"});
        createPropertyGroup("request", new String[] {"bookId", "expectedStatus"});

        PropertyDescriptor p = property("baseUrl");
        p.setValue(NOT_UNDEFINED, Boolean.TRUE);
        p.setValue(DEFAULT, "http://${__P(host,localhost)}:${__P(port,8080)}");

        p = property("timeoutMs");
        p.setValue(NOT_UNDEFINED, Boolean.TRUE);
        p.setValue(DEFAULT, 5000);

        p = property("bookId");
        p.setValue(NOT_UNDEFINED, Boolean.TRUE);
        p.setValue(DEFAULT, "${bookId}");

        p = property("expectedStatus");
        p.setValue(NOT_UNDEFINED, Boolean.TRUE);
        p.setValue(DEFAULT, "200");
        p.setValue(NOT_OTHER, Boolean.TRUE);
        p.setValue(NOT_EXPRESSION, Boolean.TRUE);
        p.setValue(TAGS, new String[] {"200", "404"});
    }
}

And the resource bundle, in src/main/resources under the same package path:

displayName=Bookshelf Book Lookup
server.displayName=Server
request.displayName=Request
baseUrl.displayName=Base URL
baseUrl.shortDescription=Scheme, host and port of the Bookshelf API, without a trailing slash
timeoutMs.displayName=Timeout (ms)
timeoutMs.shortDescription=Request timeout in milliseconds
bookId.displayName=Book id
bookId.shortDescription=Id of the book to fetch, usually ${bookId}
expectedStatus.displayName=Expected status
expectedStatus.shortDescription=HTTP status that marks the sample as successful

Property attributes

BeanInfoSupport exposes these attribute names as constants, to be set with property(name).setValue(...). The meanings come from the tutorial and the Javadoc of BeanInfoSupport and GenericTestBeanCustomizer:

Constant Type Meaning

NOT_UNDEFINED

Boolean

The property may not be left null (required). A DEFAULT must then be given.

DEFAULT

Object

Initial value in the form. Must be non-null if NOT_UNDEFINED is set, and one of the tags if NOT_OTHER is set.

NOT_EXPRESSION

Boolean

The value is a constant: it is not parsed for functions or ${var} references.

NOT_OTHER

Boolean

Not a free-form field: only values from TAGS (or null) are allowed.

TAGS

String[]

Predefined values; JMeter shows a drop-down.

MULTILINE

Boolean

A multi-line text editor instead of a one-line field.

DEFAULT_NOT_SAVED

Boolean

The value is saved in the .jmx only when it differs from the default.

TEXT_LANGUAGE

String

Language of a text-editor field; the Javadoc describes it only as "TextEditor property".

GenericTestBeanCustomizer.ORDER

Integer

Position within the group; smaller is higher. Properties with equal order are sorted alphabetically. createPropertyGroup sets the group and the order for you, in the order of the array.

A custom editor replaces the default one: property("dataFile").setPropertyEditorClass(FileEditor.class) gives a text field with a Browse button, as in the tutorial’s CSV example. GenericTestBeanCustomizer registers JMeter’s own editors for the Long, Integer and Boolean wrapper types; the example’s String and int properties were set correctly at run time.

The resource bundle keys are:

Key Used for

displayName

Name of the element in the Add menu and the default name in the tree.

<group>.displayName

Title of each group created with createPropertyGroup.

<property>.displayName

Label of the field.

<property>.shortDescription

Tooltip of the field.

When the bundle is missing, BeanInfoSupport logs Localized strings not available for bean ... and the form shows raw property names.

Using it in the Bookshelf plan

After deployment (see Deploying to lib/ext) the element appears under Add → Sampler → Bookshelf Book Lookup, with a Server group (Base URL, Timeout (ms)) and a Request group (Book id, Expected status). Saved, it looks like this; the element tag and testclass are the bean’s class name and guiclass is always TestBeanGUI:

<com.example.bookshelf.jmeter.sampler.BookLookupSampler guiclass="TestBeanGUI" testclass="com.example.bookshelf.jmeter.sampler.BookLookupSampler" testname="GET /api/books/{id} (TestBean)">
  <stringProp name="baseUrl">http://${__P(host,localhost)}:${__P(port,8080)}</stringProp>
  <stringProp name="bookId">${bookId}</stringProp>
  <intProp name="timeoutMs">5000</intProp>
  <stringProp name="expectedStatus">200</stringProp>
</com.example.bookshelf.jmeter.sampler.BookLookupSampler>
<hashTree/>

Like any plugin element, this tag makes the .jmx unloadable on a JMeter without the jar, which is what lets the Plugins Manager’s install-for-jmx find the plugin when it is published (see Publishing to the Plugins Manager repository).

Hand-written GUIs

When a generated form is not enough (tables, tabs, dependent fields), write the element without TestBean and pair it with a GUI class. The tutorial lists the base classes to extend: AbstractSamplerGui, AbstractAssertionGui, AbstractConfigGui, AbstractControllerGui, AbstractPostProcessorGui, AbstractPreProcessorGui, AbstractVisualizer and AbstractTimerGui. The contract, from the tutorial and the 5.6.3 JMeterGUIComponent Javadoc:

Method What to do

getLabelResource() / getStaticLabel()

getLabelResource returns a key of JMeter’s own messages.properties, which a plugin jar does not add to. Plugins therefore override getStaticLabel() to return the menu label directly, which the interface’s Javadoc allows "where the resource name is not available".

makeTestElement()

New in 5.6.3: return new YourSampler(). The default createTestElement() calls it and then assignDefaultValues, which sets the name, guiclass and testclass. A plugin that must also load on 5.6.2 or older overrides createTestElement() instead, creating the element and calling modifyTestElement on it, which was the canonical form before 5.6.3.

configure(TestElement)

Copy the element’s properties into the Swing fields. Call super.configure(element) first. Set every field, since the GUI instance is reused and would otherwise show stale values.

modifyTestElement(TestElement)

The reverse: call super.modifyTestElement(element), then write each field into the element. Treat an empty field as an absent property (remove it rather than store "").

clearGui()

Reset controls that do not map to properties (tabs, sliders).

Layout

Call setBorder(makeBorder()) and add makeTitlePanel() first, then the element’s own panel.

JMeter 5.6 also added TestElementSchema (PR 5944) to declare an element’s properties once and bind form fields to them automatically; the tutorial points to ThreadGroupSchema as the example. The API is marked experimental in 5.6.3. Complete, compilable examples of both styles ship in the JMeter source under src/examples: ExampleSampler/ExampleSamplerGui (hand-written GUI) and Example1 to Example3 (TestBeans).

Custom functions

The Function contract

A function is any class implementing org.apache.jmeter.functions.Function, normally through AbstractFunction:

Method Role

getReferenceKey()

The name used in plans. The Javadoc’s convention is to start it with two underscores, as in __randomIsbn.

getArgumentDesc()

One description per parameter, shown by the Function Helper dialog (Tools → Function Helper Dialog). Not optional: return a list with one (possibly blank) string per parameter.

setParameters(Collection<CompoundVariable>)

Called when the plan is compiled, with the raw parameters. Validate the count here (checkParameterCount, checkMinParameterCount) and keep the CompoundVariable objects; do not evaluate them yet.

execute(SampleResult, Sampler)

Called every time the field is evaluated. Evaluate each parameter with CompoundVariable.execute() and return the replacement text. addVariableValue(value, values, index) stores the result in a variable when the user passed a variable name at that position, the usual last-parameter convention of the built-in functions.

Threading. JMeter registers each function class once by its reference key and creates a new instance for each place the function appears in the plan; that instance is then shared by every thread that evaluates the field. The Function Javadoc states it plainly: execute "must be thread-safe - multiple threads will be using the same object". Keep state in local variables or in thread-safe structures, and use ThreadLocalRandom rather than a shared Random.

No caching since 5.6. FunctionProperty no longer caches the value per iteration (PR 5788), so execute runs each time the field is read. The old behaviour can be restored temporarily with the function.cache.per.iteration property. Keep execute cheap.

Example: __randomIsbn

The Bookshelf catalogue needs syntactically valid ISBN-13 values to create books and to probe searches for unknown books. ${__randomIsbn(979,isbn)} returns a random ISBN-13 with the given prefix and a correct check digit, and stores it in the variable isbn:

package com.example.bookshelf.jmeter.functions;

import java.util.Collection;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;

import org.apache.jmeter.engine.util.CompoundVariable;
import org.apache.jmeter.functions.AbstractFunction;
import org.apache.jmeter.functions.InvalidVariableException;
import org.apache.jmeter.samplers.SampleResult;
import org.apache.jmeter.samplers.Sampler;

/**
 * ${__randomIsbn(prefix,varName)}: a random, valid ISBN-13 such as 9781234567897.
 */
public class RandomIsbnFunction extends AbstractFunction {

    private static final String KEY = "__randomIsbn";
    private static final List<String> DESC = List.of(
            "Prefix, 978 or 979 (optional, default 978)",
            "Name of variable in which to store the result (optional)");

    private CompoundVariable[] values;

    @Override
    public void setParameters(Collection<CompoundVariable> parameters) throws InvalidVariableException {
        checkParameterCount(parameters, 0, 2);
        values = parameters.toArray(new CompoundVariable[0]);
    }

    @Override
    public String execute(SampleResult previousResult, Sampler currentSampler) {
        String prefix = values.length > 0 ? values[0].execute().trim() : "";
        if (prefix.isEmpty()) {
            prefix = "978";
        }
        StringBuilder isbn = new StringBuilder(prefix);
        ThreadLocalRandom random = ThreadLocalRandom.current();
        while (isbn.length() < 12) {
            isbn.append(random.nextInt(10));
        }
        int sum = 0;
        for (int i = 0; i < 12; i++) {
            sum += (isbn.charAt(i) - '0') * (i % 2 == 0 ? 1 : 3);
        }
        isbn.append((10 - sum % 10) % 10);
        String result = isbn.toString();
        addVariableValue(result, values, 1);
        return result;
    }

    @Override
    public String getReferenceKey() {
        return KEY;
    }

    @Override
    public List<String> getArgumentDesc() {
        return DESC;
    }
}

Its service file:

# src/main/resources/META-INF/services/org.apache.jmeter.functions.Function
com.example.bookshelf.jmeter.functions.RandomIsbnFunction

The class sits in a package whose name contains .functions.. The 5.6.3 jmeter.properties still ships classfinder.functions.contain=.functions. and classfinder.functions.notContain=.gui., with a comment saying that function classes not matching them are not loaded. In the 5.6.3 source, CompoundVariable only logs these two values and loads every Function it finds, but following the naming costs nothing and keeps the plugin safe with older releases and customised property files.

Used as the label of the bundled JavaTest client in a short run, each sample gets a fresh number (rows from the results file):

timeStamp,elapsed,label,...
1791977449081,109,isbn 9799220183907,...
1791977449327,113,isbn 9797458006036,...

Each value is a well-formed ISBN-13: for 9799220183907, the weighted sum of the first twelve digits (weights 1, 3, 1, 3, …​) is 103, and (10 - 103 % 10) % 10 = 7, the last digit.

Custom Backend Listener clients

The Backend Listener element, its queue and the three bundled clients are described on Real-time Monitoring with Backend Listener. A custom client is a class implementing BackendListenerClient, normally by extending AbstractBackendListenerClient (since 2.13).

Life cycle and threading

The 5.6.3 BackendListener source explains how a client runs:

  • One client per listener name. When the test starts, the first BackendListener instance with a given name creates the client, a bounded queue (Async Queue size, default 5000) and one daemon worker thread, then calls setupTest(context). Further BackendListener instances with the same name in the same JVM reuse them, so two listeners that should run separate clients need different names.

  • createSampleResult runs on the sampler threads. For every sample, the virtual user’s thread calls createSampleResult(context, result) and puts the returned object in the queue. Returning null drops the sample, and returning a trimmed copy reduces memory; this is the only client code that costs virtual-user time.

  • handleSampleResults runs on the single worker thread. The worker takes everything waiting in the queue and passes it as one list. Because only that thread calls it, fields used only from handleSampleResults need no locking. The context passed to the worker also contains the listener’s name under the key TestElement.name.

  • When the queue is full the sampler threads block until there is room, so a slow client slows the whole test.

  • teardownTest runs at the end of the test, after the worker has drained the queue.

AbstractBackendListenerClient adds getSamplerMetric(label), getMetricsPerSampler() and getUserMetrics(), the per-label aggregates the bundled InfluxDB and Graphite clients use, and clears them in its setupTest and teardownTest, which is why subclasses call super. BackendListenerContext offers the same parameter getters as the Java Request’s context (getParameter, getIntParameter, getLongParameter) plus getBooleanParameter.

Example: counting SLA breaches per search label

The client counts, for every label that starts with a prefix, how many samples exceeded the search SLA (the search_sla_ms run property used across this section), and logs the totals when the test ends. A real client would write them to a database or a message queue instead of the log:

package com.example.bookshelf.jmeter.backend;

import java.util.List;
import java.util.Map;
import java.util.TreeMap;

import org.apache.jmeter.config.Arguments;
import org.apache.jmeter.samplers.SampleResult;
import org.apache.jmeter.visualizers.backend.AbstractBackendListenerClient;
import org.apache.jmeter.visualizers.backend.BackendListenerContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/** Counts, per label, the samples slower than an SLA and logs the totals at the end of the test. */
public class SlaBreachBackendListenerClient extends AbstractBackendListenerClient {

    private static final Logger log = LoggerFactory.getLogger(SlaBreachBackendListenerClient.class);

    private long slaMs;
    private String labelPrefix;
    // only the single worker thread calls handleSampleResults, so a plain map is enough
    private final Map<String, long[]> counts = new TreeMap<>();

    @Override
    public Arguments getDefaultParameters() {
        Arguments args = new Arguments();
        args.addArgument("slaMs", "${__P(search_sla_ms,800)}");
        args.addArgument("labelPrefix", "GET /api/books");
        return args;
    }

    @Override
    public void setupTest(BackendListenerContext context) throws Exception {
        super.setupTest(context);
        slaMs = context.getLongParameter("slaMs", 800L);
        labelPrefix = context.getParameter("labelPrefix", "");
        log.info("SLA breach listener started: sla={} ms, labels starting with '{}'", slaMs, labelPrefix);
    }

    @Override
    public void handleSampleResults(List<SampleResult> sampleResults, BackendListenerContext context) {
        for (SampleResult result : sampleResults) {
            if (!result.getSampleLabel().startsWith(labelPrefix)) {
                continue;
            }
            long[] c = counts.computeIfAbsent(result.getSampleLabel(), k -> new long[2]);
            c[0]++;
            if (result.getTime() > slaMs) {
                c[1]++;
            }
        }
    }

    @Override
    public SampleResult createSampleResult(BackendListenerContext context, SampleResult result) {
        // drop what this client never looks at before it reaches the queue
        return result.getSampleLabel().startsWith(labelPrefix) ? result : null;
    }

    @Override
    public void teardownTest(BackendListenerContext context) throws Exception {
        counts.forEach((label, c) -> log.info("SLA breaches for '{}': {} of {} samples over {} ms",
                label, c[1], c[0], slaMs));
        counts.clear();
        super.teardownTest(context);
    }
}

createSampleResult reads labelPrefix, which is set in setupTest before any sample arrives. It runs on many threads at once, so it only reads that field and never touches counts.

Service file and plan element (Add → Listener → Backend Listener, then pick the class in Backend Listener implementation):

# src/main/resources/META-INF/services/org.apache.jmeter.visualizers.backend.BackendListenerClient
com.example.bookshelf.jmeter.backend.SlaBreachBackendListenerClient
<BackendListener guiclass="BackendListenerGui" testclass="BackendListener" testname="SLA breaches">
  <elementProp name="arguments" elementType="Arguments" guiclass="ArgumentsPanel" testclass="Arguments">
    <collectionProp name="Arguments.arguments">
      <elementProp name="slaMs" elementType="Argument">
        <stringProp name="Argument.name">slaMs</stringProp>
        <stringProp name="Argument.value">${__P(search_sla_ms,800)}</stringProp>
        <stringProp name="Argument.metadata">=</stringProp>
      </elementProp>
      <elementProp name="labelPrefix" elementType="Argument">
        <stringProp name="Argument.name">labelPrefix</stringProp>
        <stringProp name="Argument.value">GET /api/books</stringProp>
        <stringProp name="Argument.metadata">=</stringProp>
      </elementProp>
    </collectionProp>
  </elementProp>
  <stringProp name="classname">com.example.bookshelf.jmeter.backend.SlaBreachBackendListenerClient</stringProp>
  <stringProp name="QUEUE_SIZE">5000</stringProp>
</BackendListener>
<hashTree/>

In a 4-thread, 10-iteration run against a slow local stand-in, jmeter.log shows the life cycle:

INFO o.a.j.v.b.BackendListener: SLA breaches: Starting worker with class: class com.example.bookshelf.jmeter.backend.SlaBreachBackendListenerClient and queue capacity: 5000
INFO c.e.b.j.b.SlaBreachBackendListenerClient: SLA breach listener started: sla=800 ms, labels starting with 'GET /api/books'
INFO c.e.b.j.b.SlaBreachBackendListenerClient: SLA breaches for 'GET /api/books/{id} (TestBean)': 17 of 40 samples over 800 ms

Worked numbers. In the Bookshelf load profile (50 threads, 3 s think time, about 0.2 s per request) each thread completes one sample every 3.2 s, so the listener receives about 50 / 3.2 ≈ 15.6 samples per second. With the default queue of 5000 results, the worker could stall for 5000 / 15.6 ≈ 320 s before the first virtual user blocks; a client that makes one network call per batch is far below that limit. Without the prefix filter in createSampleResult, every sample of the journey (login, search, view, loan) would enter the queue, not only the searches.

Custom dashboard graphs

The HTML dashboard report builds each chart with a graph consumer, a class extending org.apache.jmeter.report.processor.graph.AbstractGraphConsumer declared in user.properties. The configuration properties, the default graphs and the no-code CustomGraphConsumer are covered on HTML Dashboard Report; this section writes a new consumer class.

How the report generator uses a consumer

From the 5.6.3 ReportGenerator and HtmlTemplateExporter sources:

  1. For each jmeter.reportgenerator.graph.<graph_id>.classname, the generator loads the class with Class.forName, creates it with its no-argument constructor, calls setName(graph_id) and setTitle(title).

  2. Each property.<name> is converted to a setter name in camel case (set_threshold_ms becomes setThresholdMs) and called with the value converted to the parameter type. A property without a matching setter is skipped with a warning; a value that cannot be converted stops the generation.

  3. initialize() is called; then every sample of the results file is passed to consume, which asks the keys selector for the X value (a time bucket for over-time graphs), the series selector for the series names and the value selector for the Y value. A null value is ignored. The aggregator combines the values per bucket.

  4. The exporter renders a graph whose id starts with custom_ on the Customs Graphs page with a generic template. Any other id needs a matching entry in the report template, so plugin graphs should use a custom_ id.

For an over-time chart, AbstractOverTimeGraphConsumer fixes the keys selector to time buckets of granularity ms; a subclass provides createTimeStampKeysSelector() and createGroupInfos(), and fills initializeExtraResults with what the template reads (the granularity).

Example: slow samples per second

The consumer plots, per label, how many samples per second took longer than a threshold. It is the shape of a hits per second chart with a value selector that only counts slow samples:

package com.example.bookshelf.jmeter.report;

import java.util.Collections;
import java.util.Map;

import org.apache.jmeter.report.processor.MapResultData;
import org.apache.jmeter.report.processor.TimeRateAggregatorFactory;
import org.apache.jmeter.report.processor.ValueResultData;
import org.apache.jmeter.report.processor.graph.AbstractGraphConsumer;
import org.apache.jmeter.report.processor.graph.AbstractOverTimeGraphConsumer;
import org.apache.jmeter.report.processor.graph.GroupInfo;
import org.apache.jmeter.report.processor.graph.NameSeriesSelector;
import org.apache.jmeter.report.processor.graph.TimeStampKeysSelector;

/** Dashboard graph: samples per second slower than a threshold, one series per label. */
public class SlowSamplesPerSecondGraphConsumer extends AbstractOverTimeGraphConsumer {

    private long thresholdMs = 800;

    // property.set_threshold_ms=800 in user.properties calls this setter
    public void setThresholdMs(long thresholdMs) {
        this.thresholdMs = thresholdMs;
    }

    @Override
    protected TimeStampKeysSelector createTimeStampKeysSelector() {
        TimeStampKeysSelector keysSelector = new TimeStampKeysSelector();
        keysSelector.setSelectBeginTime(true);
        return keysSelector;
    }

    @Override
    protected Map<String, GroupInfo> createGroupInfos() {
        return Collections.singletonMap(
                AbstractGraphConsumer.DEFAULT_GROUP,
                new GroupInfo(
                        new TimeRateAggregatorFactory(),
                        new NameSeriesSelector(),
                        // 1 for a slow sample, null (ignored) otherwise
                        (series, sample) -> !sample.isController() && sample.getElapsedTime() > thresholdMs ? 1.0 : null,
                        false, false));
    }

    @Override
    protected void initializeExtraResults(MapResultData parentResult) {
        parentResult.setResult(RESULT_CTX_GRANULARITY, new ValueResultData(getGranularity()));
    }

    @Override
    public void initialize() {
        super.initialize();
        ((TimeRateAggregatorFactory) getGroupInfos().get(AbstractGraphConsumer.DEFAULT_GROUP)
                .getAggregatorFactory()).setGranularity(getGranularity());
    }
}

TimeRateAggregatorFactory turns the count in each bucket into a rate per second, as in the bundled HitsPerSecondGraphConsumer; initialize passes it the granularity because it needs the bucket width to compute the rate.

The graph is declared in the user.properties used to generate the report:

jmeter.reportgenerator.graph.custom_slowSamples.classname=com.example.bookshelf.jmeter.report.SlowSamplesPerSecondGraphConsumer
jmeter.reportgenerator.graph.custom_slowSamples.title=Bookshelf: samples slower than 800 ms per second
jmeter.reportgenerator.graph.custom_slowSamples.property.set_threshold_ms=800
jmeter.reportgenerator.graph.custom_slowSamples.property.set_granularity=1000
jmeter.reportgenerator.graph.custom_slowSamples.property.set_X_Axis=Over Time
jmeter.reportgenerator.graph.custom_slowSamples.property.set_Y_Axis=Slow samples/s

The generic Customs Graphs template takes its axis labels from the set_X_Axis and set_Y_Axis configuration entries, so the chart is labelled even though the class has no setters for them; the generator logs two harmless warnings when it creates the consumer:

WARN o.a.j.r.d.ReportGenerator: 'set_Y_Axis' is not a valid property for class 'com.example.bookshelf.jmeter.report.SlowSamplesPerSecondGraphConsumer', skip it
WARN o.a.j.r.d.ReportGenerator: 'set_X_Axis' is not a valid property for class 'com.example.bookshelf.jmeter.report.SlowSamplesPerSecondGraphConsumer', skip it

Add setXAxis(String) and setYAxis(String) methods that store the values if a clean log matters. The class must be on the class path of the JVM that generates the report: in lib/ext of the installation that runs -e -o or -g, which is not necessarily a load generator.

Two side effects follow from NameSeriesSelector: a series is created for every label in the results file, including those with no slow sample (they appear in the legend with no points), and transaction controller samples are excluded by the value selector. Restrict the labels with jmeter.reportgenerator.exporter.html.series_filter when the plan has many.

Registration: ServiceLoader and class scanning

What 5.6 changed

Up to 5.5, JMeter found every plugin class by scanning all classes in the jars of lib/ext and search_paths. JMeter 5.6 started loading plugins with Java’s ServiceLoader instead (PR 5885, "It enables faster startup"), and the tutorial’s Service lookup section explains the contract:

  • an interface that supports service loading is annotated @JMeterService;

  • a plugin registers an implementation in META-INF/services/<fully qualified interface name>, one class name per line; the tutorial suggests Google’s @AutoService annotation processor to generate the files at build time, as JMeter does for its own functions (@AutoService(Function.class));

  • for backward compatibility JMeter still scans the jars, so a plugin without service files keeps working;

  • a jar that provides all its plugins through services can declare the manifest attribute JMeter-Skip-Class-Scanning: true so that JMeter does not scan it.

In 5.6.3, the interfaces annotated @JMeterService that matter for plugin authors are Function, JavaSamplerClient and BackendListenerClient. The JMeter jars themselves also ship service files for internal extension points (GUI commands, menu creators, View Results Tree renderers and request views, recorder sampler creators, access-log parsers), and every 5.6.3 jar in lib/ext carries JMeter-Skip-Class-Scanning: true.

What the skip attribute really skips

The 5.6.3 source makes the attribute narrower than its name suggests, and the difference decides whether a plugin shows up:

Lookup How 5.6.3 does it Effect of JMeter-Skip-Class-Scanning: true

Functions, Java Request clients, Backend Listener clients

JMeterUtils.loadServicesAndScanJars: first the ServiceLoader, then a scan of the jars for classes not yet found

The jar is not scanned. Without a service file the class is invisible.

Samplers, config elements, TestBeans and every other menu entry

MenuFactory scans lib/ext and search_paths for JMeterGUIComponent and TestBean classes (the source carries the note "TODO: migrate to ServiceLoader")

None: the jar is still scanned for menu entries.

Both rows were checked against the example jar. With the attribute set and the Function service file deleted from the jar, ${__randomIsbn(979,isbn)} was no longer recognised and the sample label stayed as the literal text isbn ${__randomIsbn(979, with no error in the log; with the service file restored, the function worked again. The ClassFinder menu scan found BookLookupSampler in the same jar regardless of the attribute.

The practical rules:

  • Ship a service file for every Function, JavaSamplerClient and BackendListenerClient implementation.

  • Set JMeter-Skip-Class-Scanning: true only when every such implementation has one; it speeds up start-up and does not hide TestBeans or GUI classes.

  • Keep TestBeans and GUI classes in lib/ext (or a search_paths entry): they are only found by scanning those paths, not by being somewhere on the class path.

The general discovery model, lib versus lib/ext and the effect of old plugins without service files are described on Plugins & Plugins Manager.

Deploying to lib/ext

  1. Copy the plugin jar to lib/ext of the JMeter installation, and any third-party library it needs to lib.

  2. Restart JMeter. Class loaders are built at start-up; a running GUI does not see new jars.

  3. Repeat on every machine that runs the plan: in distributed testing each server loads the plan itself, so each one needs the jar (see Installation & Configuration for search_paths, user.classpath and plugin_dependency_paths). The machine that generates the HTML report needs the graph consumer.

  4. Check jmeter.log for the plugin’s classes, or open the GUI and look for Bookshelf Book Lookup under Add → Sampler and __randomIsbn in Tools → Function Helper Dialog.

cp target/bookshelf-jmeter-ext-1.0.0.jar "$JMETER_HOME/lib/ext/"
jmeter -n -t bookshelf-extensions.jmx -q user.properties \
  -Jhost=localhost -Jport=8080 -Jthreads=4 -Jloops=10 \
  -l results.jtl -e -o report
Creating summariser <summary>
Created the tree successfully using bookshelf-extensions.jmx
Starting standalone test @ 2026 Oct 14 13:30:48 CEST (1791977448877)
Waiting for possible Shutdown/StopTestNow/HeapDump/ThreadDump message on port 4445
summary =    120 in 00:00:10 =   11.7/s Avg:   278 Min:     4 Max:   924 Err:     0 (0.00%)
Tidying up ...    @ 2026 Oct 14 13:30:59 CEST (1791977459253)
... end of run

The 120 samples are 4 threads × 10 iterations × 3 samplers (the __randomIsbn probe, the Java Request BookSearchClient and the BookLookupSampler TestBean). The report folder contains the Customs Graphs page with the custom_slowSamples chart, and jmeter.log the SLA lines of the Backend Listener client shown above.

For reproducible generators, do not copy jars by hand: publish the jar to a Maven repository and list it in the jmeter-maven-plugin’s <jmeterExtensions>, or publish it to a Plugins Manager repository (next section) and install it with PluginsManagerCMD. Both are described on Plugins & Plugins Manager; the page on CI/CD with Maven, Docker and GitHub Actions later in this section builds a generator image that way.

Publishing to the Plugins Manager repository

The JMeter Plugins Manager installs plugins listed in a JSON catalogue. The public catalogue is maintained by the jmeter-plugins.org project, a community project that is not part of Apache JMeter. There are two ways to make a plugin installable with it.

A private catalogue

For a plugin used only inside an organisation, publish the jar on an internal server and add a JSON catalogue to jpgc.repo.address; the format and the property are shown in Plugins & Plugins Manager. This needs no approval from anyone.

The public catalogue

The wiki page Submitting Your Plugin to the Marketplace describes the process: the plugin’s code stays in its own repository, and the author sends a pull request to undera/jmeter-plugins that adds a descriptor:

  • New plugin: append one entry to site/dat/repo/various.json (the array of community plugins); do not create a new file.

  • New version: add a key to the plugin’s existing versions map.

  • Stable download location: each version’s downloadUrl must point to Maven Central or to the project’s GitHub releases.

  • Version key matches the artifact: the key must match the version in the jar file name (1.0.0 for bookshelf-jmeter-ext-1.0.0.jar).

  • Formatting: run python3 format_repo.py before committing; CI checks it with python3 format_repo.py --check (2-space indentation, final newline).

  • Real, reusable value: the plugin should do something not already covered by JMeter or by a plugin in the catalogue.

A descriptor for the example project, if it were published on Maven Central under a public group id:

{
  "id": "bookshelf-jmeter-ext",
  "name": "Bookshelf JMeter Extensions",
  "description": "Book Lookup sampler, __randomIsbn function, SLA breach Backend Listener client and a slow-samples dashboard graph",
  "screenshotUrl": "https://github.com/example/bookshelf-jmeter-ext/raw/main/docs/book-lookup.png",
  "helpUrl": "https://github.com/example/bookshelf-jmeter-ext",
  "vendor": "Bookshelf QA",
  "markerClass": "com.example.bookshelf.jmeter.sampler.BookLookupSampler",
  "componentClasses": [
    "com.example.bookshelf.jmeter.sampler.BookLookupSampler",
    "com.example.bookshelf.jmeter.functions.RandomIsbnFunction",
    "com.example.bookshelf.jmeter.backend.SlaBreachBackendListenerClient"
  ],
  "versions": {
    "1.0.0": {
      "changes": "First release",
      "downloadUrl": "https://repo1.maven.org/maven2/com/example/bookshelf/bookshelf-jmeter-ext/1.0.0/bookshelf-jmeter-ext-1.0.0.jar",
      "depends": ["jmeter-core", "jmeter-java", "jmeter-components"],
      "libs": {}
    }
  }
}

The fields, from the descriptor format page:

Field Meaning

id

Unique plugin identifier, used by PluginsManagerCMD install <id>=<version>.

name, description, vendor

Shown in the Plugins Manager dialog.

screenshotUrl, helpUrl

Screenshot and help page links in the dialog.

markerClass

A class whose presence means the plugin is installed.

componentClasses

The significant classes (elements and GUIs); used to detect the plugin and by install-for-jmx to map the class names in a .jmx to a plugin.

installerClass

Optional: a class with a main method run outside JMeter after installation, with only the plugin jar on its class path.

canUninstall

Optional, default true.

versions.<v>.downloadUrl

Where the jar of that version is downloaded from.

versions.<v>.changes

Optional release notes.

versions.<v>.libs

Map of library name to download URL, installed into lib; a key such as jmeter-plugins-cmn-jmeter>=0.6 requires a minimum version.

versions.<v>.depends

Ids of other catalogue entries this version needs.

In the public catalogue, depends usually names the pseudo-plugins for JMeter’s own jars: on 2026-10-14, various.json held 80 entries whose versions list jmeter-core 208 times, jmeter-components 99 times, jmeter-http 47 times and jmeter-java once. Listing the JMeter modules a plugin compiles against is therefore the established practice, though the descriptor format page does not document these ids. The componentClasses value also explains why the example lists the TestBean, the function and the client: those are the class names that can appear in a .jmx.

The jmeter-plugins.org Developers Guidelines add expectations for plugins whose code lives in the project’s own repository (unit tests, a wiki page per plugin, a link to the wiki and the version in every plugin GUI, example plans built with Dummy Samplers). They are good practice for any public plugin.

Cost on the load generator

Extension code runs inside the virtual users' JVM, so it is part of what the generator measures:

  • Samplers and Java Request clients run on the virtual-user thread. Time spent outside sampleStart() and sampleEnd() is not in the response time but still delays the next request of that thread, lowering throughput. Build clients and parse parameters once (setupTest, static thread-safe clients), not per sample.

  • TestBeans pay for TestBeanHelper.prepare before every sample: one evaluation and setter call per property. Keep the number of properties reasonable and mark constants with NOT_EXPRESSION.

  • Functions run every time a field containing them is evaluated (no caching since 5.6). A function that allocates, locks or does I/O multiplies that cost by the number of threads and fields.

  • Backend Listener clients run on their own thread, except createSampleResult, which runs on every sampler thread. A client slower than the sample rate fills the queue and then blocks the virtual users.

  • Graph consumers run only during report generation, not during the test.

  • Memory: each thread holds its own clone of every TestBean and its own Java Request client instance; 50 threads mean 50 copies of any per-instance buffer.

From the books to 5.6 to 6.0

Topic 2.x / 3.0 era 5.6.3 and planned 6.0

Plugin discovery

Class scanning of lib/ext and search_paths only.

5.6: ServiceLoader for @JMeterService interfaces, scanning kept for compatibility, JMeter-Skip-Class-Scanning manifest attribute; menu entries still scanned.

Element API

AbstractTestElement with string property names; GUI classes override createTestElement.

5.6: TestElement migrated to Kotlin with nullability annotations (PR 5959), TestElementSchema property schemas (PR 5944); 5.6.3: makeTestElement/assignDefaultValues in JMeterGUIComponent.

Functions

Value cached per iteration.

5.6: no caching (function.cache.per.iteration restores it temporarily).

Building JMeter itself

Ant (build.xml).

Gradle (./gradlew createDist); plugins build with any tool against the Maven Central artifacts.

Installing plugins

Unzip jar sets into lib/ext.

Plugins Manager 2.0, PluginsManagerCMD or <jmeterExtensions>.

GUI visualizers

A common kind of plugin.

Discouraged by the manual in favour of the HTML report and Backend Listener clients.

Runtime Java and libraries (planned 6.0)

Java 7 / 8.

Planned per changes.xml on master: Java 17 or later required (PR 6220), Kotlin 1.9 or later (PR 6550), Groovy 5.1.3, Jackson 2.22.3, Log4j 2.26.1, commons-lang3 3.21.0 and other library upgrades. Plugins that rely on the versions bundled with 5.6.3, or are compiled for a newer Java than the generator runs, must be rebuilt and retested.

At the time of writing the 6.0 change list does not mention changes to the JavaSamplerClient, Function, BackendListenerClient or TestBean APIs; re-check changes.xml before the release.

Pitfalls

  • Missing service file with the skip attribute. A function or client in a jar marked JMeter-Skip-Class-Scanning: true but without its META-INF/services entry is silently ignored; a function call then stays as literal text in requests and labels.

  • Bundling JMeter. Packaging ApacheJMeter_core or its dependencies into the plugin jar (a fat jar without provided scope) puts two copies of JMeter classes in lib/ext. Keep every JMeter artifact provided.

  • Wrong Java release. A jar compiled for Java 17 fails on a generator still running Java 8 or 11 with UnsupportedClassVersionError.

  • Shared mutable state. A function instance is shared by all threads; a Backend Listener client is shared by the whole test; a TestBean or Java Request client is per thread, but its static fields are not. Make shared state thread-safe.

  • Holding the element in the GUI. A GUI class that stores a reference to the element it edited shows or saves data of the wrong element, because JMeter reuses GUI instances.

  • Forgetting super. configure and modifyTestElement must call super (name, GUI and test classes); AbstractBackendListenerClient subclasses must call super.setupTest and super.teardownTest.

  • Renaming classes. The class names are written into every .jmx (testclass, classname) and into the catalogue’s componentClasses. Renaming a class or package in a later version breaks existing plans.

  • Timing too much. Only the code between sampleStart() and sampleEnd() is measured; client creation inside that window inflates response times.

  • Forgetting the report machine. A custom graph consumer must be in lib/ext of the JMeter that generates the report; otherwise the generator logs Unable to add class and leaves the chart out.

  • Not restarting. Copying a new jar into a running JMeter has no effect until the next start.