Gatling plugin for Apache Kafka — produce, request-reply, and consume load testing with Avro, Protobuf, and Schema Registry support
22
stars
179
commits
Scala
primary language
Sep 6, 2026
updated
Kafka protocol plugin for Gatling load testing framework. The main branch supports produce-only and request-reply Kafka flows with plain serialization and Avro helpers.
| Branch / Line | Gatling | Scala | Java | Kafka client |
|---|---|---|---|---|
main / 1.3.x | 3.13.5 | 2.13.18 | 17+ | Apache 3.9.x |
1.1.x – 1.2.x | 3.13.5 | 2.13.18 | 17+ | Confluent 7.9.x-ce |
1.0.x | 3.13.5 | 2.13.16 | 17+ | Confluent 7.9.x-ccs |
| 0.22.x | 3.13.x | 2.13 | 17+ | Confluent 7.x |
| 0.21.x | 3.12.x | 2.13 | 17+ | Confluent 7.x |
| 0.20.3 | 3.11.5 | 2.13 | 17+ | Confluent 7.x |
Kafka client: from
1.3.0the plugin depends on the Apache release ofkafka-clients(org.apache.kafka:kafka-clients:3.9.x) rather than Confluent's-cerebuild of the same upstream code. Confluent Platform 7.9.x is built from Apache Kafka 3.9.x, so this is the same code under a different version scheme — but the Confluent rebuild is published only topackages.confluent.io, which made the plugin unresolvable for anyone building against Maven Central. Broker compatibility is unchanged.
Version guidance: if you are on Gatling
3.11.5, use plugin0.20.3. The1.0.x/mainline targets Gatling3.13.x.Upgrading from an older release? Start with the Migration Guide below. It summarizes the supported upgrade paths and the breaking or behavioral changes that tend to matter most.
Branch strategy:
mainis the active development branch and current release line. Short-lived topic branches are cut frommain, andbackport/*branches are only created when a released line needs a targeted follow-up fix.
libraryDependencies += "org.galaxio" %% "gatling-kafka-plugin" % "<version>" % Test
gatling("org.galaxio:gatling-kafka-plugin_2.13:<version>")
<dependency>
<groupId>org.galaxio</groupId>
<artifactId>gatling-kafka-plugin_2.13</artifactId>
<version>${version}</version>
<scope>test</scope>
</dependency>
Everything above resolves from Maven Central. No additional repository is required for plain serialization — which covers producing, request-reply, checks, and consume-only tracking.
Schema-Registry-backed Avro needs two artifacts that Confluent publishes only to its own repository —
they are not on Maven Central, so the plugin declares them as provided and you add them yourself.
This mirrors how avro4s has always worked here. Skip this section entirely if you do not use
Schema Registry; nothing else in the plugin needs it.
resolvers += "Confluent" at "https://packages.confluent.io/maven/"
libraryDependencies ++= Seq(
"io.confluent" % "kafka-avro-serializer" % "7.9.9" % Test,
"io.confluent" % "kafka-streams-avro-serde" % "7.9.9" % Test,
).map(_.exclude("org.apache.kafka", "kafka-clients"))
repositories {
maven("https://packages.confluent.io/maven/")
}
dependencies {
gatling("io.confluent:kafka-avro-serializer:7.9.9") {
exclude(group = "org.apache.kafka", module = "kafka-clients")
}
gatling("io.confluent:kafka-streams-avro-serde:7.9.9") {
exclude(group = "org.apache.kafka", module = "kafka-clients")
}
}
<repositories>
<repository>
<id>confluent</id>
<url>https://packages.confluent.io/maven/</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>io.confluent</groupId>
<artifactId>kafka-avro-serializer</artifactId>
<version>7.9.9</version>
<scope>test</scope>
<exclusions>
<exclusion><groupId>org.apache.kafka</groupId><artifactId>kafka-clients</artifactId></exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>io.confluent</groupId>
<artifactId>kafka-streams-avro-serde</artifactId>
<version>7.9.9</version>
<scope>test</scope>
<exclusions>
<exclusion><groupId>org.apache.kafka</groupId><artifactId>kafka-clients</artifactId></exclusion>
</exclusions>
</dependency>
</dependencies>
The kafka-clients exclusion is not optional — keep it. These artifacts pull
io.confluent:kafka-schema-registry-client, which depends on Confluent's own rebuild of the Kafka
client (kafka-clients:7.9.x-ccs). sbt and Gradle both resolve conflicts by taking the highest
version, so that rebuild wins over the Apache 3.9.x this plugin declares and your load test silently
runs a different client from the one the plugin is built and tested against. Excluding it leaves the
plugin's own Apache client in place. Maven resolves nearest-wins and is not affected, but the exclusion
is harmless there and keeps the three snippets equivalent.
Your simulation code needs no change: org.galaxio.gatling.kafka.Predef._ exposes the Avro serdes
exactly as before. See Schema Registry Integration for usage.
If you are installing this while upgrading an older test suite, read the Migration Guide before copying examples from main.
docker compose -f docker-compose.kafka.yml up -d
Stop:
docker compose -f docker-compose.kafka.yml down
import org.galaxio.gatling.kafka.Predef._
import io.gatling.core.Predef._
class KafkaSimulation extends Simulation {
val kafkaConf = kafka
.properties(Map("bootstrap.servers" -> "localhost:9092"))
val scn = scenario("Kafka Producer")
.exec(
kafka("send message")
.topic("test-topic")
.send[String, String]("key", """{"msg": "hello"}""")
)
setUp(scn.inject(atOnceUsers(1))).protocols(kafkaConf)
}
import static org.galaxio.gatling.kafka.javaapi.KafkaDsl.*;
import static io.gatling.javaapi.core.CoreDsl.*;
public class KafkaSimulation extends Simulation {
var kafkaConf = kafka()
.properties(Map.of("bootstrap.servers", "localhost:9092"));
var scn = scenario("Kafka Producer")
.exec(
kafka("send message")
.topic("test-topic")
.send("key", "{\"msg\": \"hello\"}")
);
{ setUp(scn.injectOpen(atOnceUsers(1)).protocols(kafkaConf)); }
}
import org.galaxio.gatling.kafka.javaapi.KafkaDsl.*
import io.gatling.javaapi.core.CoreDsl.*
class KafkaSimulation : Simulation() {
val kafkaConf = kafka()
.properties(mapOf("bootstrap.servers" to "localhost:9092"))
val scn = scenario("Kafka Producer")
.exec(
kafka("send message")
.topic("test-topic")
.send("key", """{"msg": "hello"}""")
)
init { setUp(scn.injectOpen(atOnceUsers(1)).protocols(kafkaConf)) }
}
The main branch currently ships:
kafka("name").topic("topic").send(...)kafka("name").requestReply.requestTopic(...).replyTopic(...).send(...).matchByValue or .matchByMessage(...)org.galaxio.gatling.kafka.avro4s._ or custom Kafka Serde[T]The following APIs are not available on main and are intentionally not documented below:
consumeFrom, consumeAny, keyForTracking, or saveAsrequestMatchBy and replyMatchBypartition, timestamp, or silentKafkaProtobufDsl helpers such as protobufBodyimport org.galaxio.gatling.kafka.Predef._
scenario("Producer")
.exec(
kafka("send string")
.topic("test-topic")
.send[String, String]("key", "payload"),
)
Target a specific partition or set an explicit timestamp on produced records:
kafka("send to partition")
.topic("test-topic")
.send[String, String]("key", "payload")
.partition(3)
.timestamp(System.currentTimeMillis())
Both .partition() and .timestamp() accept Gatling Expression values for dynamic resolution from the session.
kafka("silent request")
.topic("test-topic")
.send[String]("foo")
.silent
Set the topic on each request builder with kafka("name").topic("...").
Request-reply needs both producer settings and consumer settings. The producer sends the request, and the consumer side tracks replies on the configured reply topic.
import scala.concurrent.duration._
val kafkaConf = kafka
.producerSettings(
"bootstrap.servers" -> "localhost:9092",
)
.consumeSettings(
"bootstrap.servers" -> "localhost:9092",
)
.timeout(10.seconds)
kafka("request reply").requestReply
.requestTopic("requests")
.replyTopic("replies")
.send[String, String]("key", """{"action": "process"}""")
.check(jsonPath("$.status").is("ok"))
The example below is the shortest complete setup we recommend for a new request-reply simulation on local Kafka.
import io.gatling.core.Predef._
import io.gatling.core.structure.ScenarioBuilder
import org.apache.kafka.clients.consumer.ConsumerConfig
import org.apache.kafka.clients.producer.ProducerConfig
import org.galaxio.gatling.kafka.Predef._
import scala.concurrent.duration._
class RequestReplySimulation extends Simulation {
private val requestTopic = "requests"
private val replyTopic = "replies"
private val kafkaConf = kafka
.producerSettings(
ProducerConfig.BOOTSTRAP_SERVERS_CONFIG -> "localhost:9092",
ProducerConfig.ACKS_CONFIG -> "1",
)
.consumeSettings(
ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG -> "localhost:9092",
ConsumerConfig.GROUP_ID_CONFIG -> s"gatling-rr-${System.currentTimeMillis()}",
ConsumerConfig.AUTO_OFFSET_RESET_CONFIG -> "latest",
)
.timeout(15.seconds)
private val scn: ScenarioBuilder = scenario("request-reply")
.exec(
kafka("send request").requestReply
.requestTopic(requestTopic)
.replyTopic(replyTopic)
.send[String, String]("order-42", """{"action":"process"}""")
.check(jsonPath("$.status").is("ok")),
)
setUp(scn.inject(atOnceUsers(1))).protocols(kafkaConf)
}
Required consumer-side settings in that example:
consumeSettings("bootstrap.servers" -> ...) is mandatory. Without it, the plugin never creates the reply-tracking consumer.group.id should be unique per local run unless you deliberately want to resume committed offsets.auto.offset.reset=latest keeps a fresh local group focused on replies produced after the simulation starts..timeout(...) must cover both Kafka round-trip latency and the first consumer-group assignment for the reply topic.Reply-topic assumptions:
requestTopic.replyTopic.order-42 as the Kafka key.Minimal local responder setup:
docker compose -f docker-compose.kafka.yml up -d.requests and republishes to replies using the same key.If you want a repository-backed responder example instead of writing your own, see KafkaIntegrationSpec.scala, especially the request-reply integration test that wires an input topic, reply topic, sender, and dynamic consumer together end to end.
Expected success signal:
send request action as successful..check(...) clause.Timed out waiting for reply or Timed out waiting for consumer assignment errors in the run output.| Method | Request extractor | Response extractor |
|---|---|---|
| (default) | msg.key | msg.key |
.matchByValue | msg.value | msg.value |
.matchByMessage(fn) | fn(msg) | fn(msg) |
These matchers are configured on the protocol, not on individual request builders:
import org.galaxio.gatling.kafka.request.KafkaProtocolMessage
def correlationIdFromHeader(headerName: String): KafkaProtocolMessage => Array[Byte] =
_.headers
.flatMap(headers => Option(headers.lastHeader(headerName)).map(_.value()))
.orNull
Return
null, notArray.emptyByteArray, when the field is missing. An empty array is a value: every request missing the header would produce the same correlation id, they would all share one slot, and replies would be matched to the wrong virtual user. Returningnullmakes the plugin fail those requests immediately with a message naming the cause.
KafkaConsumer for reply tracking. The consumer is created once per distinct consumer bootstrap.servers value and reused by all scenarios using that protocol.When you supply consumeSettings, the plugin always adds byte-array deserializers and also injects these defaults unless you override them:
| Setting | Default | Why |
|---|---|---|
group.id | gatling-kafka-test-<uuid> | Generated when absent so reply tracking can start without forcing a shared consumer group across runs. |
auto.offset.reset | latest | New consumer groups start from newly produced replies instead of replaying old traffic. |
enable.auto.commit | true | Kafka commits offsets automatically unless you opt out explicitly. |
Two important consequences follow from those defaults:
auto.offset.reset=latest only matters when the consumer group has no committed offsets yet.group.id and keep enable.auto.commit=true, later runs resume from committed offsets for that group. In that case Kafka may ignore latest and continue from the stored position instead.group.id values or provide a unique group.id per run.group.id, decide explicitly whether you want committed offsets. Override enable.auto.commit and auto.offset.reset instead of relying on defaults..matchByValue and .matchByMessage(...) must extract the same logical id on both sides.| Symptom | Likely cause | What to check |
|---|---|---|
| Requests are sent but no replies are ever matched | No consumer was created for tracking | Make sure the protocol includes consumeSettings("bootstrap.servers" -> ...), not only producer settings. |
| First requests on a reply topic time out under load or right after startup | Topic subscription and partition assignment consumed most of the timeout budget | Increase .timeout(...) and verify the consumer group can join and get assignments promptly. |
| Replies seem to be skipped on later test runs | A reused group.id resumed from committed offsets | Use a fresh group.id, or override enable.auto.commit / auto.offset.reset deliberately. |
| Late replies do not recover a timed-out request | Correlation entries are removed after timeout | Treat the timeout as a hard deadline and size it for your end-to-end latency envelope. |
| Replies arrive on Kafka but still do not match | Request and reply are extracting different correlation ids | Verify whether you are matching by key, value, or a custom extractor, and confirm both sides produce the same bytes. |
kafka("consume event")
.consumeFrom("events")
.keyForTracking("#{eventKey}")
.check(bodyString.exists)
.saveAs("eventBody")(msg => new String(msg.value))
Consume first available (no correlation):
kafka("consume any")
.consumeAny("events")
.saveAs("payload")(msg => new String(msg.value))
Add avro4s to your test dependencies:
libraryDependencies += "com.sksamuel.avro4s" %% "avro4s-core" % "4.1.2" % Test
Usage with automatic schema derivation:
import com.sksamuel.avro4s._
import org.galaxio.gatling.kafka.Predef._
import org.galaxio.gatling.kafka.avro4s._
case class Ingredient(name: String, sugar: Double, fat: Double)
scenario("Avro4s")
.exec(
kafka("send avro")
.topic("ingredients")
.send[String, Ingredient]("key", Ingredient("Cheese", 0d, 70d)),
)
Requires two extra dependencies. From
1.3.0the Confluent Schema Registry artifacts areprovidedrather than inherited, because they are not published to Maven Central. Add them and the Confluent resolver as shown in Installation → Optional: Avro via Confluent Schema Registry before using anything in this section. Without them the code below still compiles, and fails at run time withNoClassDefFoundError: io/confluent/kafka/streams/serdes/avro/GenericAvroSerde.
For Schema Registry-backed Avro classes, provide an implicit schemaRegUrl or your own Kafka Serde[T]:
implicit val schemaRegUrl: String = "http://localhost:8081"
The same applies to avroBody checks and to the Java facade's avro(...) entry points, which are
backed by the same Confluent serdes.
See AvroClassWithRequestReplySimulation.scala for a complete request-reply example with a custom Avro Serde.
Using sbt-schema-registry-plugin:
sbt schemaRegistryDownload
Predef / KafkaDsl (entry points, implicits)
|
KafkaProtocolBuilder (producerSettings, consumeSettings, timeout, matchers)
KafkaRequestBuilderBase (DSL: .topic.send, .requestReply)
|
+-- KafkaRequestAction (produce-only action)
+-- KafkaRequestReplyAction (produce + correlated reply tracking)
+-- KafkaConsumeAction (consume-only tracking)
|
KafkaMessageTrackerActor (Akka actor for correlation)
TrackersPool (shared consumer per bootstrap servers, tracker per reply topic)
KafkaSender / KafkaSenderPool (producer pool)
Use this section as release-based upgrade notes. Start from the version you are on today, then apply the checklist for the target line you want to adopt.
| Current line | Target line | Notes |
|---|---|---|
0.22.x / RC | 1.0.0 | Remove protocol-level .topic(...) calls, set topic on each request builder. Remove any use of messageCheck. |
0.20.3 | 1.0.x | Move from Gatling 3.11.5 to 3.13.x, update request-reply consumer settings, re-check examples against current README. |
0.21.x | 1.0.x | Stay on Gatling 3.13.x, review request-reply defaults and DSL surface. |
0.20.x or older | 1.0.x | Treat as full doc refresh. Older consume-only or per-action matcher APIs are not present. |
1.0.x – 1.2.x | 1.3.x | Build-file only. Plain users: no change. Schema Registry Avro users: declare two artifacts and the Confluent resolver — see below. |
1.3.x | 2.0.0 | Source-breaking, but only for API that could not work. Most suites need no change — see below. |
2.0.x | 2.1.0 | No source change. Reporting moves: failure messages now name the kind of failure, and requests the plugin rejects before sending get a request name of their own — see below. |
2.1.0No changes to the DSL, the javaapi facade, protocol settings or wire formats — nothing you have written stops compiling.
What changes is what a run reports, in four places. One of them can turn a passing assertion red, so read that one before
upgrading.
A failure is now reported as TimeoutException: Expiring 1 record(s) for request-topic-0 rather than as
Expiring 1 record(s) for request-topic-0. Nothing else changes: the status is still KO, timings are
unchanged, and successful requests are untouched. Three paths are affected:
| Path | Before | Now |
|---|---|---|
| Request-reply delivery or reply-channel failure | Expiring 1 record(s) for t-0 | TimeoutException: Expiring 1 record(s) for t-0 |
| Produce-only send failure | Failed to send request to Kafka broker: Expiring 1 record(s) for t-0 | Failed to send request to Kafka broker: TimeoutException: Expiring 1 record(s) for t-0 |
| Consumer failure (fails every in-flight request-reply at once) | Consumer failure: <text>, or Consumer failure: null when the exception had no message | Consumer failure: KafkaException: <text> |
The kind of failure was already being collected on the request-reply path — it was handed to Gatling in
the response-code slot that sits beside the message on logResponse. Gatling OSS discards that slot
before writing run data: its file serializer writes groups, name, timestamps, status and message and
nothing else, its console writer keys the error histogram by message, and the record the HTML report
parses back has no field for it at all. The value therefore reached no report, no assertion and no
simulation.log (issue #254). Putting it in the message is what makes it visible.
Where a failure arrives wrapped, the cause is named too — IllegalStateException: Kafka consumer failed (caused by SaslAuthenticationException: …). The plugin wraps every consumer fault in one
IllegalStateException with a fixed message, so without the cause a broker outage, an ACL rejection and
a SASL misconfiguration were reported identically.
What to check. If you assert on, grep for, or group by the exact text of a failure message, the
<ExceptionType>: prefix is new. In the HTML report's errors table, failures that used to share one row
because they shared a message now split by exception type.
KafkaMessageTracker.SendFailed.errorType is deprecatedThat field carried the failure kind to the response-code slot described above. With the kind now in
errorMessage, it has no destination left, so nothing reads it and nothing sets it — every SendFailed
carries None.
It is not removed. SendFailed is a published case class, so dropping a field would change apply,
copy, unapply and the accessor, and anything compiled against 2.0.x would fail at run time with
NoSuchMethodError rather than at compile time — this project declares versionScheme := "semver-spec",
so build tools treat 2.0.x → 2.1.0 as compatible and would not warn. Removal belongs in the next major
release. Nothing you have compiles differently in 2.1.0: constructing it, reading errorType,
copy(errorType = …) and a four-arity case SendFailed(id, msg, token, kind) => all still work. Drop
the argument at your convenience; if you matched on it, it was already always None.
A request-reply can end before the record reaches the broker: the configured matcher yields no correlation id, or the reply channel cannot be established. Both were reported under the request name your simulation declared, and both are now reported under a derived one:
| What happened | Reported as |
|---|---|
| The matcher returned nothing for this request | <your name> [rejected: no correlation id] |
| The reply channel could not be acquired | <your name> [rejected: no reply channel] |
| The producer never delivered the record | <your name> [rejected: not delivered] |
The delivery-failure row is the one most likely to move your numbers. With Kafka's default
delivery.timeout.ms of two minutes, a broker outage used to put a ~120 000 ms sample on every in-flight
request, under the request's own name. Those requests never reached the broker, so that figure measured
nothing about your system under test — and it dwarfed everything else on this list.
Status, failure message and measured interval are unchanged. A rejection that waited still reports its wait; a rejection decided instantly still reports a near-zero one.
Why the name and not something else. Gatling feeds a request name's response-time distribution from every entry carrying
that name, failures included, and its assertion API has no successful-only scope for responseTime. So a request that never
left the JVM was contributing a sample to the percentile your simulation asserts on. The request name is the only field a
plugin controls that separates those samples: the response-code slot is discarded before a run's data is written, and the
alternative — reporting them as run errors — would drop them out of failedRequests entirely, which is worse.
⚠️ This can turn an assertion red. An assertion of the form:
details("My Request").failedRequests.count.is(3)
stops counting rejections, because they are no longer reported under My Request. global.failedRequests and
global.allRequests are unchanged, and so is every assertion written against them. If a per-request failure count of yours
was counting rejections, point it at the derived name:
details("My Request [rejected: no correlation id]").failedRequests.count.is(3)
The bracketed suffix is reserved. Do not declare a request whose name ends in [rejected: …]; nothing prevents it, and
the two rows would merge. Gatling's own redirect naming (<name> Redirect 1) carries the same exposure.
Groups are cleaned too. A rejection no longer contributes to the cumulated response time of an enclosing
group(...) either, so details("<your group>").responseTime stops absorbing requests that never reached
the broker. It is still counted as a failed request.
What this does not fix. global.responseTime still includes rejection samples. Gatling feeds the run-wide distribution
without reference to request name, so no choice available to a plugin can clean it. Assert latency on
details("<your name>").responseTime and on details("<your group>").responseTime, and correctness on
failedRequests / successfulRequests. Reply timeouts are unchanged and still report under the request's
own name: a request that waited out its budget did measure your system under test, unlike the three
rejections above.
bootstrap.servers now fails before the run startsA protocol whose consumer settings carry no bootstrap.servers has no reply channel, so a request-reply against it could
never be answered. Every such request used to be failed individually — one KO per virtual user, each with a near-zero
interval, for a misconfiguration no run could recover from. The simulation now refuses to start, naming the missing entry.
Note the gate is the bootstrap.servers entry, not the consumeSettings(...) call: a protocol that calls consumeSettings
and sets only, say, group.id is refused too, and the message says so.
⚠️ The refusal happens while Gatling materialises the scenario, which is after your before {} hook has run and before
after {} becomes reachable. If your before starts anything — a container, a stub service, a topic seed — it will not be
torn down. The plugin's own producers and consumers are still closed, but yours are not. Guard before with a try/catch,
or check the protocol's consumer settings before you start anything expensive.
kafka.properties(...) carries no consumer settings by design, and
publishing never asks for a reply channel. If your simulation only sends, nothing changes.setUp level. A produce-only protocol applied to every scenario, in a
simulation that also contains a request-reply, now stops the whole run rather than failing that one scenario's requests.
Attach the request-reply scenario's protocol per injection, or give the protocol consumeSettings.A service can answer with a tombstone — a record with no payload — which is ordinary traffic on a compacted topic. If your protocol correlates on the record value, there is nothing in a tombstone to correlate on: the reply arrives, cannot be matched to its request, and is dropped. The request then failed on its reply timeout, indistinguishable from a service that never answered at all.
The timeout is still reported, but it now names what happened:
Reply timeout after 12000 ms. Replies also arrived on this reply topic that KafkaValueMatcher could not
read a correlation id from, so this request may have been answered in a shape this configuration cannot
correlate rather than not answered at all. matchByValue correlates on the payload, so the payload cannot
be null — give this request a body, or correlate on a key or header instead.
The remedy is derived from the matcher you configured, so a header-correlated channel gets header advice. The clause carries no count: Gatling groups its error table by message text, and a per-timeout number would split one row into hundreds. The count is in the log instead.
Against a service that genuinely never answers, the message is exactly what it was.
If your target may answer with tombstones, correlate on a header rather than the value. A tombstone still carries its
headers, so a header-correlated reply reaches its request and your checks run against it — including the clean absent-payload
failure 1.2.0 introduced, which on the value-correlated path was unreachable because the reply never got there:
import org.galaxio.gatling.kafka.request.KafkaProtocolMessage
// A `val`, not a `def`. Reply channels are keyed on matcher identity, and passing a method reference to
// matchByMessage eta-expands to a fresh function every time it is evaluated — so two protocols built from
// the same method on the same reply topic get two channels, two consumers, and each sees only part of the
// replies. One `val` shared by every protocol that correlates the same way avoids it.
val correlationId: KafkaProtocolMessage => Array[Byte] =
msg => msg.headers.flatMap(hs => Option(hs.lastHeader("x-correlation-id"))).map(_.value()).orNull
val protocol = kafka
.producerSettings(...)
.consumeSettings(...)
.matchByMessage(correlationId)
Correlating on the key works for the same reason and needs no extractor. matchByValue against a tombstone-answering service
cannot be made to work — there is nothing in the record to correlate on.
1.3.x → 2.0.0 — removals2.0.0 removes published API. Every removal below is something that either could not run, never
carried a value, or had no caller — nothing that worked has been taken away. If your simulations
use kafka("name").topic(...).send(...) and
kafka("name").requestReply.requestTopic(...).replyTopic(...).send(...), you need no source change
at all.
send(...) without a topic is goneThe send(...) overloads that could be called directly on kafka("name") — without .topic(...)
or .requestReply... first — have been removed from both the Scala DSL and the javaapi facade.
They never worked. Every action they built carried no producer topic and failed at send time with
Kafka producer topic is not defined; the Java sendWithClass(payload, class, headers) overload
threw IllegalArgumentException while the scenario was still being constructed. If you have one of
these in a suite, it has been reporting failures rather than sending.
// before — compiles, fails at run time
kafka("request").send[String, String]("key", "payload")
// after — name the topic first
kafka("request").topic("my-topic").send[String, String]("key", "payload")
kafka-streams-scala is no longer inheritedsessionWindowedSerde and consumedFromSerde, deprecated in 1.3.0, are removed — and with them
the org.apache.kafka:kafka-streams-scala dependency your build used to receive transitively. The
plugin never built a Streams topology, so nothing in it used them.
If you genuinely build Streams topologies in your harness, declare the artifact yourself:
libraryDependencies += "org.apache.kafka" %% "kafka-streams-scala" % "3.9.2" % Test
The inherited dependency set is now scala-library, kafka-clients and avro — three coordinates,
each used by plugin code.
KafkaProtocolMessage.responseCode is goneNothing ever set it: every message carried None from the day it was added. Your reports do not
change.
If you read the field, drop the read. If you matched on it, it was always None.
KafkaCheckType.ResponseCode is goneUse KafkaCheckType.Simple. Nothing could produce a check carrying ResponseCode, and its
materialization was identical to Simple's, so behaviour is unchanged.
send(...) now returns KafkaRequestBuilderThe RequestBuilder[K, V] trait had one abstract member and one implementation, and was public only
because it was the declared return type of the documented send methods. It is folded into
KafkaRequestBuilder. Invisible unless you wrote the type out:
// before
val req: RequestBuilder[String, String] = kafka("r").topic("t").send("k", "v")
// after
val req: KafkaRequestBuilder[String, String] = kafka("r").topic("t").send("k", "v")
Inference (val req = ...) needs no change. The Java facade's own
javaapi.request.builder.RequestBuilder is a different class and is unaffected.
LazyGenericAvroSerde is goneAn internal wrapper that existed only because the 1.x binary freeze forced avroSerde to be a
strict val; it is now simply lazy. Predef still supplies Serde[GenericRecord], still hands out
one stable instance — so Predef.avroSerde.configure(...) and KafkaChecks.avroSerde().configure(...)
still configure the serde the DSL later uses — plain simulations still start with no Confluent artifact
present, and Avro still fails only when you actually use it. No source change.
If you referenced the class directly, use Predef's avroSerde (Scala) or
KafkaChecks.avroSerde() (Java) instead.
Neither appears in ordinary simulations; both break code that names them directly.
javaapi.request.builder.RequestBuilder's constructor now takes the concrete Scala
KafkaRequestBuilder<K, V> instead of the removed RequestBuilder<K, V> trait. Only code that
constructs this wrapper itself is affected — kafka(...).topic(...).send(...) returns one already.KafkaAttributes.producerTopic is Expression[String] instead of Option[Expression[String]].
Every builder that reaches an action supplies a topic, so the Option could only ever be Some.
If you build KafkaAttributes directly — in a test harness, say — drop the Some(...) wrapper.kafka("name").topic(...) previously passed the request name through as a literal, while
kafka("name").requestReply()... resolved it as a Gatling expression. The produce-only path now
matches request-reply.
For almost every suite this changes nothing — a plain name like "BasicRequest" resolves to itself.
It matters only if your request name contains #{...}: it used to appear verbatim in reports and now
resolves per virtual user, and a name referring to a session attribute that is not set will fail the
request instead of reporting the literal.
// resolves per user now; previously reported literally as "order-#{orderId}"
kafka("order-#{orderId}").topic("orders").send(key, payload);
If you were relying on the literal, escape it (\#{orderId}) or rename the request.
KafkaCheckMaterializer.avroBody and KafkaMessagePreparer.avroPreparer are goneUnreachable. Both avroBody entry points — KafkaCheckSupport.avroBody for Scala and
KafkaDsl.avroBody() for Java — deserialize inside the check's extractor and never used these. Keep
using the entry points; nothing about writing an Avro body check changes.
timeout / withDefaultTimeout on the producer-settings step are goneThe reply timeout belongs to the consume step — a produce-only protocol never waits for a reply.
Both methods remain on consumeSettings(...):
kafka.producerSettings(...).consumeSettings(...).timeout(10.seconds) // unchanged
For a produce-only protocol use kafka.properties(...).
1.2.x → 1.3.x — Confluent artifacts are no longer inheritedIf you use plain serialization, avro4s, or anything other than Confluent Schema Registry: nothing to do. Bump the version and carry on. You may also drop the Confluent resolver from your build if you added one — it is no longer needed.
Why this changed. Up to 1.2.x the plugin declared four dependencies that are published only to
packages.confluent.io, while its released POM carries no repository list. A consumer building against
Maven Central alone could not resolve the plugin at all. Two of the four (the Kafka client and Kafka
Streams Scala) were Confluent rebuilds of Apache code and now use the Apache coordinates. The other two
are genuinely Confluent-only and have become optional.
If you use Schema-Registry-backed Avro, your build previously received these transitively. Declare
them yourself, exactly as you already declare avro4s:
resolvers += "Confluent" at "https://packages.confluent.io/maven/"
libraryDependencies ++= Seq(
"io.confluent" % "kafka-avro-serializer" % "7.9.9" % Test,
"io.confluent" % "kafka-streams-avro-serde" % "7.9.9" % Test,
).map(_.exclude("org.apache.kafka", "kafka-clients"))
Keep the kafka-clients exclusion — without it these artifacts pull Confluent's own rebuild of the
Kafka client, which outranks the Apache one this plugin declares under sbt's and Gradle's
highest-version-wins resolution. See
Installation for the Gradle and Maven forms and the
full explanation.
No source change is required, in any scenario. Imports, implicits, and every Scala and Java entry
point are unchanged — Predef still supplies the Avro serdes.
How you find out if you forget them. Not at build time: provided dependencies are simply absent
from your classpath, so resolution and compilation both succeed. The serdes construct their Confluent
delegate on first use, so the first Avro send or check fails with
NoClassDefFoundError: io/confluent/kafka/streams/serdes/avro/GenericAvroSerde — in the middle of a
run. If your suite uses Schema Registry Avro, add the dependencies before you upgrade rather than
finding out from a load test.
Also in this release, sessionWindowedSerde and consumedFromSerde are deprecated. They are Kafka
Streams helpers that this plugin never used; they will be removed in 2.0.0 along with the
kafka-streams-scala dependency. If you genuinely build Streams topologies in your harness, depend on
org.apache.kafka:kafka-streams-scala_2.13 directly.
1.2.0No changes to the DSL, the javaapi facade or protocol settings — nothing you have written stops compiling. Three
behavioural changes, and two of them can turn a passing scenario red, so read the sections below before upgrading.
A request with no key produced an empty correlation id — and so did every other keyless request. They shared a single slot in the correlation table, so a reply resolved whichever request happened to occupy it: one virtual user was credited with another user's answer while the real owner timed out. Nothing in the report distinguished that from a genuine result.
Under the default matchByKey there is nothing to correlate a keyless reply on, so such a request is now reported as a
failure at issue time and is not published. The failure names the matcher and the remedy.
If a request-reply scenario of yours has no key, it will now go red. Those runs were reporting incorrect results before; the change surfaces that rather than causing it. Two ways forward, depending on what the request actually correlates on:
// Give each request a key to correlate on
kafka("req").requestReply
.requestTopic("in").replyTopic("out")
.send[String, String]("#{correlationId}", "payload")
// Or correlate on something the request already carries
val protocol = kafka
.producerSettings(...)
.consumeSettings(...)
.matchByValue // the payload itself
// .matchByMessage(msg => ...) // or a header / any extracted field
Request-reply that already sets a key, or that uses matchByValue / matchByMessage, is unaffected.
A reply can arrive with no payload at all — a tombstone on a compacted topic, or an acknowledgement carrying no body. Applying
a content check to one (bodyString, substring, bodyBytes, jsonPath, jmesPath) used to throw inside the reply-handling
path, which had nothing to catch it. The virtual user was never continued: no success, no failure, no next request. It
simply stopped, and the run's user count silently diverged from the load the profile was applying.
Such a check now reports the request as a failure naming the absent payload, and the virtual user carries on.
bodyString.is("") still passes on an empty reply and now
fails on a tombstone — "the service sent nothing" and "the service sent an empty string" are different findings.Independently of the checks above, no check can strand a virtual user any more: one that throws for any reason is reported as a failure and the user continues.
The plugin was substituting an empty byte array for an absent key, which is not the same thing: an empty key is a present
key. Kafka hashes it, and murmur2 of an empty input is a constant — so every keyless message landed on the same partition
for the whole run, no matter how long the run was or how many partitions the topic had. This applied to fire-and-forget
sends as well as request-reply.
Keyless messages now reach the broker with a genuinely absent key, so Kafka applies its normal keyless partitioning instead of hashing a constant.
hash(key) % partitions, so per-key ordering guarantees hold.global.responseTime percentiles, note that a run rejecting every request will lower them; assert on
failedRequests/successfulRequests to catch that case. In 2.1.0 these rejections moved to a request name of their
own, so they no longer affect the percentile reported for the request you declared — but global.responseTime still
blends them in, so the advice above still holds for it.⚠️ Keyless sends to a log-compacted topic now fail
A compacted topic (
cleanup.policy=compact) requires every record to have a key, and Kafka treats an empty key as present but a null key as absent. The old empty-array substitution therefore slipped past that check; a genuinely absent key does not.A scenario that publishes keyless records — request-reply or fire-and-forget — to a compacted topic goes from passing to every request failing, with
InvalidRecordException: Compacted topic cannot accept message without key.This is the broker enforcing a rule the plugin was previously hiding: those records were never valid on that topic. Give the send a key:
kafka("req").topic("compacted-topic").send[String, String]("#{entityId}", "payload")
1.1.0No changes to the DSL, the javaapi facade, protocol settings or wire formats. One behavioural change worth knowing about, in request-reply only.
Request-reply now registers the pending request before handing the record to the producer, so that a reply cannot arrive before the plugin is watching for it. Previously the request was sent first and the reply channel acquired afterwards, which meant a reply from a fast responder could be received and silently discarded, and the request then failed on its reply timeout as though nothing had answered.
The consequence: when acquiring the reply channel fails — for example the reply topic is never assigned within the configured timeout — the request is now reported as a failure without being published. Before, it was published first and then reported as a failure.
2.1.0: its message gained an exception-type prefix, and it moved to a
request name of its own, <your name> [rejected: no reply channel] — see
Upgrading to 2.1.0.A request-reply is now measured from the moment the record is handed to the producer. Previously it was measured from the broker's acknowledgement of that record, which excluded the produce round trip from every reported time.
Expect reported times to grow by one produce acknowledgement — typically a few milliseconds against a local broker, more
with acks=all or a loaded one. Nothing about the requests changed; only where the clock starts.
This is the interval the virtual user actually waits for, and it is what every other Gatling protocol reports. If you compare percentiles across this upgrade, compare them knowing the earlier numbers omitted a leg.
Channel setup is still never included: the clock starts after the reply channel exists, so a first request on a new reply topic is not charged for its subscription and rebalance.
1.0.0 from 0.22.x / RCThe kafka.topic("...") shorthand on the protocol builder was deprecated in 1.0.0-RC1 and is now removed.
| Before (removed) | After |
|---|---|
kafka.topic("my-topic").properties(Map(...)) | kafka.producerSettings(Map(...))... |
kafka("req").send(payload) with protocol-level topic | kafka("req").topic("my-topic").send(payload) |
Every request builder must now declare its own topic with .topic("...") or .requestTopic("...").replyTopic("...").
KafkaMessageCheck removedmessageCheck accessor removed from the DSL. Use simpleCheck { msg => ... } or the standard jsonPath / bodyString check builders directly.
main / 1.0.xKafkaStreams to KafkaConsumerThe plugin uses KafkaConsumer instead of KafkaStreams for reply tracking.
| Before (Streams) | After (Consumer) |
|---|---|
application.id | group.id |
default.key.serde | (removed) |
default.value.serde | (removed) |
// Before
.consumeSettings(Map(
"bootstrap.servers" -> "localhost:9092",
"application.id" -> "my-test-group",
))
// After
.consumeSettings(Map(
"bootstrap.servers" -> "localhost:9092",
"group.id" -> "my-test-group",
))
What to revisit during this step:
default.key.serde and default.value.serde.group.id as a runtime behavior choice, not just a rename. Reusing the same group means later runs may resume committed offsets.consumeSettings(...); producer settings alone are not enough.Older snippets often show only requestTopic(...) and replyTopic(...), but upgrade work should also refresh the surrounding consumer configuration and timeout choices. When moving to main, review the current README examples instead of copying older request-reply fragments blindly.
main is narrower than some older examplesBefore upgrading old simulations, compare them against Current API Surface. In particular, main intentionally does not document or expose older patterns such as:
consumeFrom, consumeAny, keyForTracking, or saveAsrequestMatchBy / replyMatchByKafkaProtobufDsl / protobufBodyIf your older suite depends on those APIs, plan a code migration instead of a pure version bump.
application.id with group.id if you are migrating from older KafkaStreams-based tracking.group.id, enable.auto.commit, and auto.offset.reset deliberately.main instead of copying snippets from blog posts or stale branches.Each is a plain consumer project: it depends on the published artifact exactly as your own project does, and runs its simulations with that build tool's own Gatling task. Nothing in them is specific to this repository, so you can copy one and start from it.
Publish the plugin locally once, then run whichever you like:
docker compose -f docker-compose.kafka.yml up -d
sbt 'set ThisBuild / version := "0.0.0-EXAMPLES-SNAPSHOT"' publishM2
(cd examples/scala && sbt "Gatling / test") # 5 simulations
mvn -f examples/java/pom.xml verify # 4 simulations
(cd examples/kotlin && ./gradlew gatlingRun --all) # 4 simulations
Point any of them at a released version instead of the local snapshot and they run unchanged.
CI runs all three, and additionally checks — with no broker — that every example on disk has recorded coverage and that no two examples share a topic:
sbt "Test / runMain org.galaxio.gatling.kafka.examples.ExampleCoverageCheck"
Enable the shared git hook once per clone — pre-commit runs scalafmt and re-stages the files
you touched, so CI's formatting gate never trips on you:
./scripts/install-hooks.sh
Bypass it with SKIP_SCALAFMT=1 git commit … (or git commit --no-verify) when needed.
Commit subjects follow Conventional Commits — release notes are generated from them.
# Compile the library
sbt compile
# Run the full Scala test suite in the Test scope
sbt test
# Run the Gatling simulations exercised in CI (requires Kafka/Schema Registry, for example via Docker Compose)
sbt "Gatling / test"
# Check formatting (matches the formatting CI step)
sbt scalafmtCheckAll scalafmtSbtCheck
# Format code
sbt scalafmtAll scalafmtSbt
# Recommended local check before pushing (matches the main CI flow)
sbt clean compile "Gatling / test" test
Releases are manual and tag-driven. Pushing a vX.Y.Z tag that is reachable from main (or a
release/* branch) runs release.yml: it compiles, tests,
publishes to Sonatype via sbt-ci-release (version derived from the tag by dynver), and opens a
GitHub Release with notes rendered by git-cliff from
cliff.toml.
git checkout main && git pull
git tag -a vX.Y.Z -m "Release vX.Y.Z"
git push origin vX.Y.Z
Nothing publishes from a branch push — ci.yml only lints, compiles,
and tests. A tag that is not on main/release/* is rejected by the workflow. Published
coordinates are immutable: to fix a bad release, ship the next patch version rather than moving
the tag.
Apache License 2.0. See LICENSE for details.
Scala
65.1%
Shell
19.3%
PowerShell
7.1%
Java
6.8%
Python
1.8%
Gatling plugin for Apache Kafka — produce, request-reply, and consume load testing with Avro, Protobuf, and Schema Registry support
22
stars
179
commits
Scala
primary language
Sep 6, 2026
updated
Kafka protocol plugin for Gatling load testing framework. The main branch supports produce-only and request-reply Kafka flows with plain serialization and Avro helpers.
| Branch / Line | Gatling | Scala | Java | Kafka client |
|---|---|---|---|---|
main / 1.3.x | 3.13.5 | 2.13.18 | 17+ | Apache 3.9.x |
1.1.x – 1.2.x | 3.13.5 | 2.13.18 | 17+ | Confluent 7.9.x-ce |
1.0.x | 3.13.5 | 2.13.16 | 17+ | Confluent 7.9.x-ccs |
| 0.22.x | 3.13.x | 2.13 | 17+ | Confluent 7.x |
| 0.21.x | 3.12.x | 2.13 | 17+ | Confluent 7.x |
| 0.20.3 | 3.11.5 | 2.13 | 17+ | Confluent 7.x |
Kafka client: from
1.3.0the plugin depends on the Apache release ofkafka-clients(org.apache.kafka:kafka-clients:3.9.x) rather than Confluent's-cerebuild of the same upstream code. Confluent Platform 7.9.x is built from Apache Kafka 3.9.x, so this is the same code under a different version scheme — but the Confluent rebuild is published only topackages.confluent.io, which made the plugin unresolvable for anyone building against Maven Central. Broker compatibility is unchanged.
Version guidance: if you are on Gatling
3.11.5, use plugin0.20.3. The1.0.x/mainline targets Gatling3.13.x.Upgrading from an older release? Start with the Migration Guide below. It summarizes the supported upgrade paths and the breaking or behavioral changes that tend to matter most.
Branch strategy:
mainis the active development branch and current release line. Short-lived topic branches are cut frommain, andbackport/*branches are only created when a released line needs a targeted follow-up fix.
libraryDependencies += "org.galaxio" %% "gatling-kafka-plugin" % "<version>" % Test
gatling("org.galaxio:gatling-kafka-plugin_2.13:<version>")
<dependency>
<groupId>org.galaxio</groupId>
<artifactId>gatling-kafka-plugin_2.13</artifactId>
<version>${version}</version>
<scope>test</scope>
</dependency>
Everything above resolves from Maven Central. No additional repository is required for plain serialization — which covers producing, request-reply, checks, and consume-only tracking.
Schema-Registry-backed Avro needs two artifacts that Confluent publishes only to its own repository —
they are not on Maven Central, so the plugin declares them as provided and you add them yourself.
This mirrors how avro4s has always worked here. Skip this section entirely if you do not use
Schema Registry; nothing else in the plugin needs it.
resolvers += "Confluent" at "https://packages.confluent.io/maven/"
libraryDependencies ++= Seq(
"io.confluent" % "kafka-avro-serializer" % "7.9.9" % Test,
"io.confluent" % "kafka-streams-avro-serde" % "7.9.9" % Test,
).map(_.exclude("org.apache.kafka", "kafka-clients"))
repositories {
maven("https://packages.confluent.io/maven/")
}
dependencies {
gatling("io.confluent:kafka-avro-serializer:7.9.9") {
exclude(group = "org.apache.kafka", module = "kafka-clients")
}
gatling("io.confluent:kafka-streams-avro-serde:7.9.9") {
exclude(group = "org.apache.kafka", module = "kafka-clients")
}
}
<repositories>
<repository>
<id>confluent</id>
<url>https://packages.confluent.io/maven/</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>io.confluent</groupId>
<artifactId>kafka-avro-serializer</artifactId>
<version>7.9.9</version>
<scope>test</scope>
<exclusions>
<exclusion><groupId>org.apache.kafka</groupId><artifactId>kafka-clients</artifactId></exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>io.confluent</groupId>
<artifactId>kafka-streams-avro-serde</artifactId>
<version>7.9.9</version>
<scope>test</scope>
<exclusions>
<exclusion><groupId>org.apache.kafka</groupId><artifactId>kafka-clients</artifactId></exclusion>
</exclusions>
</dependency>
</dependencies>
The kafka-clients exclusion is not optional — keep it. These artifacts pull
io.confluent:kafka-schema-registry-client, which depends on Confluent's own rebuild of the Kafka
client (kafka-clients:7.9.x-ccs). sbt and Gradle both resolve conflicts by taking the highest
version, so that rebuild wins over the Apache 3.9.x this plugin declares and your load test silently
runs a different client from the one the plugin is built and tested against. Excluding it leaves the
plugin's own Apache client in place. Maven resolves nearest-wins and is not affected, but the exclusion
is harmless there and keeps the three snippets equivalent.
Your simulation code needs no change: org.galaxio.gatling.kafka.Predef._ exposes the Avro serdes
exactly as before. See Schema Registry Integration for usage.
If you are installing this while upgrading an older test suite, read the Migration Guide before copying examples from main.
docker compose -f docker-compose.kafka.yml up -d
Stop:
docker compose -f docker-compose.kafka.yml down
import org.galaxio.gatling.kafka.Predef._
import io.gatling.core.Predef._
class KafkaSimulation extends Simulation {
val kafkaConf = kafka
.properties(Map("bootstrap.servers" -> "localhost:9092"))
val scn = scenario("Kafka Producer")
.exec(
kafka("send message")
.topic("test-topic")
.send[String, String]("key", """{"msg": "hello"}""")
)
setUp(scn.inject(atOnceUsers(1))).protocols(kafkaConf)
}
import static org.galaxio.gatling.kafka.javaapi.KafkaDsl.*;
import static io.gatling.javaapi.core.CoreDsl.*;
public class KafkaSimulation extends Simulation {
var kafkaConf = kafka()
.properties(Map.of("bootstrap.servers", "localhost:9092"));
var scn = scenario("Kafka Producer")
.exec(
kafka("send message")
.topic("test-topic")
.send("key", "{\"msg\": \"hello\"}")
);
{ setUp(scn.injectOpen(atOnceUsers(1)).protocols(kafkaConf)); }
}
import org.galaxio.gatling.kafka.javaapi.KafkaDsl.*
import io.gatling.javaapi.core.CoreDsl.*
class KafkaSimulation : Simulation() {
val kafkaConf = kafka()
.properties(mapOf("bootstrap.servers" to "localhost:9092"))
val scn = scenario("Kafka Producer")
.exec(
kafka("send message")
.topic("test-topic")
.send("key", """{"msg": "hello"}""")
)
init { setUp(scn.injectOpen(atOnceUsers(1)).protocols(kafkaConf)) }
}
The main branch currently ships:
kafka("name").topic("topic").send(...)kafka("name").requestReply.requestTopic(...).replyTopic(...).send(...).matchByValue or .matchByMessage(...)org.galaxio.gatling.kafka.avro4s._ or custom Kafka Serde[T]The following APIs are not available on main and are intentionally not documented below:
consumeFrom, consumeAny, keyForTracking, or saveAsrequestMatchBy and replyMatchBypartition, timestamp, or silentKafkaProtobufDsl helpers such as protobufBodyimport org.galaxio.gatling.kafka.Predef._
scenario("Producer")
.exec(
kafka("send string")
.topic("test-topic")
.send[String, String]("key", "payload"),
)
Target a specific partition or set an explicit timestamp on produced records:
kafka("send to partition")
.topic("test-topic")
.send[String, String]("key", "payload")
.partition(3)
.timestamp(System.currentTimeMillis())
Both .partition() and .timestamp() accept Gatling Expression values for dynamic resolution from the session.
kafka("silent request")
.topic("test-topic")
.send[String]("foo")
.silent
Set the topic on each request builder with kafka("name").topic("...").
Request-reply needs both producer settings and consumer settings. The producer sends the request, and the consumer side tracks replies on the configured reply topic.
import scala.concurrent.duration._
val kafkaConf = kafka
.producerSettings(
"bootstrap.servers" -> "localhost:9092",
)
.consumeSettings(
"bootstrap.servers" -> "localhost:9092",
)
.timeout(10.seconds)
kafka("request reply").requestReply
.requestTopic("requests")
.replyTopic("replies")
.send[String, String]("key", """{"action": "process"}""")
.check(jsonPath("$.status").is("ok"))
The example below is the shortest complete setup we recommend for a new request-reply simulation on local Kafka.
import io.gatling.core.Predef._
import io.gatling.core.structure.ScenarioBuilder
import org.apache.kafka.clients.consumer.ConsumerConfig
import org.apache.kafka.clients.producer.ProducerConfig
import org.galaxio.gatling.kafka.Predef._
import scala.concurrent.duration._
class RequestReplySimulation extends Simulation {
private val requestTopic = "requests"
private val replyTopic = "replies"
private val kafkaConf = kafka
.producerSettings(
ProducerConfig.BOOTSTRAP_SERVERS_CONFIG -> "localhost:9092",
ProducerConfig.ACKS_CONFIG -> "1",
)
.consumeSettings(
ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG -> "localhost:9092",
ConsumerConfig.GROUP_ID_CONFIG -> s"gatling-rr-${System.currentTimeMillis()}",
ConsumerConfig.AUTO_OFFSET_RESET_CONFIG -> "latest",
)
.timeout(15.seconds)
private val scn: ScenarioBuilder = scenario("request-reply")
.exec(
kafka("send request").requestReply
.requestTopic(requestTopic)
.replyTopic(replyTopic)
.send[String, String]("order-42", """{"action":"process"}""")
.check(jsonPath("$.status").is("ok")),
)
setUp(scn.inject(atOnceUsers(1))).protocols(kafkaConf)
}
Required consumer-side settings in that example:
consumeSettings("bootstrap.servers" -> ...) is mandatory. Without it, the plugin never creates the reply-tracking consumer.group.id should be unique per local run unless you deliberately want to resume committed offsets.auto.offset.reset=latest keeps a fresh local group focused on replies produced after the simulation starts..timeout(...) must cover both Kafka round-trip latency and the first consumer-group assignment for the reply topic.Reply-topic assumptions:
requestTopic.replyTopic.order-42 as the Kafka key.Minimal local responder setup:
docker compose -f docker-compose.kafka.yml up -d.requests and republishes to replies using the same key.If you want a repository-backed responder example instead of writing your own, see KafkaIntegrationSpec.scala, especially the request-reply integration test that wires an input topic, reply topic, sender, and dynamic consumer together end to end.
Expected success signal:
send request action as successful..check(...) clause.Timed out waiting for reply or Timed out waiting for consumer assignment errors in the run output.| Method | Request extractor | Response extractor |
|---|---|---|
| (default) | msg.key | msg.key |
.matchByValue | msg.value | msg.value |
.matchByMessage(fn) | fn(msg) | fn(msg) |
These matchers are configured on the protocol, not on individual request builders:
import org.galaxio.gatling.kafka.request.KafkaProtocolMessage
def correlationIdFromHeader(headerName: String): KafkaProtocolMessage => Array[Byte] =
_.headers
.flatMap(headers => Option(headers.lastHeader(headerName)).map(_.value()))
.orNull
Return
null, notArray.emptyByteArray, when the field is missing. An empty array is a value: every request missing the header would produce the same correlation id, they would all share one slot, and replies would be matched to the wrong virtual user. Returningnullmakes the plugin fail those requests immediately with a message naming the cause.
KafkaConsumer for reply tracking. The consumer is created once per distinct consumer bootstrap.servers value and reused by all scenarios using that protocol.When you supply consumeSettings, the plugin always adds byte-array deserializers and also injects these defaults unless you override them:
| Setting | Default | Why |
|---|---|---|
group.id | gatling-kafka-test-<uuid> | Generated when absent so reply tracking can start without forcing a shared consumer group across runs. |
auto.offset.reset | latest | New consumer groups start from newly produced replies instead of replaying old traffic. |
enable.auto.commit | true | Kafka commits offsets automatically unless you opt out explicitly. |
Two important consequences follow from those defaults:
auto.offset.reset=latest only matters when the consumer group has no committed offsets yet.group.id and keep enable.auto.commit=true, later runs resume from committed offsets for that group. In that case Kafka may ignore latest and continue from the stored position instead.group.id values or provide a unique group.id per run.group.id, decide explicitly whether you want committed offsets. Override enable.auto.commit and auto.offset.reset instead of relying on defaults..matchByValue and .matchByMessage(...) must extract the same logical id on both sides.| Symptom | Likely cause | What to check |
|---|---|---|
| Requests are sent but no replies are ever matched | No consumer was created for tracking | Make sure the protocol includes consumeSettings("bootstrap.servers" -> ...), not only producer settings. |
| First requests on a reply topic time out under load or right after startup | Topic subscription and partition assignment consumed most of the timeout budget | Increase .timeout(...) and verify the consumer group can join and get assignments promptly. |
| Replies seem to be skipped on later test runs | A reused group.id resumed from committed offsets | Use a fresh group.id, or override enable.auto.commit / auto.offset.reset deliberately. |
| Late replies do not recover a timed-out request | Correlation entries are removed after timeout | Treat the timeout as a hard deadline and size it for your end-to-end latency envelope. |
| Replies arrive on Kafka but still do not match | Request and reply are extracting different correlation ids | Verify whether you are matching by key, value, or a custom extractor, and confirm both sides produce the same bytes. |
kafka("consume event")
.consumeFrom("events")
.keyForTracking("#{eventKey}")
.check(bodyString.exists)
.saveAs("eventBody")(msg => new String(msg.value))
Consume first available (no correlation):
kafka("consume any")
.consumeAny("events")
.saveAs("payload")(msg => new String(msg.value))
Add avro4s to your test dependencies:
libraryDependencies += "com.sksamuel.avro4s" %% "avro4s-core" % "4.1.2" % Test
Usage with automatic schema derivation:
import com.sksamuel.avro4s._
import org.galaxio.gatling.kafka.Predef._
import org.galaxio.gatling.kafka.avro4s._
case class Ingredient(name: String, sugar: Double, fat: Double)
scenario("Avro4s")
.exec(
kafka("send avro")
.topic("ingredients")
.send[String, Ingredient]("key", Ingredient("Cheese", 0d, 70d)),
)
Requires two extra dependencies. From
1.3.0the Confluent Schema Registry artifacts areprovidedrather than inherited, because they are not published to Maven Central. Add them and the Confluent resolver as shown in Installation → Optional: Avro via Confluent Schema Registry before using anything in this section. Without them the code below still compiles, and fails at run time withNoClassDefFoundError: io/confluent/kafka/streams/serdes/avro/GenericAvroSerde.
For Schema Registry-backed Avro classes, provide an implicit schemaRegUrl or your own Kafka Serde[T]:
implicit val schemaRegUrl: String = "http://localhost:8081"
The same applies to avroBody checks and to the Java facade's avro(...) entry points, which are
backed by the same Confluent serdes.
See AvroClassWithRequestReplySimulation.scala for a complete request-reply example with a custom Avro Serde.
Using sbt-schema-registry-plugin:
sbt schemaRegistryDownload
Predef / KafkaDsl (entry points, implicits)
|
KafkaProtocolBuilder (producerSettings, consumeSettings, timeout, matchers)
KafkaRequestBuilderBase (DSL: .topic.send, .requestReply)
|
+-- KafkaRequestAction (produce-only action)
+-- KafkaRequestReplyAction (produce + correlated reply tracking)
+-- KafkaConsumeAction (consume-only tracking)
|
KafkaMessageTrackerActor (Akka actor for correlation)
TrackersPool (shared consumer per bootstrap servers, tracker per reply topic)
KafkaSender / KafkaSenderPool (producer pool)
Use this section as release-based upgrade notes. Start from the version you are on today, then apply the checklist for the target line you want to adopt.
| Current line | Target line | Notes |
|---|---|---|
0.22.x / RC | 1.0.0 | Remove protocol-level .topic(...) calls, set topic on each request builder. Remove any use of messageCheck. |
0.20.3 | 1.0.x | Move from Gatling 3.11.5 to 3.13.x, update request-reply consumer settings, re-check examples against current README. |
0.21.x | 1.0.x | Stay on Gatling 3.13.x, review request-reply defaults and DSL surface. |
0.20.x or older | 1.0.x | Treat as full doc refresh. Older consume-only or per-action matcher APIs are not present. |
1.0.x – 1.2.x | 1.3.x | Build-file only. Plain users: no change. Schema Registry Avro users: declare two artifacts and the Confluent resolver — see below. |
1.3.x | 2.0.0 | Source-breaking, but only for API that could not work. Most suites need no change — see below. |
2.0.x | 2.1.0 | No source change. Reporting moves: failure messages now name the kind of failure, and requests the plugin rejects before sending get a request name of their own — see below. |
2.1.0No changes to the DSL, the javaapi facade, protocol settings or wire formats — nothing you have written stops compiling.
What changes is what a run reports, in four places. One of them can turn a passing assertion red, so read that one before
upgrading.
A failure is now reported as TimeoutException: Expiring 1 record(s) for request-topic-0 rather than as
Expiring 1 record(s) for request-topic-0. Nothing else changes: the status is still KO, timings are
unchanged, and successful requests are untouched. Three paths are affected:
| Path | Before | Now |
|---|---|---|
| Request-reply delivery or reply-channel failure | Expiring 1 record(s) for t-0 | TimeoutException: Expiring 1 record(s) for t-0 |
| Produce-only send failure | Failed to send request to Kafka broker: Expiring 1 record(s) for t-0 | Failed to send request to Kafka broker: TimeoutException: Expiring 1 record(s) for t-0 |
| Consumer failure (fails every in-flight request-reply at once) | Consumer failure: <text>, or Consumer failure: null when the exception had no message | Consumer failure: KafkaException: <text> |
The kind of failure was already being collected on the request-reply path — it was handed to Gatling in
the response-code slot that sits beside the message on logResponse. Gatling OSS discards that slot
before writing run data: its file serializer writes groups, name, timestamps, status and message and
nothing else, its console writer keys the error histogram by message, and the record the HTML report
parses back has no field for it at all. The value therefore reached no report, no assertion and no
simulation.log (issue #254). Putting it in the message is what makes it visible.
Where a failure arrives wrapped, the cause is named too — IllegalStateException: Kafka consumer failed (caused by SaslAuthenticationException: …). The plugin wraps every consumer fault in one
IllegalStateException with a fixed message, so without the cause a broker outage, an ACL rejection and
a SASL misconfiguration were reported identically.
What to check. If you assert on, grep for, or group by the exact text of a failure message, the
<ExceptionType>: prefix is new. In the HTML report's errors table, failures that used to share one row
because they shared a message now split by exception type.
KafkaMessageTracker.SendFailed.errorType is deprecatedThat field carried the failure kind to the response-code slot described above. With the kind now in
errorMessage, it has no destination left, so nothing reads it and nothing sets it — every SendFailed
carries None.
It is not removed. SendFailed is a published case class, so dropping a field would change apply,
copy, unapply and the accessor, and anything compiled against 2.0.x would fail at run time with
NoSuchMethodError rather than at compile time — this project declares versionScheme := "semver-spec",
so build tools treat 2.0.x → 2.1.0 as compatible and would not warn. Removal belongs in the next major
release. Nothing you have compiles differently in 2.1.0: constructing it, reading errorType,
copy(errorType = …) and a four-arity case SendFailed(id, msg, token, kind) => all still work. Drop
the argument at your convenience; if you matched on it, it was already always None.
A request-reply can end before the record reaches the broker: the configured matcher yields no correlation id, or the reply channel cannot be established. Both were reported under the request name your simulation declared, and both are now reported under a derived one:
| What happened | Reported as |
|---|---|
| The matcher returned nothing for this request | <your name> [rejected: no correlation id] |
| The reply channel could not be acquired | <your name> [rejected: no reply channel] |
| The producer never delivered the record | <your name> [rejected: not delivered] |
The delivery-failure row is the one most likely to move your numbers. With Kafka's default
delivery.timeout.ms of two minutes, a broker outage used to put a ~120 000 ms sample on every in-flight
request, under the request's own name. Those requests never reached the broker, so that figure measured
nothing about your system under test — and it dwarfed everything else on this list.
Status, failure message and measured interval are unchanged. A rejection that waited still reports its wait; a rejection decided instantly still reports a near-zero one.
Why the name and not something else. Gatling feeds a request name's response-time distribution from every entry carrying
that name, failures included, and its assertion API has no successful-only scope for responseTime. So a request that never
left the JVM was contributing a sample to the percentile your simulation asserts on. The request name is the only field a
plugin controls that separates those samples: the response-code slot is discarded before a run's data is written, and the
alternative — reporting them as run errors — would drop them out of failedRequests entirely, which is worse.
⚠️ This can turn an assertion red. An assertion of the form:
details("My Request").failedRequests.count.is(3)
stops counting rejections, because they are no longer reported under My Request. global.failedRequests and
global.allRequests are unchanged, and so is every assertion written against them. If a per-request failure count of yours
was counting rejections, point it at the derived name:
details("My Request [rejected: no correlation id]").failedRequests.count.is(3)
The bracketed suffix is reserved. Do not declare a request whose name ends in [rejected: …]; nothing prevents it, and
the two rows would merge. Gatling's own redirect naming (<name> Redirect 1) carries the same exposure.
Groups are cleaned too. A rejection no longer contributes to the cumulated response time of an enclosing
group(...) either, so details("<your group>").responseTime stops absorbing requests that never reached
the broker. It is still counted as a failed request.
What this does not fix. global.responseTime still includes rejection samples. Gatling feeds the run-wide distribution
without reference to request name, so no choice available to a plugin can clean it. Assert latency on
details("<your name>").responseTime and on details("<your group>").responseTime, and correctness on
failedRequests / successfulRequests. Reply timeouts are unchanged and still report under the request's
own name: a request that waited out its budget did measure your system under test, unlike the three
rejections above.
bootstrap.servers now fails before the run startsA protocol whose consumer settings carry no bootstrap.servers has no reply channel, so a request-reply against it could
never be answered. Every such request used to be failed individually — one KO per virtual user, each with a near-zero
interval, for a misconfiguration no run could recover from. The simulation now refuses to start, naming the missing entry.
Note the gate is the bootstrap.servers entry, not the consumeSettings(...) call: a protocol that calls consumeSettings
and sets only, say, group.id is refused too, and the message says so.
⚠️ The refusal happens while Gatling materialises the scenario, which is after your before {} hook has run and before
after {} becomes reachable. If your before starts anything — a container, a stub service, a topic seed — it will not be
torn down. The plugin's own producers and consumers are still closed, but yours are not. Guard before with a try/catch,
or check the protocol's consumer settings before you start anything expensive.
kafka.properties(...) carries no consumer settings by design, and
publishing never asks for a reply channel. If your simulation only sends, nothing changes.setUp level. A produce-only protocol applied to every scenario, in a
simulation that also contains a request-reply, now stops the whole run rather than failing that one scenario's requests.
Attach the request-reply scenario's protocol per injection, or give the protocol consumeSettings.A service can answer with a tombstone — a record with no payload — which is ordinary traffic on a compacted topic. If your protocol correlates on the record value, there is nothing in a tombstone to correlate on: the reply arrives, cannot be matched to its request, and is dropped. The request then failed on its reply timeout, indistinguishable from a service that never answered at all.
The timeout is still reported, but it now names what happened:
Reply timeout after 12000 ms. Replies also arrived on this reply topic that KafkaValueMatcher could not
read a correlation id from, so this request may have been answered in a shape this configuration cannot
correlate rather than not answered at all. matchByValue correlates on the payload, so the payload cannot
be null — give this request a body, or correlate on a key or header instead.
The remedy is derived from the matcher you configured, so a header-correlated channel gets header advice. The clause carries no count: Gatling groups its error table by message text, and a per-timeout number would split one row into hundreds. The count is in the log instead.
Against a service that genuinely never answers, the message is exactly what it was.
If your target may answer with tombstones, correlate on a header rather than the value. A tombstone still carries its
headers, so a header-correlated reply reaches its request and your checks run against it — including the clean absent-payload
failure 1.2.0 introduced, which on the value-correlated path was unreachable because the reply never got there:
import org.galaxio.gatling.kafka.request.KafkaProtocolMessage
// A `val`, not a `def`. Reply channels are keyed on matcher identity, and passing a method reference to
// matchByMessage eta-expands to a fresh function every time it is evaluated — so two protocols built from
// the same method on the same reply topic get two channels, two consumers, and each sees only part of the
// replies. One `val` shared by every protocol that correlates the same way avoids it.
val correlationId: KafkaProtocolMessage => Array[Byte] =
msg => msg.headers.flatMap(hs => Option(hs.lastHeader("x-correlation-id"))).map(_.value()).orNull
val protocol = kafka
.producerSettings(...)
.consumeSettings(...)
.matchByMessage(correlationId)
Correlating on the key works for the same reason and needs no extractor. matchByValue against a tombstone-answering service
cannot be made to work — there is nothing in the record to correlate on.
1.3.x → 2.0.0 — removals2.0.0 removes published API. Every removal below is something that either could not run, never
carried a value, or had no caller — nothing that worked has been taken away. If your simulations
use kafka("name").topic(...).send(...) and
kafka("name").requestReply.requestTopic(...).replyTopic(...).send(...), you need no source change
at all.
send(...) without a topic is goneThe send(...) overloads that could be called directly on kafka("name") — without .topic(...)
or .requestReply... first — have been removed from both the Scala DSL and the javaapi facade.
They never worked. Every action they built carried no producer topic and failed at send time with
Kafka producer topic is not defined; the Java sendWithClass(payload, class, headers) overload
threw IllegalArgumentException while the scenario was still being constructed. If you have one of
these in a suite, it has been reporting failures rather than sending.
// before — compiles, fails at run time
kafka("request").send[String, String]("key", "payload")
// after — name the topic first
kafka("request").topic("my-topic").send[String, String]("key", "payload")
kafka-streams-scala is no longer inheritedsessionWindowedSerde and consumedFromSerde, deprecated in 1.3.0, are removed — and with them
the org.apache.kafka:kafka-streams-scala dependency your build used to receive transitively. The
plugin never built a Streams topology, so nothing in it used them.
If you genuinely build Streams topologies in your harness, declare the artifact yourself:
libraryDependencies += "org.apache.kafka" %% "kafka-streams-scala" % "3.9.2" % Test
The inherited dependency set is now scala-library, kafka-clients and avro — three coordinates,
each used by plugin code.
KafkaProtocolMessage.responseCode is goneNothing ever set it: every message carried None from the day it was added. Your reports do not
change.
If you read the field, drop the read. If you matched on it, it was always None.
KafkaCheckType.ResponseCode is goneUse KafkaCheckType.Simple. Nothing could produce a check carrying ResponseCode, and its
materialization was identical to Simple's, so behaviour is unchanged.
send(...) now returns KafkaRequestBuilderThe RequestBuilder[K, V] trait had one abstract member and one implementation, and was public only
because it was the declared return type of the documented send methods. It is folded into
KafkaRequestBuilder. Invisible unless you wrote the type out:
// before
val req: RequestBuilder[String, String] = kafka("r").topic("t").send("k", "v")
// after
val req: KafkaRequestBuilder[String, String] = kafka("r").topic("t").send("k", "v")
Inference (val req = ...) needs no change. The Java facade's own
javaapi.request.builder.RequestBuilder is a different class and is unaffected.
LazyGenericAvroSerde is goneAn internal wrapper that existed only because the 1.x binary freeze forced avroSerde to be a
strict val; it is now simply lazy. Predef still supplies Serde[GenericRecord], still hands out
one stable instance — so Predef.avroSerde.configure(...) and KafkaChecks.avroSerde().configure(...)
still configure the serde the DSL later uses — plain simulations still start with no Confluent artifact
present, and Avro still fails only when you actually use it. No source change.
If you referenced the class directly, use Predef's avroSerde (Scala) or
KafkaChecks.avroSerde() (Java) instead.
Neither appears in ordinary simulations; both break code that names them directly.
javaapi.request.builder.RequestBuilder's constructor now takes the concrete Scala
KafkaRequestBuilder<K, V> instead of the removed RequestBuilder<K, V> trait. Only code that
constructs this wrapper itself is affected — kafka(...).topic(...).send(...) returns one already.KafkaAttributes.producerTopic is Expression[String] instead of Option[Expression[String]].
Every builder that reaches an action supplies a topic, so the Option could only ever be Some.
If you build KafkaAttributes directly — in a test harness, say — drop the Some(...) wrapper.kafka("name").topic(...) previously passed the request name through as a literal, while
kafka("name").requestReply()... resolved it as a Gatling expression. The produce-only path now
matches request-reply.
For almost every suite this changes nothing — a plain name like "BasicRequest" resolves to itself.
It matters only if your request name contains #{...}: it used to appear verbatim in reports and now
resolves per virtual user, and a name referring to a session attribute that is not set will fail the
request instead of reporting the literal.
// resolves per user now; previously reported literally as "order-#{orderId}"
kafka("order-#{orderId}").topic("orders").send(key, payload);
If you were relying on the literal, escape it (\#{orderId}) or rename the request.
KafkaCheckMaterializer.avroBody and KafkaMessagePreparer.avroPreparer are goneUnreachable. Both avroBody entry points — KafkaCheckSupport.avroBody for Scala and
KafkaDsl.avroBody() for Java — deserialize inside the check's extractor and never used these. Keep
using the entry points; nothing about writing an Avro body check changes.
timeout / withDefaultTimeout on the producer-settings step are goneThe reply timeout belongs to the consume step — a produce-only protocol never waits for a reply.
Both methods remain on consumeSettings(...):
kafka.producerSettings(...).consumeSettings(...).timeout(10.seconds) // unchanged
For a produce-only protocol use kafka.properties(...).
1.2.x → 1.3.x — Confluent artifacts are no longer inheritedIf you use plain serialization, avro4s, or anything other than Confluent Schema Registry: nothing to do. Bump the version and carry on. You may also drop the Confluent resolver from your build if you added one — it is no longer needed.
Why this changed. Up to 1.2.x the plugin declared four dependencies that are published only to
packages.confluent.io, while its released POM carries no repository list. A consumer building against
Maven Central alone could not resolve the plugin at all. Two of the four (the Kafka client and Kafka
Streams Scala) were Confluent rebuilds of Apache code and now use the Apache coordinates. The other two
are genuinely Confluent-only and have become optional.
If you use Schema-Registry-backed Avro, your build previously received these transitively. Declare
them yourself, exactly as you already declare avro4s:
resolvers += "Confluent" at "https://packages.confluent.io/maven/"
libraryDependencies ++= Seq(
"io.confluent" % "kafka-avro-serializer" % "7.9.9" % Test,
"io.confluent" % "kafka-streams-avro-serde" % "7.9.9" % Test,
).map(_.exclude("org.apache.kafka", "kafka-clients"))
Keep the kafka-clients exclusion — without it these artifacts pull Confluent's own rebuild of the
Kafka client, which outranks the Apache one this plugin declares under sbt's and Gradle's
highest-version-wins resolution. See
Installation for the Gradle and Maven forms and the
full explanation.
No source change is required, in any scenario. Imports, implicits, and every Scala and Java entry
point are unchanged — Predef still supplies the Avro serdes.
How you find out if you forget them. Not at build time: provided dependencies are simply absent
from your classpath, so resolution and compilation both succeed. The serdes construct their Confluent
delegate on first use, so the first Avro send or check fails with
NoClassDefFoundError: io/confluent/kafka/streams/serdes/avro/GenericAvroSerde — in the middle of a
run. If your suite uses Schema Registry Avro, add the dependencies before you upgrade rather than
finding out from a load test.
Also in this release, sessionWindowedSerde and consumedFromSerde are deprecated. They are Kafka
Streams helpers that this plugin never used; they will be removed in 2.0.0 along with the
kafka-streams-scala dependency. If you genuinely build Streams topologies in your harness, depend on
org.apache.kafka:kafka-streams-scala_2.13 directly.
1.2.0No changes to the DSL, the javaapi facade or protocol settings — nothing you have written stops compiling. Three
behavioural changes, and two of them can turn a passing scenario red, so read the sections below before upgrading.
A request with no key produced an empty correlation id — and so did every other keyless request. They shared a single slot in the correlation table, so a reply resolved whichever request happened to occupy it: one virtual user was credited with another user's answer while the real owner timed out. Nothing in the report distinguished that from a genuine result.
Under the default matchByKey there is nothing to correlate a keyless reply on, so such a request is now reported as a
failure at issue time and is not published. The failure names the matcher and the remedy.
If a request-reply scenario of yours has no key, it will now go red. Those runs were reporting incorrect results before; the change surfaces that rather than causing it. Two ways forward, depending on what the request actually correlates on:
// Give each request a key to correlate on
kafka("req").requestReply
.requestTopic("in").replyTopic("out")
.send[String, String]("#{correlationId}", "payload")
// Or correlate on something the request already carries
val protocol = kafka
.producerSettings(...)
.consumeSettings(...)
.matchByValue // the payload itself
// .matchByMessage(msg => ...) // or a header / any extracted field
Request-reply that already sets a key, or that uses matchByValue / matchByMessage, is unaffected.
A reply can arrive with no payload at all — a tombstone on a compacted topic, or an acknowledgement carrying no body. Applying
a content check to one (bodyString, substring, bodyBytes, jsonPath, jmesPath) used to throw inside the reply-handling
path, which had nothing to catch it. The virtual user was never continued: no success, no failure, no next request. It
simply stopped, and the run's user count silently diverged from the load the profile was applying.
Such a check now reports the request as a failure naming the absent payload, and the virtual user carries on.
bodyString.is("") still passes on an empty reply and now
fails on a tombstone — "the service sent nothing" and "the service sent an empty string" are different findings.Independently of the checks above, no check can strand a virtual user any more: one that throws for any reason is reported as a failure and the user continues.
The plugin was substituting an empty byte array for an absent key, which is not the same thing: an empty key is a present
key. Kafka hashes it, and murmur2 of an empty input is a constant — so every keyless message landed on the same partition
for the whole run, no matter how long the run was or how many partitions the topic had. This applied to fire-and-forget
sends as well as request-reply.
Keyless messages now reach the broker with a genuinely absent key, so Kafka applies its normal keyless partitioning instead of hashing a constant.
hash(key) % partitions, so per-key ordering guarantees hold.global.responseTime percentiles, note that a run rejecting every request will lower them; assert on
failedRequests/successfulRequests to catch that case. In 2.1.0 these rejections moved to a request name of their
own, so they no longer affect the percentile reported for the request you declared — but global.responseTime still
blends them in, so the advice above still holds for it.⚠️ Keyless sends to a log-compacted topic now fail
A compacted topic (
cleanup.policy=compact) requires every record to have a key, and Kafka treats an empty key as present but a null key as absent. The old empty-array substitution therefore slipped past that check; a genuinely absent key does not.A scenario that publishes keyless records — request-reply or fire-and-forget — to a compacted topic goes from passing to every request failing, with
InvalidRecordException: Compacted topic cannot accept message without key.This is the broker enforcing a rule the plugin was previously hiding: those records were never valid on that topic. Give the send a key:
kafka("req").topic("compacted-topic").send[String, String]("#{entityId}", "payload")
1.1.0No changes to the DSL, the javaapi facade, protocol settings or wire formats. One behavioural change worth knowing about, in request-reply only.
Request-reply now registers the pending request before handing the record to the producer, so that a reply cannot arrive before the plugin is watching for it. Previously the request was sent first and the reply channel acquired afterwards, which meant a reply from a fast responder could be received and silently discarded, and the request then failed on its reply timeout as though nothing had answered.
The consequence: when acquiring the reply channel fails — for example the reply topic is never assigned within the configured timeout — the request is now reported as a failure without being published. Before, it was published first and then reported as a failure.
2.1.0: its message gained an exception-type prefix, and it moved to a
request name of its own, <your name> [rejected: no reply channel] — see
Upgrading to 2.1.0.A request-reply is now measured from the moment the record is handed to the producer. Previously it was measured from the broker's acknowledgement of that record, which excluded the produce round trip from every reported time.
Expect reported times to grow by one produce acknowledgement — typically a few milliseconds against a local broker, more
with acks=all or a loaded one. Nothing about the requests changed; only where the clock starts.
This is the interval the virtual user actually waits for, and it is what every other Gatling protocol reports. If you compare percentiles across this upgrade, compare them knowing the earlier numbers omitted a leg.
Channel setup is still never included: the clock starts after the reply channel exists, so a first request on a new reply topic is not charged for its subscription and rebalance.
1.0.0 from 0.22.x / RCThe kafka.topic("...") shorthand on the protocol builder was deprecated in 1.0.0-RC1 and is now removed.
| Before (removed) | After |
|---|---|
kafka.topic("my-topic").properties(Map(...)) | kafka.producerSettings(Map(...))... |
kafka("req").send(payload) with protocol-level topic | kafka("req").topic("my-topic").send(payload) |
Every request builder must now declare its own topic with .topic("...") or .requestTopic("...").replyTopic("...").
KafkaMessageCheck removedmessageCheck accessor removed from the DSL. Use simpleCheck { msg => ... } or the standard jsonPath / bodyString check builders directly.
main / 1.0.xKafkaStreams to KafkaConsumerThe plugin uses KafkaConsumer instead of KafkaStreams for reply tracking.
| Before (Streams) | After (Consumer) |
|---|---|
application.id | group.id |
default.key.serde | (removed) |
default.value.serde | (removed) |
// Before
.consumeSettings(Map(
"bootstrap.servers" -> "localhost:9092",
"application.id" -> "my-test-group",
))
// After
.consumeSettings(Map(
"bootstrap.servers" -> "localhost:9092",
"group.id" -> "my-test-group",
))
What to revisit during this step:
default.key.serde and default.value.serde.group.id as a runtime behavior choice, not just a rename. Reusing the same group means later runs may resume committed offsets.consumeSettings(...); producer settings alone are not enough.Older snippets often show only requestTopic(...) and replyTopic(...), but upgrade work should also refresh the surrounding consumer configuration and timeout choices. When moving to main, review the current README examples instead of copying older request-reply fragments blindly.
main is narrower than some older examplesBefore upgrading old simulations, compare them against Current API Surface. In particular, main intentionally does not document or expose older patterns such as:
consumeFrom, consumeAny, keyForTracking, or saveAsrequestMatchBy / replyMatchByKafkaProtobufDsl / protobufBodyIf your older suite depends on those APIs, plan a code migration instead of a pure version bump.
application.id with group.id if you are migrating from older KafkaStreams-based tracking.group.id, enable.auto.commit, and auto.offset.reset deliberately.main instead of copying snippets from blog posts or stale branches.Each is a plain consumer project: it depends on the published artifact exactly as your own project does, and runs its simulations with that build tool's own Gatling task. Nothing in them is specific to this repository, so you can copy one and start from it.
Publish the plugin locally once, then run whichever you like:
docker compose -f docker-compose.kafka.yml up -d
sbt 'set ThisBuild / version := "0.0.0-EXAMPLES-SNAPSHOT"' publishM2
(cd examples/scala && sbt "Gatling / test") # 5 simulations
mvn -f examples/java/pom.xml verify # 4 simulations
(cd examples/kotlin && ./gradlew gatlingRun --all) # 4 simulations
Point any of them at a released version instead of the local snapshot and they run unchanged.
CI runs all three, and additionally checks — with no broker — that every example on disk has recorded coverage and that no two examples share a topic:
sbt "Test / runMain org.galaxio.gatling.kafka.examples.ExampleCoverageCheck"
Enable the shared git hook once per clone — pre-commit runs scalafmt and re-stages the files
you touched, so CI's formatting gate never trips on you:
./scripts/install-hooks.sh
Bypass it with SKIP_SCALAFMT=1 git commit … (or git commit --no-verify) when needed.
Commit subjects follow Conventional Commits — release notes are generated from them.
# Compile the library
sbt compile
# Run the full Scala test suite in the Test scope
sbt test
# Run the Gatling simulations exercised in CI (requires Kafka/Schema Registry, for example via Docker Compose)
sbt "Gatling / test"
# Check formatting (matches the formatting CI step)
sbt scalafmtCheckAll scalafmtSbtCheck
# Format code
sbt scalafmtAll scalafmtSbt
# Recommended local check before pushing (matches the main CI flow)
sbt clean compile "Gatling / test" test
Releases are manual and tag-driven. Pushing a vX.Y.Z tag that is reachable from main (or a
release/* branch) runs release.yml: it compiles, tests,
publishes to Sonatype via sbt-ci-release (version derived from the tag by dynver), and opens a
GitHub Release with notes rendered by git-cliff from
cliff.toml.
git checkout main && git pull
git tag -a vX.Y.Z -m "Release vX.Y.Z"
git push origin vX.Y.Z
Nothing publishes from a branch push — ci.yml only lints, compiles,
and tests. A tag that is not on main/release/* is rejected by the workflow. Published
coordinates are immutable: to fix a bad release, ship the next patch version rather than moving
the tag.
Apache License 2.0. See LICENSE for details.
Scala
65.1%
Shell
19.3%
PowerShell
7.1%
Java
6.8%
Python
1.8%