From 4ac9eb15b9de069aafd84c613d12482da95de70b Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 20 Oct 2026 15:08:22 +0200 Subject: [PATCH 1/3] test(spring-boot): Move the Boot 3 sample's Person test into the module Adds an integrationTest suite to sentry-spring-boot-jakarta that runs a Spring Boot app in the test's JVM against MockSentryServer, and ports the jakarta sample's PersonSystemTest to it: errors with feature flags, the server transaction and its spans, logs, the JDBC query span and the profile linked to the transaction. It is a suite of its own because the unit tests' classpath carries the OpenTelemetry agent, which switches Sentry into agent mode. Test classes share one application context and one server, so the app starts once per suite. The Spring Boot 3 matrix now runs both of the module's integration suites against every Boot version it covers. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/spring-boot-3-matrix.yml | 6 + .../io/sentry/systemtest/PersonSystemTest.kt | 109 ------------------ sentry-spring-boot-jakarta/build.gradle.kts | 21 ++++ .../integrationtest/IntegrationTest.kt | 52 +++++++++ .../IntegrationTestApplication.kt | 70 +++++++++++ .../integrationtest/PersonIntegrationTest.kt | 100 ++++++++++++++++ .../src/integrationTest/resources/schema.sql | 5 + 7 files changed, 254 insertions(+), 109 deletions(-) delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTest.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTestApplication.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/PersonIntegrationTest.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTest/resources/schema.sql diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index 33e06c49b50..bf1eac06afc 100644 --- a/.github/workflows/spring-boot-3-matrix.yml +++ b/.github/workflows/spring-boot-3-matrix.yml @@ -83,6 +83,12 @@ jobs: :sentry-samples:sentry-samples-spring-jakarta:testClasses \ :sentry-opentelemetry:sentry-opentelemetry-agent:assemble + - name: Test sentry-spring-boot-jakarta integration suites + run: | + ./gradlew \ + :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 \ diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt deleted file mode 100644 index 2389734a8b3..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ /dev/null @@ -1,109 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.protocol.FeatureFlag -import io.sentry.protocol.SentryId -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import org.junit.Before - -class PersonSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `get person fails`() { - val restClient = testHelper.restClient - restClient.getPerson(1L) - assertEquals(500, restClient.lastKnownStatusCode) - - testHelper.ensureErrorReceived { event -> - event.message?.formatted == "Trying person with id=1" && - testHelper.doesEventHaveFlag(event, "my-feature-flag", true) - } - - testHelper.ensureErrorReceived { event -> - testHelper.doesEventHaveExceptionMessage(event, "Something went wrong [id=1]") && - testHelper.doesEventHaveFlag(event, "my-feature-flag", true) - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionHave( - transaction, - op = "http.server", - featureFlag = FeatureFlag("flag.evaluation.transaction-feature-flag", true), - ) && - testHelper.doesTransactionHaveSpanWith( - transaction, - op = "spanCreatedThroughSentryApi", - featureFlag = FeatureFlag("flag.evaluation.my-feature-flag", true), - ) - } - - Thread.sleep(10000) - - testHelper.ensureLogsReceived { logs, envelopeHeader -> - testHelper.doesContainLogWithBody(logs, "warn Sentry logging") && - testHelper.doesContainLogWithBody(logs, "error Sentry logging") && - testHelper.doesContainLogWithBody(logs, "hello there world!") && - testHelper.doesLogWithBodyHaveAttribute( - logs, - "warn Sentry logging", - "user.type", - "admin", - ) && - testHelper.doesLogWithBodyHaveAttribute( - logs, - "warn Sentry logging", - "feature.version", - 2, - ) && - testHelper.doesLogWithBodyHaveAttribute(logs, "warn Sentry logging", "debug.enabled", true) - } - } - - @Test - fun `create person works`() { - val restClient = testHelper.restClient - val person = Person("firstA", "lastB") - val returnedPerson = restClient.createPerson(person) - assertEquals(200, restClient.lastKnownStatusCode) - - assertEquals(person.firstName, returnedPerson!!.firstName) - assertEquals(person.lastName, returnedPerson!!.lastName) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOp(transaction, "PersonService.create") && - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "db.query", - "insert into person (firstName, lastName) values (?, ?)", - ) - } - } - - @Test - fun `create person starts a profile linked to the transaction`() { - var profilerId: SentryId? = null - val restClient = testHelper.restClient - val person = Person("firstA", "lastB") - val returnedPerson = restClient.createPerson(person) - assertEquals(200, restClient.lastKnownStatusCode) - - assertEquals(person.firstName, returnedPerson!!.firstName) - assertEquals(person.lastName, returnedPerson!!.lastName) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - profilerId = transaction.contexts.profile?.profilerId - transaction.transaction == "POST /person/" - } - testHelper.ensureProfileChunkReceived { profileChunk, envelopeHeader -> - profileChunk.profilerId == profilerId - } - } -} diff --git a/sentry-spring-boot-jakarta/build.gradle.kts b/sentry-spring-boot-jakarta/build.gradle.kts index 28ebe0ba872..8b029ba2afe 100644 --- a/sentry-spring-boot-jakarta/build.gradle.kts +++ b/sentry-spring-boot-jakarta/build.gradle.kts @@ -170,6 +170,27 @@ dependencies { testing { suites { + // Runs a Spring Boot app in the test's JVM against MockSentryServer. It is a suite of its own + // because the unit tests' classpath carries the agent, which switches Sentry into agent mode. + register("integrationTest") { + useJUnit() + dependencies { + implementation(platform(SpringBootPlugin.BOM_COORDINATES)) + implementation(projects.sentrySpringBootStarterJakarta) + implementation(projects.sentryAsyncProfiler) + implementation(projects.sentryJdbc) + implementation(projects.sentryLogback) + implementation(projects.sentryTestSupport) + implementation(libs.kotlin.test.junit) + implementation(libs.google.truth) + implementation(libs.springboot3.starter.aop) + implementation(libs.springboot3.starter.jdbc) + implementation(libs.springboot3.starter.test) + implementation(libs.springboot3.starter.web) + runtimeOnly(libs.hsqldb) + } + } + register("integrationTestOtelAgent") { useJUnit() dependencies { 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 new file mode 100644 index 00000000000..b2df6f0fbf0 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTest.kt @@ -0,0 +1,52 @@ +package io.sentry.spring.boot.jakarta.integrationtest + +import io.sentry.test.MockSentryServer +import org.junit.Before +import org.junit.runner.RunWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +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. + */ +@RunWith(SpringRunner::class) +@SpringBootTest( + classes = [IntegrationTestApplication::class], + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = + [ + "sentry.traces-sample-rate=1.0", + "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", + ], +) +abstract class IntegrationTest { + @Autowired lateinit var restTemplate: TestRestTemplate + + @Before + fun resetServer() { + server.reset() + } + + companion object { + val server = MockSentryServer() + + @JvmStatic + @DynamicPropertySource + fun sentryDsn(registry: DynamicPropertyRegistry) { + registry.add("sentry.dsn") { server.dsn } + } + } +} diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTestApplication.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTestApplication.kt new file mode 100644 index 00000000000..d0e19302ae5 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTestApplication.kt @@ -0,0 +1,70 @@ +package io.sentry.spring.boot.jakarta.integrationtest + +import io.sentry.Sentry +import io.sentry.spring.jakarta.tracing.SentrySpan +import org.slf4j.LoggerFactory +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.stereotype.Service +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +// Classes and methods are open because Spring proxies them and this module doesn't apply the +// kotlin-spring plugin. + +@SpringBootApplication open class IntegrationTestApplication + +open class Person(var firstName: String? = null, var lastName: String? = null) + +@RestController +@RequestMapping("/person/") +open class PersonController(private val personService: PersonService) { + private val logger = LoggerFactory.getLogger(PersonController::class.java) + + /** Reports to Sentry through every API an app uses, then fails the request. */ + @GetMapping("{id}") + open fun person(@PathVariable("id") id: Long): Person { + Sentry.addFeatureFlag("transaction-feature-flag", true) + val sentrySpan = Sentry.getSpan()!!.startChild("spanCreatedThroughSentryApi") + try { + Sentry.setAttribute("user.type", "admin") + Sentry.setAttribute("feature.version", 2) + Sentry.setAttribute("debug.enabled", true) + Sentry.logger().warn("warn Sentry logging") + Sentry.logger().error("error Sentry logging") + Sentry.logger().info("hello %s %s", "there", "world!") + Sentry.addFeatureFlag("my-feature-flag", true) + logger.error("Trying person with id={}", id, RuntimeException("error while loading")) + throw IllegalArgumentException("Something went wrong [id=$id]") + } finally { + sentrySpan.finish() + } + } + + @PostMapping + open fun create(@RequestBody person: Person): Person { + val sentrySpan = Sentry.getSpan()!!.startChild("spanCreatedThroughSentryApi") + try { + return personService.create(person) + } finally { + sentrySpan.finish() + } + } +} + +@Service +@SentrySpan +open class PersonService(private val jdbcTemplate: JdbcTemplate) { + open fun create(person: Person): Person { + jdbcTemplate.update( + "insert into person (firstName, lastName) values (?, ?)", + person.firstName, + person.lastName, + ) + return person + } +} diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/PersonIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/PersonIntegrationTest.kt new file mode 100644 index 00000000000..a0838b6fee2 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/PersonIntegrationTest.kt @@ -0,0 +1,100 @@ +package io.sentry.spring.boot.jakarta.integrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.Sentry +import io.sentry.protocol.FeatureFlag +import io.sentry.test.event +import io.sentry.test.hasFlag +import io.sentry.test.hasLogWithAttribute +import io.sentry.test.hasLogWithBody +import io.sentry.test.hasOp +import io.sentry.test.hasSpanWithOp +import io.sentry.test.hasSpanWithOpAndDescription +import io.sentry.test.logs +import io.sentry.test.profileChunk +import io.sentry.test.transaction +import kotlin.test.Test + +class PersonIntegrationTest : IntegrationTest() { + + @Test + fun `failed request reports errors, the transaction and logs`() { + val response = restTemplate.getForEntity("/person/1", String::class.java) + assertThat(response.statusCode.value()).isEqualTo(500) + Sentry.flush(5000) + + val logged = + server.awaitEnvelope { it.event()?.message?.formatted == "Trying person with id=1" }.event()!! + assertThat(logged.hasFlag("my-feature-flag", true)).isTrue() + + val thrown = + server + .awaitEnvelope { + it.event()?.exceptions?.any { e -> e.value == "Something went wrong [id=1]" } == true + } + .event()!! + assertThat(thrown.hasFlag("my-feature-flag", true)).isTrue() + + val transaction = + server.awaitEnvelope { it.transaction()?.transaction == "GET /person/{id}" }.transaction()!! + assertThat( + transaction.hasOp( + "http.server", + FeatureFlag("flag.evaluation.transaction-feature-flag", true), + ) + ) + .isTrue() + assertThat( + transaction.hasSpanWithOp( + "spanCreatedThroughSentryApi", + FeatureFlag("flag.evaluation.my-feature-flag", true), + ) + ) + .isTrue() + + val logs = + server + .awaitEnvelope { + it.logs()?.items?.any { log -> log.body == "warn Sentry logging" } == true + } + .logs()!! + assertThat(logs.hasLogWithBody("error Sentry logging")).isTrue() + assertThat(logs.hasLogWithBody("hello there world!")).isTrue() + assertThat(logs.hasLogWithAttribute("warn Sentry logging", "user.type", "admin")).isTrue() + assertThat(logs.hasLogWithAttribute("warn Sentry logging", "feature.version", 2)).isTrue() + assertThat(logs.hasLogWithAttribute("warn Sentry logging", "debug.enabled", true)).isTrue() + } + + @Test + fun `creating a person traces the service method and the query`() { + val response = + restTemplate.postForEntity("/person/", Person("firstA", "lastB"), Person::class.java) + assertThat(response.statusCode.value()).isEqualTo(200) + assertThat(response.body!!.firstName).isEqualTo("firstA") + assertThat(response.body!!.lastName).isEqualTo("lastB") + + val transaction = + server.awaitEnvelope { it.transaction()?.transaction == "POST /person/" }.transaction()!! + assertThat(transaction.hasSpanWithOp("PersonService.create")).isTrue() + assertThat( + transaction.hasSpanWithOpAndDescription( + "db.query", + "insert into person (firstName, lastName) values (?, ?)", + ) + ) + .isTrue() + } + + @Test + fun `creating a person starts a profile linked to the transaction`() { + val response = + restTemplate.postForEntity("/person/", Person("firstA", "lastB"), Person::class.java) + assertThat(response.statusCode.value()).isEqualTo(200) + + val transaction = + server.awaitEnvelope { it.transaction()?.transaction == "POST /person/" }.transaction()!! + val profilerId = transaction.contexts.profile?.profilerId + assertThat(profilerId).isNotNull() + server.awaitEnvelope(timeoutMillis = 30_000) { it.profileChunk()?.profilerId == profilerId } + } +} diff --git a/sentry-spring-boot-jakarta/src/integrationTest/resources/schema.sql b/sentry-spring-boot-jakarta/src/integrationTest/resources/schema.sql new file mode 100644 index 00000000000..7ca8a5cbf42 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTest/resources/schema.sql @@ -0,0 +1,5 @@ +CREATE TABLE person ( + id INTEGER IDENTITY PRIMARY KEY, + firstName VARCHAR(50) NOT NULL, + lastName VARCHAR(50) NOT NULL +); From ae2f4fd1cd2d1c1187e178801a1b7299414ad6eb Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 20 Oct 2026 15:26:44 +0200 Subject: [PATCH 2/3] test(spring-boot): Move the Boot 3 tracing and HTTP client tests into the module Ports the jakarta sample's DistributedTracingSystemTest and TodoSystemTest to the integrationTest suite. The app calls itself on its random port instead of a hardcoded localhost:8080, and serves the todo the sample fetched from jsonplaceholder.typicode.com, so the tests no longer need the internet. Adds MockSentryServer.assertNoEnvelope for the unsampled case. A flush can't prove an absence because the server may finish its transaction after the client already has the response, so it watches for a short window instead of the old 10s sleep. The suite declares context-propagation explicitly: without it Reactor can't carry the Sentry scope to the WebClient's thread. The sample only had it because Spring GraphQL pulls it in. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../DistributedTracingSystemTest.kt | 197 ------------------ .../io/sentry/systemtest/TodoSystemTest.kt | 77 ------- sentry-spring-boot-jakarta/build.gradle.kts | 7 + .../DistributedTracingController.kt | 22 ++ .../DistributedTracingIntegrationTest.kt | 137 ++++++++++++ .../integrationtest/IntegrationTest.kt | 1 + .../IntegrationTestApplication.kt | 27 ++- .../jakarta/integrationtest/TodoController.kt | 63 ++++++ .../integrationtest/TodoIntegrationTest.kt | 47 +++++ .../kotlin/io/sentry/test/MockSentryServer.kt | 19 ++ .../io/sentry/test/MockSentryServerTest.kt | 21 ++ 11 files changed, 343 insertions(+), 275 deletions(-) delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/DistributedTracingController.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/DistributedTracingIntegrationTest.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/TodoController.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/TodoIntegrationTest.kt diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt deleted file mode 100644 index 3cd16003024..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt +++ /dev/null @@ -1,197 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.protocol.SentryId -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotEquals -import org.junit.Before - -class DistributedTracingSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `get person distributed tracing`() { - val traceId = SentryId() - val restClient = testHelper.restClient - restClient.getPersonDistributedTracing( - 1L, - mapOf( - "sentry-trace" to "$traceId-424cffc8f94feeee-1", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(500, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /tracing/{id}" && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /person/{id}" && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - } - } - - @Test - fun `get person distributed tracing with sampled false`() { - val traceId = SentryId() - val restClient = testHelper.restClient - restClient.getPersonDistributedTracing( - 1L, - mapOf( - "sentry-trace" to "$traceId-424cffc8f94feeee-0", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=false,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(500, restClient.lastKnownStatusCode) - - testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /tracing/{id}" - } - - testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /person/{id}" - } - } - - @Test - fun `get person distributed tracing without sample_rand`() { - val traceId = SentryId() - val restClient = testHelper.restClient - restClient.getPersonDistributedTracing( - 1L, - mapOf( - "sentry-trace" to "$traceId-424cffc8f94feeee-1", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(500, restClient.lastKnownStatusCode) - - var sampleRand1: String? = null - var sampleRand2: String? = null - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - val matches = - transaction.transaction == "GET /tracing/{id}" && - envelopeHeader.traceContext!!.traceId == traceId && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - - if (matches) { - testHelper.logObject(envelopeHeader) - testHelper.logObject(transaction) - sampleRand1 = envelopeHeader.traceContext?.sampleRand - } - - matches - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - val matches = - transaction.transaction == "GET /person/{id}" && - envelopeHeader.traceContext!!.traceId == traceId && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - - if (matches) { - testHelper.logObject(envelopeHeader) - testHelper.logObject(transaction) - sampleRand2 = envelopeHeader.traceContext?.sampleRand - } - - matches - } - - assertEquals(sampleRand1, sampleRand2) - } - - @Test - fun `get person distributed tracing updates sample_rate on deferred decision`() { - val traceId = SentryId() - val restClient = testHelper.restClient - restClient.getPersonDistributedTracing( - 1L, - mapOf( - "sentry-trace" to "$traceId-424cffc8f94feeee", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(500, restClient.lastKnownStatusCode) - - var sampleRate1: String? = null - var sampleRate2: String? = null - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - val matches = - transaction.transaction == "GET /tracing/{id}" && - envelopeHeader.traceContext!!.traceId == traceId && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - - if (matches) { - testHelper.logObject(envelopeHeader) - testHelper.logObject(transaction) - sampleRate1 = envelopeHeader.traceContext?.sampleRate - } - - matches - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - val matches = - transaction.transaction == "GET /person/{id}" && - envelopeHeader.traceContext!!.traceId == traceId && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - - if (matches) { - testHelper.logObject(envelopeHeader) - testHelper.logObject(transaction) - sampleRate2 = envelopeHeader.traceContext?.sampleRate - } - - matches - } - - assertEquals(sampleRate1, sampleRate2) - assertNotEquals(sampleRate1, "0.5") - } - - @Test - fun `create person distributed tracing`() { - val traceId = SentryId() - val restClient = testHelper.restClient - val person = Person("firstA", "lastB") - val returnedPerson = - restClient.createPersonDistributedTracing( - person, - mapOf( - "sentry-trace" to "$traceId-424cffc8f94feeee-1", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(200, restClient.lastKnownStatusCode) - - assertEquals(person.firstName, returnedPerson!!.firstName) - assertEquals(person.lastName, returnedPerson!!.lastName) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "POST /tracing/" && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "POST /person/" && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt deleted file mode 100644 index 66600e9753d..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt +++ /dev/null @@ -1,77 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import org.junit.Before - -class TodoSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `get todo works`() { - val restClient = testHelper.restClient - restClient.getTodo(1L) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "http.client", - "GET https://jsonplaceholder.typicode.com/todos/1", - ) - } - } - - @Test - fun `get todo webclient works`() { - val restClient = testHelper.restClient - restClient.getTodoWebclient(1L) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "http.client", - "GET https://jsonplaceholder.typicode.com/todos/1", - ) - } - } - - @Test - fun `get todo restclient works`() { - val restClient = testHelper.restClient - restClient.getTodoRestClient(1L) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "http.client", - "GET https://jsonplaceholder.typicode.com/todos/1", - ) - } - } - - @Test - fun `get todo okhttp works`() { - val restClient = testHelper.restClient - restClient.getTodoOkHttp(1L) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /todo-okhttp/{id}" && - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "http.client", - "GET https://jsonplaceholder.typicode.com/todos/1", - ) - } - } -} diff --git a/sentry-spring-boot-jakarta/build.gradle.kts b/sentry-spring-boot-jakarta/build.gradle.kts index 8b029ba2afe..70c0ae267e5 100644 --- a/sentry-spring-boot-jakarta/build.gradle.kts +++ b/sentry-spring-boot-jakarta/build.gradle.kts @@ -180,13 +180,20 @@ testing { implementation(projects.sentryAsyncProfiler) implementation(projects.sentryJdbc) implementation(projects.sentryLogback) + implementation(projects.sentryOkhttp) implementation(projects.sentryTestSupport) + // Reactor's automatic context propagation needs it to carry the Sentry scope across + // threads. + // The sample only had it because Spring GraphQL pulls it in. + implementation(libs.context.propagation) implementation(libs.kotlin.test.junit) implementation(libs.google.truth) + implementation(libs.okhttp) implementation(libs.springboot3.starter.aop) implementation(libs.springboot3.starter.jdbc) implementation(libs.springboot3.starter.test) implementation(libs.springboot3.starter.web) + implementation(libs.springboot3.starter.webflux) runtimeOnly(libs.hsqldb) } } diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/DistributedTracingController.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/DistributedTracingController.kt new file mode 100644 index 00000000000..8f120bf250e --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/DistributedTracingController.kt @@ -0,0 +1,22 @@ +package io.sentry.spring.boot.jakarta.integrationtest + +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.client.RestClient + +/** Forwards to this app's own Person endpoints, so a trace continues through outgoing requests. */ +@RestController +@RequestMapping("/tracing/") +open class DistributedTracingController(private val restClient: RestClient) { + @GetMapping("{id}") + open fun person(@PathVariable("id") id: Long): Person? = + restClient.get().uri(selfUrl("/person/$id")).retrieve().body(Person::class.java) + + @PostMapping + open fun create(@RequestBody person: Person): Person? = + restClient.post().uri(selfUrl("/person/")).body(person).retrieve().body(Person::class.java) +} diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/DistributedTracingIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/DistributedTracingIntegrationTest.kt new file mode 100644 index 00000000000..9df1812bd32 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/DistributedTracingIntegrationTest.kt @@ -0,0 +1,137 @@ +package io.sentry.spring.boot.jakarta.integrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.SentryEnvelope +import io.sentry.protocol.SentryId +import io.sentry.test.transaction +import kotlin.test.Test +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod + +/** + * Sends requests that continue an incoming trace, and checks that both the request's transaction + * and the one for the app's own outgoing request carry the trace. + */ +class DistributedTracingIntegrationTest : IntegrationTest() { + + @Test + fun `sampled trace continues through the outgoing request`() { + val traceId = SentryId() + val status = + get( + "/tracing/1", + sentryTrace = "$traceId-424cffc8f94feeee-1", + baggage = + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ) + assertThat(status).isEqualTo(500) + + awaitTransaction("GET /tracing/{id}", traceId) + awaitTransaction("GET /person/{id}", traceId) + } + + @Test + fun `unsampled trace sends no transactions`() { + val traceId = SentryId() + val status = + get( + "/tracing/1", + sentryTrace = "$traceId-424cffc8f94feeee-0", + baggage = + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=false,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ) + assertThat(status).isEqualTo(500) + + server.assertNoEnvelope { + isTransaction(it, "GET /tracing/{id}", traceId) || + isTransaction(it, "GET /person/{id}", traceId) + } + } + + @Test + fun `sample_rand generated for baggage without one is propagated`() { + val traceId = SentryId() + val status = + get( + "/tracing/1", + sentryTrace = "$traceId-424cffc8f94feeee-1", + baggage = + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ) + assertThat(status).isEqualTo(500) + + val incoming = awaitTransaction("GET /tracing/{id}", traceId).header.traceContext!! + val outgoing = awaitTransaction("GET /person/{id}", traceId).header.traceContext!! + assertThat(incoming.sampleRand).isNotNull() + assertThat(outgoing.sampleRand).isEqualTo(incoming.sampleRand) + } + + @Test + fun `deferred sampling decision updates the propagated sample_rate`() { + val traceId = SentryId() + val status = + get( + "/tracing/1", + sentryTrace = "$traceId-424cffc8f94feeee", + baggage = + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ) + assertThat(status).isEqualTo(500) + + val incoming = awaitTransaction("GET /tracing/{id}", traceId).header.traceContext!! + val outgoing = awaitTransaction("GET /person/{id}", traceId).header.traceContext!! + assertThat(incoming.sampleRate).isNotEqualTo("0.5") + assertThat(outgoing.sampleRate).isEqualTo(incoming.sampleRate) + } + + @Test + fun `sampled trace continues through an outgoing POST`() { + val traceId = SentryId() + val headers = + traceHeaders( + sentryTrace = "$traceId-424cffc8f94feeee-1", + baggage = + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ) + val response = + restTemplate.postForEntity( + "/tracing/", + HttpEntity(Person("firstA", "lastB"), headers), + Person::class.java, + ) + assertThat(response.statusCode.value()).isEqualTo(200) + assertThat(response.body!!.firstName).isEqualTo("firstA") + assertThat(response.body!!.lastName).isEqualTo("lastB") + + awaitTransaction("POST /tracing/", traceId) + awaitTransaction("POST /person/", traceId) + } + + private fun get(path: String, sentryTrace: String, baggage: String): Int = + restTemplate + .exchange( + path, + HttpMethod.GET, + HttpEntity(traceHeaders(sentryTrace, baggage)), + String::class.java, + ) + .statusCode + .value() + + private fun traceHeaders(sentryTrace: String, baggage: String): HttpHeaders = + HttpHeaders().apply { + set("sentry-trace", sentryTrace) + set("baggage", baggage) + } + + private fun awaitTransaction(name: String, traceId: SentryId): SentryEnvelope = + server.awaitEnvelope { + isTransaction(it, name, traceId) + } + + private fun isTransaction(envelope: SentryEnvelope, name: String, traceId: SentryId): Boolean { + val transaction = envelope.transaction() ?: return false + return transaction.transaction == name && transaction.contexts.trace?.traceId == traceId + } +} 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 b2df6f0fbf0..ac8d89397ae 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 @@ -24,6 +24,7 @@ import org.springframework.test.context.junit4.SpringRunner properties = [ "sentry.traces-sample-rate=1.0", + "sentry.clients.ok-http-enabled=true", "sentry.logs.enabled=true", "sentry.profile-session-sample-rate=1.0", "sentry.profile-lifecycle=TRACE", diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTestApplication.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTestApplication.kt index d0e19302ae5..89d686dead6 100644 --- a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTestApplication.kt +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTestApplication.kt @@ -2,8 +2,11 @@ package io.sentry.spring.boot.jakarta.integrationtest import io.sentry.Sentry import io.sentry.spring.jakarta.tracing.SentrySpan +import okhttp3.OkHttpClient import org.slf4j.LoggerFactory import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.web.client.RestTemplateBuilder +import org.springframework.context.annotation.Bean import org.springframework.jdbc.core.JdbcTemplate import org.springframework.stereotype.Service import org.springframework.web.bind.annotation.GetMapping @@ -12,11 +15,33 @@ import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +import org.springframework.web.client.RestClient +import org.springframework.web.client.RestTemplate +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.servlet.support.ServletUriComponentsBuilder // Classes and methods are open because Spring proxies them and this module doesn't apply the // kotlin-spring plugin. -@SpringBootApplication open class IntegrationTestApplication +@SpringBootApplication +open class IntegrationTestApplication { + // Built from Spring Boot's builders, which Sentry customizes to trace outgoing requests. + @Bean open fun restTemplate(builder: RestTemplateBuilder): RestTemplate = builder.build() + + @Bean open fun restClient(builder: RestClient.Builder): RestClient = builder.build() + + @Bean open fun webClient(builder: WebClient.Builder): WebClient = builder.build() + + // Traced through sentry.clients.ok-http-enabled. + @Bean open fun okHttpClient(): OkHttpClient = OkHttpClient() +} + +/** + * The URL of [path] on this app, which listens on a random port. Only works on a request thread, + * because it reads the address from the current request. + */ +fun selfUrl(path: String): String = + ServletUriComponentsBuilder.fromCurrentContextPath().path(path).toUriString() open class Person(var firstName: String? = null, var lastName: String? = null) diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/TodoController.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/TodoController.kt new file mode 100644 index 00000000000..5edb7bc6aa1 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/TodoController.kt @@ -0,0 +1,63 @@ +package io.sentry.spring.boot.jakarta.integrationtest + +import com.fasterxml.jackson.databind.ObjectMapper +import io.sentry.reactor.SentryReactorUtils +import okhttp3.OkHttpClient +import okhttp3.Request +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.client.RestClient +import org.springframework.web.client.RestTemplate +import org.springframework.web.reactive.function.client.WebClient +import reactor.core.publisher.Hooks +import reactor.core.publisher.Mono +import reactor.core.scheduler.Schedulers + +open class Todo(var id: Long? = null, var title: String? = null, var completed: Boolean = false) + +/** Fetches a todo through each HTTP client that Sentry traces. */ +@RestController +open class TodoController( + private val restTemplate: RestTemplate, + private val webClient: WebClient, + private val restClient: RestClient, + private val okHttpClient: OkHttpClient, + private val objectMapper: ObjectMapper, +) { + @GetMapping("/todo/{id}") + open fun todo(@PathVariable("id") id: Long): Todo? = + restTemplate.getForObject(todoUrl(id), Todo::class.java) + + /** Requests from another thread, which the Sentry scope has to follow through Reactor. */ + @GetMapping("/todo-webclient/{id}") + open fun todoWebClient(@PathVariable("id") id: Long): Todo? { + val url = todoUrl(id) + Hooks.enableAutomaticContextPropagation() + return SentryReactorUtils.withSentry( + Mono.just(true).publishOn(Schedulers.boundedElastic()).flatMap { + webClient.get().uri(url).retrieve().bodyToMono(Todo::class.java) + } + ) + .block() + } + + @GetMapping("/todo-restclient/{id}") + open fun todoRestClient(@PathVariable("id") id: Long): Todo? = + restClient.get().uri(todoUrl(id)).retrieve().body(Todo::class.java) + + @GetMapping("/todo-okhttp/{id}") + open fun todoOkHttp(@PathVariable("id") id: Long): Todo = + okHttpClient.newCall(Request.Builder().url(todoUrl(id)).build()).execute().use { response -> + objectMapper.readValue(response.body!!.byteStream(), Todo::class.java) + } + + private fun todoUrl(id: Long): String = selfUrl("/stub/todos/$id") +} + +/** Stands in for the public API the sample fetches todos from, so tests don't need the internet. */ +@RestController +open class TodoStubController { + @GetMapping("/stub/todos/{id}") + open fun todo(@PathVariable("id") id: Long): Todo = Todo(id, "delectus aut autem") +} diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/TodoIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/TodoIntegrationTest.kt new file mode 100644 index 00000000000..bb3e2d6a99a --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/TodoIntegrationTest.kt @@ -0,0 +1,47 @@ +package io.sentry.spring.boot.jakarta.integrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.test.hasSpanWithOpAndDescription +import io.sentry.test.transaction +import kotlin.test.Test +import org.springframework.boot.test.web.server.LocalServerPort + +class TodoIntegrationTest : IntegrationTest() { + @LocalServerPort private var port = 0 + + @Test + fun `RestTemplate request is traced`() { + assertOutgoingRequestIsTraced("/todo/1", "GET /todo/{id}") + } + + @Test + fun `WebClient request is traced`() { + assertOutgoingRequestIsTraced("/todo-webclient/1", "GET /todo-webclient/{id}") + } + + @Test + fun `RestClient request is traced`() { + assertOutgoingRequestIsTraced("/todo-restclient/1", "GET /todo-restclient/{id}") + } + + @Test + fun `OkHttp request is traced`() { + assertOutgoingRequestIsTraced("/todo-okhttp/1", "GET /todo-okhttp/{id}") + } + + private fun assertOutgoingRequestIsTraced(path: String, transactionName: String) { + val response = restTemplate.getForEntity(path, Todo::class.java) + assertThat(response.statusCode.value()).isEqualTo(200) + assertThat(response.body!!.title).isEqualTo("delectus aut autem") + + val transaction = + server.awaitEnvelope { it.transaction()?.transaction == transactionName }.transaction()!! + assertThat( + transaction.hasSpanWithOpAndDescription( + "http.client", + "GET http://localhost:$port/stub/todos/1", + ) + ) + .isTrue() + } +} diff --git a/sentry-test-support/src/main/kotlin/io/sentry/test/MockSentryServer.kt b/sentry-test-support/src/main/kotlin/io/sentry/test/MockSentryServer.kt index 439b61f2c2a..52cf59b52d1 100644 --- a/sentry-test-support/src/main/kotlin/io/sentry/test/MockSentryServer.kt +++ b/sentry-test-support/src/main/kotlin/io/sentry/test/MockSentryServer.kt @@ -55,6 +55,25 @@ class MockSentryServer : Closeable { } } + /** + * Fails if an envelope matching [predicate] has arrived or arrives within [millis]. + * + * A flush alone can't prove an absence: the SDK may not have produced the envelope yet when the + * flush runs, for example when a server finishes its transaction after sending the response. + */ + fun assertNoEnvelope(millis: Long = 2_000, predicate: (SentryEnvelope) -> Boolean) { + val deadline = Deadline.after(JavaMonotonicTicker.getInstance(), millis, TimeUnit.MILLISECONDS) + while (true) { + if (received.any(predicate)) { + throw AssertionError("Received an envelope that should not have been sent") + } + if (deadline.hasPassed()) { + return + } + Thread.sleep(POLL_INTERVAL_MILLIS) + } + } + fun reset() { received.clear() } diff --git a/sentry-test-support/src/test/kotlin/io/sentry/test/MockSentryServerTest.kt b/sentry-test-support/src/test/kotlin/io/sentry/test/MockSentryServerTest.kt index 6a1e3f03225..e52c78790e3 100644 --- a/sentry-test-support/src/test/kotlin/io/sentry/test/MockSentryServerTest.kt +++ b/sentry-test-support/src/test/kotlin/io/sentry/test/MockSentryServerTest.kt @@ -31,4 +31,25 @@ class MockSentryServerTest { fun `awaitEnvelope fails once the timeout passes without a match`() { assertFailsWith { server.awaitEnvelope(timeoutMillis = 100) { true } } } + + @Test + fun `assertNoEnvelope passes when nothing matches`() { + initForTest { it.dsn = server.dsn } + + Sentry.captureMessage("hello") + Sentry.flush(5000) + + server.assertNoEnvelope(millis = 100) { it.header.eventId == null } + } + + @Test + fun `assertNoEnvelope fails when a matching envelope arrives`() { + initForTest { it.dsn = server.dsn } + + val eventId = Sentry.captureMessage("hello") + + assertFailsWith { + server.assertNoEnvelope(millis = 5000) { it.header.eventId == eventId } + } + } } From da63fd9c844d908ffee7c81c7f7893cb0c46bc24 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 20 Oct 2026 20:43:14 +0200 Subject: [PATCH 3/3] test(spring-boot): Tighten the Boot 3 tracing checks and use a plain test client The unsampled-trace test now sends a request with an id of its own and rejects any transaction for it, whatever its trace, so a mishandled header that starts a new trace fails it. The sampled test checks that transactions carry their request, which that relies on. The sample_rand and deferred-sampling tests also check the envelope header's trace ID, as the sample's tests did. That check showed that the context's TestRestTemplate, built from the RestTemplateBuilder Sentry customizes, added trace headers of its own to the requests the tests sent. The suite now uses one built outside the context. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../DistributedTracingIntegrationTest.kt | 23 ++++++++++++++----- .../integrationtest/IntegrationTest.kt | 11 +++++++-- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/DistributedTracingIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/DistributedTracingIntegrationTest.kt index 9df1812bd32..e48945453f7 100644 --- a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/DistributedTracingIntegrationTest.kt +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/DistributedTracingIntegrationTest.kt @@ -4,6 +4,7 @@ import com.google.common.truth.Truth.assertThat import io.sentry.SentryEnvelope import io.sentry.protocol.SentryId import io.sentry.test.transaction +import kotlin.random.Random import kotlin.test.Test import org.springframework.http.HttpEntity import org.springframework.http.HttpHeaders @@ -27,26 +28,26 @@ class DistributedTracingIntegrationTest : IntegrationTest() { ) assertThat(status).isEqualTo(500) - awaitTransaction("GET /tracing/{id}", traceId) + val incoming = awaitTransaction("GET /tracing/{id}", traceId).transaction()!! + assertThat(incoming.request?.url).endsWith("/tracing/1") awaitTransaction("GET /person/{id}", traceId) } @Test fun `unsampled trace sends no transactions`() { val traceId = SentryId() + // An id only this request uses, because a mishandled header could start a trace of its own. + val id = Random.nextLong(2, Long.MAX_VALUE) val status = get( - "/tracing/1", + "/tracing/$id", sentryTrace = "$traceId-424cffc8f94feeee-0", baggage = "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=false,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", ) assertThat(status).isEqualTo(500) - server.assertNoEnvelope { - isTransaction(it, "GET /tracing/{id}", traceId) || - isTransaction(it, "GET /person/{id}", traceId) - } + server.assertNoEnvelope { isForRequest(it, id) } } @Test @@ -63,6 +64,8 @@ class DistributedTracingIntegrationTest : IntegrationTest() { val incoming = awaitTransaction("GET /tracing/{id}", traceId).header.traceContext!! val outgoing = awaitTransaction("GET /person/{id}", traceId).header.traceContext!! + assertThat(incoming.traceId).isEqualTo(traceId) + assertThat(outgoing.traceId).isEqualTo(traceId) assertThat(incoming.sampleRand).isNotNull() assertThat(outgoing.sampleRand).isEqualTo(incoming.sampleRand) } @@ -81,6 +84,8 @@ class DistributedTracingIntegrationTest : IntegrationTest() { val incoming = awaitTransaction("GET /tracing/{id}", traceId).header.traceContext!! val outgoing = awaitTransaction("GET /person/{id}", traceId).header.traceContext!! + assertThat(incoming.traceId).isEqualTo(traceId) + assertThat(outgoing.traceId).isEqualTo(traceId) assertThat(incoming.sampleRate).isNotEqualTo("0.5") assertThat(outgoing.sampleRate).isEqualTo(incoming.sampleRate) } @@ -130,6 +135,12 @@ class DistributedTracingIntegrationTest : IntegrationTest() { isTransaction(it, name, traceId) } + /** Whether [envelope] is a transaction for `/tracing/[id]` or the `/person/[id]` it calls. */ + private fun isForRequest(envelope: SentryEnvelope, id: Long): Boolean { + val url = envelope.transaction()?.request?.url ?: return false + return url.endsWith("/tracing/$id") || url.endsWith("/person/$id") + } + private fun isTransaction(envelope: SentryEnvelope, name: String, traceId: SentryId): Boolean { val transaction = envelope.transaction() ?: return false return transaction.transaction == name && transaction.contexts.trace?.traceId == traceId 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 ac8d89397ae..aeffe53c788 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 @@ -3,9 +3,10 @@ package io.sentry.spring.boot.jakarta.integrationtest import io.sentry.test.MockSentryServer import org.junit.Before import org.junit.runner.RunWith -import org.springframework.beans.factory.annotation.Autowired 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.test.context.DynamicPropertyRegistry import org.springframework.test.context.DynamicPropertySource import org.springframework.test.context.junit4.SpringRunner @@ -34,7 +35,13 @@ import org.springframework.test.context.junit4.SpringRunner ], ) abstract class IntegrationTest { - @Autowired lateinit var restTemplate: TestRestTemplate + @LocalServerPort private var port = 0 + + // The context's TestRestTemplate comes from the RestTemplateBuilder that Sentry customizes, so + // it would add trace headers of its own to the requests tests send. Tests use a plain one. + val restTemplate: TestRestTemplate by lazy { + TestRestTemplate(RestTemplateBuilder().rootUri("http://localhost:$port")) + } @Before fun resetServer() {