diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index be80d48b1fd..b6674080ba2 100644 --- a/.github/workflows/spring-boot-4-matrix.yml +++ b/.github/workflows/spring-boot-4-matrix.yml @@ -71,8 +71,6 @@ jobs: - name: Build sample artifacts run: | ./gradlew \ - :sentry-samples:sentry-samples-spring-boot-4:bootJar \ - :sentry-samples:sentry-samples-spring-boot-4:testClasses \ :sentry-samples:sentry-samples-spring-boot-4-webflux:bootJar \ :sentry-samples:sentry-samples-spring-boot-4-webflux:testClasses \ :sentry-samples:sentry-samples-spring-boot-4-opentelemetry:bootJar \ @@ -86,13 +84,6 @@ jobs: - name: Test sentry-spring-boot-4 integration suite run: ./gradlew :sentry-spring-boot-4:integrationTest - - name: Run sentry-samples-spring-boot-4 - run: | - python3 test/system-test-runner.py test \ - --module "sentry-samples-spring-boot-4" \ - --agent false \ - --auto-init "true" - - name: Run sentry-samples-spring-boot-4-webflux 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 2fcdfce0a95..97895ca2d1e 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -70,9 +70,6 @@ jobs: - sample: "sentry-samples-jul" agent: "false" agent-auto-init: "true" - - sample: "sentry-samples-spring-boot-4" - agent: "false" - agent-auto-init: "true" - sample: "sentry-samples-spring-boot-4-log4j2" agent: "false" agent-auto-init: "true" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c7c6b5e06e3..b437b33799e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -223,6 +223,7 @@ spring-kafka2 = { module = "org.springframework.kafka:spring-kafka", version = " 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" } +spring-kafka4-test = { module = "org.springframework.kafka:spring-kafka-test" } 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" } springboot4-resttestclient = { module = "org.springframework.boot:spring-boot-resttestclient", version.ref = "springboot4" } diff --git a/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts index 00b85d45bd5..7d83c62b2b9 100644 --- a/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4/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-4" @@ -61,35 +60,4 @@ dependencies { // database query tracing implementation(projects.sentryJdbc) runtimeOnly(libs.hsqldb) - - testImplementation(kotlin(Config.kotlinStdLib)) - testImplementation(projects.sentry) - testImplementation(projects.sentrySystemTestSupport) - testImplementation(libs.apollo3.kotlin) - testImplementation(libs.kotlin.test.junit) - testImplementation(libs.slf4j2.api) - testImplementation(libs.springboot4.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-4/src/test/kotlin/io/sentry/DummyTest.kt b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/DummyTest.kt deleted file mode 100644 index 6f762b7e453..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4/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-4/src/test/kotlin/io/sentry/systemtest/KafkaQueueSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/KafkaQueueSystemTest.kt deleted file mode 100644 index 43781cf2c56..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4/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-4/src/test/resources/logback.xml b/sentry-samples/sentry-samples-spring-boot-4/src/test/resources/logback.xml deleted file mode 100644 index a36b8f80f76..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4/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-4/build.gradle.kts b/sentry-spring-boot-4/build.gradle.kts index 3140080ce6d..fb6e70c39df 100644 --- a/sentry-spring-boot-4/build.gradle.kts +++ b/sentry-spring-boot-4/build.gradle.kts @@ -169,6 +169,7 @@ testing { implementation(projects.sentryAsyncProfiler) implementation(projects.sentryGraphql22) implementation(projects.sentryJdbc) + implementation(projects.sentryKafka) implementation(projects.sentryLogback) implementation(projects.sentryOkhttp) implementation(projects.sentryTestSupport) @@ -184,11 +185,13 @@ testing { implementation(libs.springboot4.starter.cache) implementation(libs.springboot4.starter.graphql) implementation(libs.springboot4.starter.jdbc) + implementation(libs.springboot4.starter.kafka) implementation(libs.springboot4.starter.restclient) implementation(libs.springboot4.starter.test) implementation(libs.springboot4.starter.web) implementation(libs.springboot4.starter.webclient) implementation(libs.springboot4.starter.webflux) + implementation(libs.spring.kafka4.test) runtimeOnly(libs.hsqldb) } } diff --git a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/IntegrationTest.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/IntegrationTest.kt index 9f8d8d60a8c..f61ad6c3826 100644 --- a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/IntegrationTest.kt +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/IntegrationTest.kt @@ -7,6 +7,7 @@ import org.springframework.boot.restclient.RestTemplateBuilder import org.springframework.boot.resttestclient.TestRestTemplate import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.web.server.LocalServerPort +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,14 @@ 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) +// 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], webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, @@ -27,12 +31,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-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/KafkaController.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/KafkaController.kt new file mode 100644 index 00000000000..4f628d20691 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/KafkaController.kt @@ -0,0 +1,28 @@ +package io.sentry.spring.boot4.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") +class KafkaController(private val kafkaTemplate: KafkaTemplate) { + @GetMapping("/produce") + fun produce(@RequestParam message: String): String { + kafkaTemplate.send(KAFKA_TOPIC, message) + return "Message sent: $message" + } +} + +@Component +class KafkaConsumer { + /** Does nothing with the message; Sentry traces the listener invocation. */ + @KafkaListener(topics = [KAFKA_TOPIC], groupId = "sentry-integration-test") + fun listen(message: String) {} +} diff --git a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/KafkaIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/KafkaIntegrationTest.kt new file mode 100644 index 00000000000..c92e79d8769 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/KafkaIntegrationTest.kt @@ -0,0 +1,49 @@ +package io.sentry.spring.boot4.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 194dc9af2f2..b257618ad4b 100644 --- a/test/system-test-runner.py +++ b/test/system-test-runner.py @@ -76,7 +76,6 @@ "sentry-samples-spring-boot-jakarta-log4j2", "sentry-samples-spring-boot-jakarta-opentelemetry", "sentry-samples-spring-boot-jakarta-opentelemetry-noagent", - "sentry-samples-spring-boot-4", "sentry-samples-spring-boot-4-log4j2", "sentry-samples-spring-boot-4-opentelemetry", "sentry-samples-spring-boot-4-opentelemetry-noagent", @@ -88,7 +87,6 @@ "sentry-samples-spring-boot-jakarta-log4j2", "sentry-samples-spring-boot-jakarta-opentelemetry", "sentry-samples-spring-boot-jakarta-opentelemetry-noagent", - "sentry-samples-spring-boot-4", "sentry-samples-spring-boot-4-log4j2", "sentry-samples-spring-boot-4-opentelemetry", "sentry-samples-spring-boot-4-opentelemetry-noagent", @@ -862,7 +860,6 @@ def get_available_modules(self) -> List[ModuleConfig]: ModuleConfig("sentry-samples-spring-boot-jakarta-opentelemetry", "true", "true", "false"), ModuleConfig("sentry-samples-spring-boot-jakarta-opentelemetry", "true", "false", "false"), ModuleConfig("sentry-samples-spring-boot-4-webflux", "false", "true", "false"), - ModuleConfig("sentry-samples-spring-boot-4", "false", "true", "false"), ModuleConfig("sentry-samples-spring-boot-4-log4j2", "false", "true", "false"), ModuleConfig("sentry-samples-spring-boot-4-opentelemetry-noagent", "false", "true", "false"), ModuleConfig("sentry-samples-spring-boot-4-opentelemetry", "true", "true", "false"),