From ab95f5e1521d8650a4cfa8a70072d2feb354859d Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 20 Oct 2026 15:57:21 +0200 Subject: [PATCH 1/2] test(spring-boot): Move the Boot 3 Kafka test into the module Ports the jakarta sample's KafkaQueueSystemTest to the integrationTest suite with an embedded KRaft broker, so no Docker is needed. The five sample tests become two that keep every assertion: the publishing request's queue.publish span, and a queue.process transaction that continues the producer's trace. Both check the messaging attributes. The broker is declared with @EmbeddedKafka on the shared base class so it belongs to the one application context: it starts before the app's listener connects and stops after it. The jakarta sample has no system tests left, so it comes off the system-test runner, the backend workflow and the Spring Boot 3 matrix, which keeps running the module's integration suites. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/spring-boot-3-matrix.yml | 9 -- .github/workflows/system-tests-backend.yml | 5 +- gradle/libs.versions.toml | 4 +- .../build.gradle.kts | 31 ----- .../src/test/kotlin/io/sentry/DummyTest.kt | 12 -- .../sentry/systemtest/KafkaQueueSystemTest.kt | 117 ------------------ .../src/test/resources/logback.xml | 17 --- sentry-spring-boot-jakarta/build.gradle.kts | 3 + .../integrationtest/IntegrationTest.kt | 13 +- .../integrationtest/KafkaController.kt | 28 +++++ .../integrationtest/KafkaIntegrationTest.kt | 49 ++++++++ test/system-test-runner.py | 3 - 12 files changed, 94 insertions(+), 197 deletions(-) delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/DummyTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/KafkaQueueSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/test/resources/logback.xml create mode 100644 sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/KafkaController.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/KafkaIntegrationTest.kt diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index bf1eac06afc..d62317b44fc 100644 --- a/.github/workflows/spring-boot-3-matrix.yml +++ b/.github/workflows/spring-boot-3-matrix.yml @@ -71,8 +71,6 @@ jobs: - name: Build sample artifacts run: | ./gradlew \ - :sentry-samples:sentry-samples-spring-boot-jakarta:bootJar \ - :sentry-samples:sentry-samples-spring-boot-jakarta:testClasses \ :sentry-samples:sentry-samples-spring-boot-webflux-jakarta:bootJar \ :sentry-samples:sentry-samples-spring-boot-webflux-jakarta:testClasses \ :sentry-samples:sentry-samples-spring-boot-jakarta-opentelemetry:bootJar \ @@ -89,13 +87,6 @@ jobs: :sentry-spring-boot-jakarta:integrationTest \ :sentry-spring-boot-jakarta:integrationTestOtelAgent - - name: Test sentry-samples-spring-boot-jakarta - run: | - python3 test/system-test-runner.py test \ - --module "sentry-samples-spring-boot-jakarta" \ - --agent false \ - --auto-init "true" - - name: Test sentry-samples-spring-boot-webflux-jakarta run: | python3 test/system-test-runner.py test \ diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index 2165a6c6a67..2fcdfce0a95 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -24,13 +24,10 @@ jobs: strategy: fail-fast: false matrix: - sample: [ "sentry-samples-spring-boot-jakarta" ] + sample: [ "sentry-samples-spring-boot" ] agent: [ "false" ] agent-auto-init: [ "true" ] include: - - sample: "sentry-samples-spring-boot" - agent: "false" - agent-auto-init: "true" - sample: "sentry-samples-spring-boot-opentelemetry-noagent" agent: "false" agent-auto-init: "true" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e5c2f9e2171..c7c6b5e06e3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -48,6 +48,7 @@ spotless = "8.8.0" springboot2 = "2.7.18" springboot3 = "3.5.0" springboot4 = "4.1.0" +springKafka3 = "3.3.5" sqldelight = "2.3.2" # Android @@ -219,7 +220,8 @@ springboot3-starter-jdbc = { module = "org.springframework.boot:spring-boot-star springboot3-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator", version.ref = "springboot3" } springboot3-starter-cache = { module = "org.springframework.boot:spring-boot-starter-cache", version.ref = "springboot3" } spring-kafka2 = { module = "org.springframework.kafka:spring-kafka", version = "2.8.11" } -spring-kafka3 = { module = "org.springframework.kafka:spring-kafka", version = "3.3.5" } +spring-kafka3 = { module = "org.springframework.kafka:spring-kafka", version.ref = "springKafka3" } +spring-kafka3-test = { module = "org.springframework.kafka:spring-kafka-test", version.ref = "springKafka3" } spring-kafka4 = { module = "org.springframework.kafka:spring-kafka" } kafka-clients = { module = "org.apache.kafka:kafka-clients", version = "3.8.1" } springboot4-otel = { module = "io.opentelemetry.instrumentation:opentelemetry-spring-boot-starter", version.ref = "otelInstrumentation" } diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts index cdc5753a9c5..e3591289cdb 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts @@ -6,7 +6,6 @@ plugins { alias(libs.plugins.spring.dependency.management) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) - id("io.sentry.systemtest") } group = "io.sentry.sample.spring-boot-jakarta" @@ -67,34 +66,4 @@ dependencies { // database query tracing implementation(projects.sentryJdbc) runtimeOnly(libs.hsqldb) - - testImplementation(kotlin(Config.kotlinStdLib)) - testImplementation(projects.sentry) - testImplementation(projects.sentrySystemTestSupport) - testImplementation(libs.kotlin.test.junit) - testImplementation(libs.slf4j2.api) - testImplementation(libs.springboot3.starter.test) { - exclude(group = "org.junit.vintage", module = "junit-vintage-engine") - } - testImplementation("ch.qos.logback:logback-classic:1.5.16") - testImplementation("ch.qos.logback:logback-core:1.5.16") -} - -tasks.register("systemTest").configure { - group = "verification" - description = "Runs the System tests" - - val test = project.extensions.getByType()["test"] - testClassesDirs = test.output.classesDirs - classpath = test.runtimeClasspath - - maxParallelForks = 1 - - filter { includeTestsMatching("io.sentry.systemtest*") } -} - -tasks.named("test").configure { - require(this is Test) - - filter { excludeTestsMatching("io.sentry.systemtest.*") } } diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/DummyTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/DummyTest.kt deleted file mode 100644 index 6f762b7e453..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/DummyTest.kt +++ /dev/null @@ -1,12 +0,0 @@ -package io.sentry - -import kotlin.test.Test -import kotlin.test.assertTrue - -class DummyTest { - @Test - fun `the only test`() { - // only needed to have more than 0 tests and not fail the build - assertTrue(true) - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/KafkaQueueSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/KafkaQueueSystemTest.kt deleted file mode 100644 index 43781cf2c56..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/KafkaQueueSystemTest.kt +++ /dev/null @@ -1,117 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import org.junit.Before - -/** - * System tests for Kafka queue instrumentation. - * - * Requires: - * - The sample app running with `--spring.profiles.active=kafka` - * - A Kafka broker at localhost:9092 - * - The mock Sentry server at localhost:8000 - */ -class KafkaQueueSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `producer endpoint creates queue publish span`() { - val restClient = testHelper.restClient - - restClient.produceKafkaMessage("test-message") - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, _ -> - testHelper.doesTransactionContainSpanWithOp(transaction, "queue.publish") - } - } - - @Test - fun `consumer creates queue process transaction`() { - val restClient = testHelper.restClient - - restClient.produceKafkaMessage("test-consumer-message") - assertEquals(200, restClient.lastKnownStatusCode) - - // The consumer runs asynchronously, so wait for the queue.process transaction - testHelper.ensureTransactionReceived { transaction, _ -> - testHelper.doesTransactionHaveOp(transaction, "queue.process") - } - } - - @Test - fun `producer and consumer share same trace`() { - val restClient = testHelper.restClient - - restClient.produceKafkaMessage("trace-test-message") - assertEquals(200, restClient.lastKnownStatusCode) - - // Capture the trace ID from the producer transaction (has queue.publish span) - var producerTraceId: String? = null - testHelper.ensureTransactionReceived { transaction, _ -> - if (testHelper.doesTransactionContainSpanWithOp(transaction, "queue.publish")) { - producerTraceId = transaction.contexts.trace?.traceId?.toString() - true - } else { - false - } - } - - // Verify the consumer transaction has the same trace ID - // Use retryCount=3 since the consumer may take a moment to process - testHelper.ensureEnvelopeReceived(retryCount = 3) { envelopeString -> - val envelope = - testHelper.jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) - ?: return@ensureEnvelopeReceived false - val txItem = - envelope.items.firstOrNull { it.header.type == io.sentry.SentryItemType.Transaction } - ?: return@ensureEnvelopeReceived false - val tx = - txItem.getTransaction(testHelper.jsonSerializer) ?: return@ensureEnvelopeReceived false - - tx.contexts.trace?.operation == "queue.process" && - tx.contexts.trace?.traceId?.toString() == producerTraceId - } - } - - @Test - fun `queue publish span has messaging attributes`() { - val restClient = testHelper.restClient - - restClient.produceKafkaMessage("attrs-test") - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, _ -> - val span = transaction.spans.firstOrNull { it.op == "queue.publish" } - if (span == null) return@ensureTransactionReceived false - - val data = span.data ?: return@ensureTransactionReceived false - data["messaging.system"] == "kafka" && data["messaging.destination.name"] == "sentry-topic" - } - } - - @Test - fun `queue process transaction has messaging attributes`() { - val restClient = testHelper.restClient - - restClient.produceKafkaMessage("process-attrs-test") - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, _ -> - if (!testHelper.doesTransactionHaveOp(transaction, "queue.process")) { - return@ensureTransactionReceived false - } - - val data = transaction.contexts.trace?.data ?: return@ensureTransactionReceived false - data["messaging.system"] == "kafka" && data["messaging.destination.name"] == "sentry-topic" - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/resources/logback.xml b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/resources/logback.xml deleted file mode 100644 index a36b8f80f76..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/resources/logback.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - %-4relative [%thread] %-5level %logger{35} -%kvp- %msg %n - - - - - - - diff --git a/sentry-spring-boot-jakarta/build.gradle.kts b/sentry-spring-boot-jakarta/build.gradle.kts index 2978ef9bb4f..9991b33b512 100644 --- a/sentry-spring-boot-jakarta/build.gradle.kts +++ b/sentry-spring-boot-jakarta/build.gradle.kts @@ -180,6 +180,7 @@ testing { implementation(projects.sentryAsyncProfiler) implementation(projects.sentryGraphql22) implementation(projects.sentryJdbc) + implementation(projects.sentryKafka) implementation(projects.sentryLogback) implementation(projects.sentryOkhttp) implementation(projects.sentryOpenfeature) @@ -200,6 +201,8 @@ testing { implementation(libs.springboot3.starter.test) implementation(libs.springboot3.starter.web) implementation(libs.springboot3.starter.webflux) + implementation(libs.spring.kafka3) + implementation(libs.spring.kafka3.test) runtimeOnly(libs.hsqldb) } } diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTest.kt index a430bd5b543..b0a5c07d4e7 100644 --- a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTest.kt +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTest.kt @@ -7,6 +7,7 @@ import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.web.client.TestRestTemplate import org.springframework.boot.test.web.server.LocalServerPort import org.springframework.boot.web.client.RestTemplateBuilder +import org.springframework.kafka.test.context.EmbeddedKafka import org.springframework.test.context.DynamicPropertyRegistry import org.springframework.test.context.DynamicPropertySource import org.springframework.test.context.junit4.SpringRunner @@ -14,11 +15,15 @@ import org.springframework.test.context.junit4.SpringRunner /** * Drives [IntegrationTestApplication] over HTTP and asserts what reaches [server]. * - * Subclasses share one application context and one server, so the app starts once per suite. - * Envelopes that arrive late can outlive [server]'s reset, so tests match on values only they - * produce. + * Subclasses share one application context, one server and one Kafka broker, so each starts once + * per suite. Envelopes that arrive late can outlive [server]'s reset, so tests match on values only + * they produce. */ @RunWith(SpringRunner::class) +// A KRaft broker in this JVM, so no Docker is needed. It belongs to the context, so it starts +// before +// the app's Kafka listener connects and stops after it. +@EmbeddedKafka(topics = [KAFKA_TOPIC], bootstrapServersProperty = "spring.kafka.bootstrap-servers") @SpringBootTest( classes = [IntegrationTestApplication::class], webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, @@ -27,12 +32,14 @@ import org.springframework.test.context.junit4.SpringRunner "sentry.traces-sample-rate=1.0", "sentry.clients.ok-http-enabled=true", "sentry.enable-cache-tracing=true", + "sentry.enable-queue-tracing=true", "sentry.logs.enabled=true", "sentry.profile-session-sample-rate=1.0", "sentry.profile-lifecycle=TRACE", "sentry.profiling-traces-dir-path=\${java.io.tmpdir}/sentry-integration-test/profiling-traces", "spring.datasource.url=jdbc:p6spy:hsqldb:mem:testdb", "spring.datasource.driver-class-name=com.p6spy.engine.spy.P6SpyDriver", + "spring.kafka.consumer.auto-offset-reset=earliest", ], ) abstract class IntegrationTest { diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/KafkaController.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/KafkaController.kt new file mode 100644 index 00000000000..cbdda822976 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/KafkaController.kt @@ -0,0 +1,28 @@ +package io.sentry.spring.boot.jakarta.integrationtest + +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.stereotype.Component +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +const val KAFKA_TOPIC = "sentry-topic" + +@RestController +@RequestMapping("/kafka") +open class KafkaController(private val kafkaTemplate: KafkaTemplate) { + @GetMapping("/produce") + open fun produce(@RequestParam message: String): String { + kafkaTemplate.send(KAFKA_TOPIC, message) + return "Message sent: $message" + } +} + +@Component +open class KafkaConsumer { + /** Does nothing with the message; Sentry traces the listener invocation. */ + @KafkaListener(topics = [KAFKA_TOPIC], groupId = "sentry-integration-test") + open fun listen(message: String) {} +} diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/KafkaIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/KafkaIntegrationTest.kt new file mode 100644 index 00000000000..4e427004868 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/KafkaIntegrationTest.kt @@ -0,0 +1,49 @@ +package io.sentry.spring.boot.jakarta.integrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.protocol.SentryTransaction +import io.sentry.test.transaction +import kotlin.test.Test + +/** A message sent through Kafka is traced on both sides: where it's published and processed. */ +class KafkaIntegrationTest : IntegrationTest() { + + @Test + fun `publishing a message traces a queue publish span`() { + val producer = produce("publish-test") + + val span = producer.spans.single { it.op == "queue.publish" } + assertThat(span.data).containsEntry("messaging.system", "kafka") + assertThat(span.data).containsEntry("messaging.destination.name", KAFKA_TOPIC) + } + + @Test + fun `processing a message continues the producer's trace`() { + val traceId = produce("process-test").contexts.trace!!.traceId + + val consumer = + server + .awaitEnvelope { + it.transaction()?.contexts?.trace?.let { trace -> + trace.operation == "queue.process" && trace.traceId == traceId + } == true + } + .transaction()!! + val data = consumer.contexts.trace!!.data + assertThat(data).containsEntry("messaging.system", "kafka") + assertThat(data).containsEntry("messaging.destination.name", KAFKA_TOPIC) + } + + /** Sends [message] through the app and returns the transaction of the request that sent it. */ + private fun produce(message: String): SentryTransaction { + assertThat(restTemplate.getForObject("/kafka/produce?message=$message", String::class.java)) + .isEqualTo("Message sent: $message") + return server + .awaitEnvelope { + it.transaction()?.let { t -> + t.transaction == "GET /kafka/produce" && t.request?.queryString == "message=$message" + } == true + } + .transaction()!! + } +} diff --git a/test/system-test-runner.py b/test/system-test-runner.py index ed3fda97dcd..194dc9af2f2 100644 --- a/test/system-test-runner.py +++ b/test/system-test-runner.py @@ -73,7 +73,6 @@ "sentry-samples-spring-boot", "sentry-samples-spring-boot-opentelemetry", "sentry-samples-spring-boot-opentelemetry-noagent", - "sentry-samples-spring-boot-jakarta", "sentry-samples-spring-boot-jakarta-log4j2", "sentry-samples-spring-boot-jakarta-opentelemetry", "sentry-samples-spring-boot-jakarta-opentelemetry-noagent", @@ -86,7 +85,6 @@ "sentry-samples-spring-boot", "sentry-samples-spring-boot-opentelemetry", "sentry-samples-spring-boot-opentelemetry-noagent", - "sentry-samples-spring-boot-jakarta", "sentry-samples-spring-boot-jakarta-log4j2", "sentry-samples-spring-boot-jakarta-opentelemetry", "sentry-samples-spring-boot-jakarta-opentelemetry-noagent", @@ -859,7 +857,6 @@ def get_available_modules(self) -> List[ModuleConfig]: ModuleConfig("sentry-samples-spring-boot-opentelemetry", "true", "false", "false"), ModuleConfig("sentry-samples-spring-boot-webflux-jakarta", "false", "true", "false"), ModuleConfig("sentry-samples-spring-boot-webflux", "false", "true", "false"), - ModuleConfig("sentry-samples-spring-boot-jakarta", "false", "true", "false"), ModuleConfig("sentry-samples-spring-boot-jakarta-log4j2", "false", "true", "false"), ModuleConfig("sentry-samples-spring-boot-jakarta-opentelemetry-noagent", "false", "true", "false"), ModuleConfig("sentry-samples-spring-boot-jakarta-opentelemetry", "true", "true", "false"), From 1685dba7213b2a1b58cd40c5db1f93a6fd19dd9c Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 20 Oct 2026 16:08:18 +0200 Subject: [PATCH 2/2] test(spring-boot): Fix the wrapping of the embedded broker comment Co-Authored-By: Claude Opus 5.5 (1M context) --- .../spring/boot/jakarta/integrationtest/IntegrationTest.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTest.kt index b0a5c07d4e7..d539470ae93 100644 --- a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTest.kt +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTest.kt @@ -20,9 +20,8 @@ import org.springframework.test.context.junit4.SpringRunner * they produce. */ @RunWith(SpringRunner::class) -// A KRaft broker in this JVM, so no Docker is needed. It belongs to the context, so it starts -// before -// the app's Kafka listener connects and stops after it. +// An in-JVM KRaft broker, so no Docker is needed. As part of the context, it starts before the +// app's Kafka listener connects and stops after it. @EmbeddedKafka(topics = [KAFKA_TOPIC], bootstrapServersProperty = "spring.kafka.bootstrap-servers") @SpringBootTest( classes = [IntegrationTestApplication::class],