From 7a251ce21a54fa571d031d81c907f111434f4750 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 20 Oct 2026 17:11:33 +0200 Subject: [PATCH] test(spring-boot): Move the Boot 4 WebFlux sample's tests into the module Adds an integrationTestWebflux suite to sentry-spring-boot-4 that mirrors the Boot 3 one, adjusted for Spring Boot 4: the webclient starter for WebClient.Builder, the restclient starter that TestRestTemplate needs, Jackson 3, and @AutoConfigureTestRestTemplate. The Todo test reads the port with @LocalServerPort because TestRestTemplate's root URI doesn't hold the port on Boot 4.0. The Boot 4 WebFlux sample has no system tests left, so it comes off the system-test runner, the backend workflow and the Spring Boot 4 matrix, which now runs the new suite. integrationTests includes it too. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/spring-boot-4-matrix.yml | 13 +- .github/workflows/system-tests-backend.yml | 3 - build.gradle.kts | 1 + .../build.gradle.kts | 31 --- .../src/test/kotlin/io/sentry/DummyTest.kt | 12 -- .../io/sentry/systemtest/CacheSystemTest.kt | 51 ----- .../DistributedTracingSystemTest.kt | 197 ------------------ .../systemtest/GraphqlGreetingSystemTest.kt | 46 ---- .../io/sentry/systemtest/MetricsSystemTest.kt | 51 ----- .../io/sentry/systemtest/PersonSystemTest.kt | 90 -------- .../io/sentry/systemtest/TodoSystemTest.kt | 31 --- .../src/test/resources/logback.xml | 17 -- sentry-spring-boot-4/build.gradle.kts | 33 ++- ...istributedTracingWebfluxIntegrationTest.kt | 137 ++++++++++++ .../GraphqlWebfluxIntegrationTest.kt | 59 ++++++ .../PersonWebfluxIntegrationTest.kt | 72 +++++++ .../TodoWebfluxIntegrationTest.kt | 30 +++ .../WebfluxIntegrationTest.kt | 55 +++++ .../WebfluxIntegrationTestApplication.kt | 102 +++++++++ .../resources/graphql/schema.graphqls | 3 + test/system-test-runner.py | 1 - 21 files changed, 492 insertions(+), 543 deletions(-) delete mode 100644 sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/DummyTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/resources/logback.xml create mode 100644 sentry-spring-boot-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/DistributedTracingWebfluxIntegrationTest.kt create mode 100644 sentry-spring-boot-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/GraphqlWebfluxIntegrationTest.kt create mode 100644 sentry-spring-boot-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/PersonWebfluxIntegrationTest.kt create mode 100644 sentry-spring-boot-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/TodoWebfluxIntegrationTest.kt create mode 100644 sentry-spring-boot-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/WebfluxIntegrationTest.kt create mode 100644 sentry-spring-boot-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/WebfluxIntegrationTestApplication.kt create mode 100644 sentry-spring-boot-4/src/integrationTestWebflux/resources/graphql/schema.graphqls diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index 440aaf634c0..bf1c86d3b6d 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-webflux:bootJar \ - :sentry-samples:sentry-samples-spring-boot-4-webflux:testClasses \ :sentry-samples:sentry-samples-spring-boot-4-opentelemetry:bootJar \ :sentry-samples:sentry-samples-spring-boot-4-opentelemetry:testClasses \ :sentry-samples:sentry-samples-spring-boot-4-opentelemetry-noagent:bootJar \ @@ -81,15 +79,8 @@ jobs: :sentry-samples:sentry-samples-spring-7:testClasses \ :sentry-opentelemetry:sentry-opentelemetry-agent:assemble - - name: Test sentry-spring-boot-4 integration suite - run: ./gradlew :sentry-spring-boot-4:integrationTest - - - name: Run sentry-samples-spring-boot-4-webflux - run: | - python3 test/system-test-runner.py test \ - --module "sentry-samples-spring-boot-4-webflux" \ - --agent false \ - --auto-init "true" + - name: Test sentry-spring-boot-4 integration suites + run: ./gradlew :sentry-spring-boot-4:integrationTest :sentry-spring-boot-4:integrationTestWebflux - name: Run sentry-samples-spring-boot-4-opentelemetry agent init true run: | diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index c26ab73d31d..231a6c19876 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -61,9 +61,6 @@ jobs: - sample: "sentry-samples-jul" agent: "false" agent-auto-init: "true" - - sample: "sentry-samples-spring-boot-4-webflux" - agent: "false" - agent-auto-init: "true" - sample: "sentry-samples-spring-boot-4-opentelemetry-noagent" agent: "false" agent-auto-init: "true" diff --git a/build.gradle.kts b/build.gradle.kts index c8ee68df866..88c00c2a193 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -245,6 +245,7 @@ tasks.register("integrationTests") { ":sentry-spring-boot-jakarta:integrationTestWebflux", ":sentry-spring-boot-jakarta:integrationTestOtelAgent", ":sentry-spring-boot-4:integrationTest", + ":sentry-spring-boot-4:integrationTestWebflux", ) } diff --git a/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts index e5b44c1793a..c83b3a0db50 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-webflux/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-webflux" @@ -34,17 +33,6 @@ dependencies { // cache tracing implementation(libs.springboot4.starter.cache) implementation(libs.caffeine) - - testImplementation(kotlin(Config.kotlinStdLib)) - 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.withType().configureEach { @@ -58,22 +46,3 @@ tasks.withType().configureEach { compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } } - -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-webflux/src/test/kotlin/io/sentry/DummyTest.kt b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/DummyTest.kt deleted file mode 100644 index 6f762b7e453..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4-webflux/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-webflux/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt deleted file mode 100644 index b45e9c10853..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt +++ /dev/null @@ -1,51 +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 CacheSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `cache put and get produce spans`() { - val restClient = testHelper.restClient - - // Save a todo (triggers @CachePut -> cache.put span) - val todo = Todo(1L, "test-todo", false) - restClient.saveCachedTodo(todo) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, _ -> - testHelper.doesTransactionContainSpanWithOp(transaction, "cache.put") - } - - testHelper.reset() - - // Get the todo (triggers @Cacheable -> cache.get span, should be a hit) - restClient.getCachedTodo(1L) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, _ -> - testHelper.doesTransactionContainSpanWithOp(transaction, "cache.get") - } - } - - @Test - fun `cache evict produces span`() { - val restClient = testHelper.restClient - - restClient.deleteCachedTodo(1L) - - testHelper.ensureTransactionReceived { transaction, _ -> - testHelper.doesTransactionContainSpanWithOp(transaction, "cache.evict") - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt deleted file mode 100644 index 3cd16003024..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4-webflux/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-4-webflux/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt deleted file mode 100644 index 76a6024decc..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt +++ /dev/null @@ -1,46 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import org.junit.Before - -class GraphqlGreetingSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `greeting works`() { - val response = testHelper.graphqlClient.greet("world") - - testHelper.ensureNoErrors(response) - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "graphql", - "Query.greeting", - ) - } - } - - @Test - fun `greeting error`() { - val response = testHelper.graphqlClient.greet("crash") - - testHelper.ensureErrorCount(response, 1) - testHelper.ensureErrorReceived { error -> - error.message?.message?.startsWith("Unresolved RuntimeException for executionId ") ?: false - } - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "graphql", - "Query.greeting", - ) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt deleted file mode 100644 index 039d9d640c7..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt +++ /dev/null @@ -1,51 +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 MetricsSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `count metric`() { - val restClient = testHelper.restClient - assertEquals("count metric increased", restClient.getCountMetric()) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureMetricsReceived { event, header -> - testHelper.doesContainMetric(event, "countMetric", "counter", 1.0) && - testHelper.doesMetricHaveAttribute(event, "countMetric", "user.type", "admin") && - testHelper.doesMetricHaveAttribute(event, "countMetric", "feature.version", 2) - } - } - - @Test - fun `gauge metric`() { - val restClient = testHelper.restClient - assertEquals("gauge metric tracked", restClient.getGaugeMetric(14)) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureMetricsReceived { event, header -> - testHelper.doesContainMetric(event, "memory.free", "gauge", 14.0) - } - } - - @Test - fun `distribution metric`() { - val restClient = testHelper.restClient - assertEquals("distribution metric tracked", restClient.getDistributionMetric(23)) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureMetricsReceived { event, header -> - testHelper.doesContainMetric(event, "distributionMetric", "distribution", 23.0) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt deleted file mode 100644 index 3b7b2751ee9..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ /dev/null @@ -1,90 +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 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 -> - testHelper.doesEventHaveExceptionMessage(event, "Something went wrong [id=1]") && - testHelper.doesEventHaveFlag(event, "my-feature-flag", true) - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionHaveOp(transaction, "http.server") - } - - 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.doesTransactionHaveOp(transaction, "http.server") - } - } - - @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 - testHelper.doesTransactionHaveOp(transaction, "http.server") - } - - testHelper.ensureProfileChunkReceived { profileChunk, envelopeHeader -> - profileChunk.profilerId == profilerId - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt deleted file mode 100644 index b06b82a62ee..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt +++ /dev/null @@ -1,31 +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 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", - ) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/resources/logback.xml b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/resources/logback.xml deleted file mode 100644 index a36b8f80f76..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4-webflux/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 20b75539ccd..857ff9a7afa 100644 --- a/sentry-spring-boot-4/build.gradle.kts +++ b/sentry-spring-boot-4/build.gradle.kts @@ -197,11 +197,40 @@ testing { runtimeOnly(libs.hsqldb) } } + + // The same for a reactive app: without the servlet stack, Spring Boot starts a WebFlux server. + register("integrationTestWebflux") { + useJUnit() + dependencies { + implementation(platform(SpringBootPlugin.BOM_COORDINATES)) + implementation(projects.sentrySpringBoot4Starter) + implementation(projects.sentryAsyncProfiler) + implementation(projects.sentryGraphql22) + implementation(projects.sentryLogback) + implementation(projects.sentryTestSupport) + // Reactor's automatic context propagation needs it to carry the Sentry scope across + // threads. + implementation(libs.context.propagation) + implementation(libs.kotlin.test.junit) + implementation(libs.google.truth) + implementation(libs.springboot4.resttestclient) + // For TestRestTemplate; it doesn't add the servlet stack. + implementation(libs.springboot4.starter.restclient) + implementation(libs.springboot4.starter.graphql) + implementation(libs.springboot4.starter.test) + implementation(libs.springboot4.starter.webclient) + implementation(libs.springboot4.starter.webflux) + } + } } } // The Spring Boot Gradle plugin compiles apps with parameter names, which Spring reads, for example // to resolve `#id` in a cache key. -tasks.named("compileIntegrationTestKotlin") { compilerOptions.javaParameters = true } +listOf("compileIntegrationTestKotlin", "compileIntegrationTestWebfluxKotlin").forEach { + tasks.named(it) { compilerOptions.javaParameters = true } +} -tasks.named("check") { dependsOn(testing.suites.named("integrationTest")) } +tasks.named("check") { + dependsOn(testing.suites.named("integrationTest"), testing.suites.named("integrationTestWebflux")) +} diff --git a/sentry-spring-boot-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/DistributedTracingWebfluxIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/DistributedTracingWebfluxIntegrationTest.kt new file mode 100644 index 00000000000..669edcba5ee --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/DistributedTracingWebfluxIntegrationTest.kt @@ -0,0 +1,137 @@ +package io.sentry.spring.boot4.webfluxintegrationtest + +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 DistributedTracingWebfluxIntegrationTest : WebfluxIntegrationTest() { + + @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-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/GraphqlWebfluxIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/GraphqlWebfluxIntegrationTest.kt new file mode 100644 index 00000000000..d6794b585c3 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/GraphqlWebfluxIntegrationTest.kt @@ -0,0 +1,59 @@ +package io.sentry.spring.boot4.webfluxintegrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.protocol.SentryTransaction +import io.sentry.test.event +import io.sentry.test.hasSpanWithOpAndDescription +import io.sentry.test.transaction +import kotlin.test.Test +import tools.jackson.databind.JsonNode + +class GraphqlWebfluxIntegrationTest : WebfluxIntegrationTest() { + + @Test + fun `query is traced`() { + val response = graphql("""query { greeting(name: "world") }""") + + assertThat(response.has("errors")).isFalse() + assertThat(response["data"]["greeting"].asString()).isEqualTo("Hello world!") + server.awaitEnvelope { + it.transaction()?.let { t -> + t.transaction == "POST /graphql" && + t.spans.any { span -> span.op == "graphql" && span.description == "Query.greeting" } + } == true + } + } + + @Test + fun `resolver error is reported with its transaction`() { + val response = graphql("""query { greeting(name: "crash") }""") + + assertThat(response["errors"].size()).isEqualTo(1) + val transaction = awaitTransactionOfError("causing an error for crash") + assertThat(transaction.hasSpanWithOpAndDescription("graphql", "Query.greeting")).isTrue() + } + + private fun graphql(query: String): JsonNode { + val response = + restTemplate.postForEntity("/graphql", mapOf("query" to query), JsonNode::class.java) + assertThat(response.statusCode.value()).isEqualTo(200) + return response.body!! + } + + /** Finds the error for [exceptionMessage], then the transaction it belongs to by trace ID. */ + private fun awaitTransactionOfError(exceptionMessage: String): SentryTransaction { + val error = + server + .awaitEnvelope { envelope -> + envelope.event()?.let { event -> + event.message?.message?.startsWith("Unresolved RuntimeException for executionId ") == + true && event.exceptions?.any { it.value == exceptionMessage } == true + } == true + } + .event()!! + val traceId = error.contexts.trace!!.traceId + return server + .awaitEnvelope { it.transaction()?.contexts?.trace?.traceId == traceId } + .transaction()!! + } +} diff --git a/sentry-spring-boot-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/PersonWebfluxIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/PersonWebfluxIntegrationTest.kt new file mode 100644 index 00000000000..8151eb4a8a1 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/PersonWebfluxIntegrationTest.kt @@ -0,0 +1,72 @@ +package io.sentry.spring.boot4.webfluxintegrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.Sentry +import io.sentry.test.event +import io.sentry.test.hasFlag +import io.sentry.test.hasLogWithAttribute +import io.sentry.test.hasLogWithBody +import io.sentry.test.logs +import io.sentry.test.profileChunk +import io.sentry.test.transaction +import kotlin.test.Test + +class PersonWebfluxIntegrationTest : WebfluxIntegrationTest() { + + @Test + fun `failed request reports the error, the transaction and logs`() { + val response = restTemplate.getForEntity("/person/1", String::class.java) + assertThat(response.statusCode.value()).isEqualTo(500) + Sentry.flush(5000) + + 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.contexts.trace!!.operation).isEqualTo("http.server") + + 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 on another thread is traced`() { + 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.contexts.trace!!.operation).isEqualTo("http.server") + } + + @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-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/TodoWebfluxIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/TodoWebfluxIntegrationTest.kt new file mode 100644 index 00000000000..eb79a9c1c98 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/TodoWebfluxIntegrationTest.kt @@ -0,0 +1,30 @@ +package io.sentry.spring.boot4.webfluxintegrationtest + +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 TodoWebfluxIntegrationTest : WebfluxIntegrationTest() { + @LocalServerPort private var port = 0 + + @Test + fun `WebClient request is traced`() { + val response = restTemplate.getForEntity("/todo-webclient/1", Todo::class.java) + assertThat(response.statusCode.value()).isEqualTo(200) + assertThat(response.body!!.title).isEqualTo("delectus aut autem") + + val transaction = + server + .awaitEnvelope { it.transaction()?.transaction == "GET /todo-webclient/{id}" } + .transaction()!! + assertThat( + transaction.hasSpanWithOpAndDescription( + "http.client", + "GET http://localhost:$port/stub/todos/1", + ) + ) + .isTrue() + } +} diff --git a/sentry-spring-boot-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/WebfluxIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/WebfluxIntegrationTest.kt new file mode 100644 index 00000000000..6a1e542266e --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/WebfluxIntegrationTest.kt @@ -0,0 +1,55 @@ +package io.sentry.spring.boot4.webfluxintegrationtest + +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.resttestclient.TestRestTemplate +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import org.springframework.test.context.junit4.SpringRunner + +/** + * Drives [WebfluxIntegrationTestApplication], a reactive app, 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) +// Spring Boot 4 only provides TestRestTemplate when asked to. +@AutoConfigureTestRestTemplate +@SpringBootTest( + classes = [WebfluxIntegrationTestApplication::class], + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = + [ + "sentry.traces-sample-rate=1.0", + "sentry.logs.enabled=true", + "sentry.reactive.thread-local-accessor-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", + ], +) +abstract class WebfluxIntegrationTest { + @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-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/WebfluxIntegrationTestApplication.kt b/sentry-spring-boot-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/WebfluxIntegrationTestApplication.kt new file mode 100644 index 00000000000..d01d8ce08c1 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/WebfluxIntegrationTestApplication.kt @@ -0,0 +1,102 @@ +package io.sentry.spring.boot4.webfluxintegrationtest + +import io.sentry.Sentry +import java.time.Duration +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.context.annotation.Bean +import org.springframework.graphql.data.method.annotation.Argument +import org.springframework.graphql.data.method.annotation.QueryMapping +import org.springframework.http.server.reactive.ServerHttpRequest +import org.springframework.stereotype.Controller +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.reactive.function.client.WebClient +import org.springframework.web.util.UriComponentsBuilder +import reactor.core.publisher.Mono +import reactor.core.scheduler.Schedulers + +@SpringBootApplication +class WebfluxIntegrationTestApplication { + // Built from Spring Boot's builder, which Sentry customizes to trace outgoing requests. + @Bean fun webClient(builder: WebClient.Builder): WebClient = builder.build() +} + +/** The URL of [path] on this app, which listens on a random port, based on [request]. */ +fun selfUrl(request: ServerHttpRequest, path: String): String = + UriComponentsBuilder.fromUri(request.uri).replacePath(path).replaceQuery(null).toUriString() + +class Person(var firstName: String? = null, var lastName: String? = null) + +class Todo(var id: Long? = null, var title: String? = null, var completed: Boolean = false) + +@RestController +@RequestMapping("/person/") +class PersonController { + /** Reports to Sentry through every API an app uses, then fails the request. */ + @GetMapping("{id}") + fun person(@PathVariable("id") id: Long): Person { + 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) + throw IllegalArgumentException("Something went wrong [id=$id]") + } + + /** Finishes on another thread, which the Sentry scope has to follow through Reactor. */ + @PostMapping + fun create(@RequestBody person: Person): Mono = + Mono.delay(Duration.ofMillis(100)) + .publishOn(Schedulers.boundedElastic()) + .doOnNext { Sentry.captureMessage("Creating person") } + .map { person } +} + +/** Forwards to this app's own Person endpoints, so a trace continues through outgoing requests. */ +@RestController +@RequestMapping("/tracing/") +class DistributedTracingController(private val webClient: WebClient) { + @GetMapping("{id}") + fun person(@PathVariable("id") id: Long, request: ServerHttpRequest): Mono = + webClient.get().uri(selfUrl(request, "/person/$id")).retrieve().bodyToMono(Person::class.java) + + @PostMapping + fun create(@RequestBody person: Person, request: ServerHttpRequest): Mono = + webClient + .post() + .uri(selfUrl(request, "/person/")) + .bodyValue(person) + .retrieve() + .bodyToMono(Person::class.java) +} + +@RestController +class TodoController(private val webClient: WebClient) { + @GetMapping("/todo-webclient/{id}") + fun todo(@PathVariable("id") id: Long, request: ServerHttpRequest): Mono = + webClient.get().uri(selfUrl(request, "/stub/todos/$id")).retrieve().bodyToMono(Todo::class.java) + + /** + * Stands in for the public API the sample fetches todos from, so tests don't need the internet. + */ + @GetMapping("/stub/todos/{id}") + fun stub(@PathVariable("id") id: Long): Todo = Todo(id, "delectus aut autem") +} + +/** Throws when given "crash", so tests can check error reporting. */ +@Controller +class GreetingController { + @QueryMapping + fun greeting(@Argument name: String): String { + if (name == "crash") { + throw RuntimeException("causing an error for $name") + } + return "Hello $name!" + } +} diff --git a/sentry-spring-boot-4/src/integrationTestWebflux/resources/graphql/schema.graphqls b/sentry-spring-boot-4/src/integrationTestWebflux/resources/graphql/schema.graphqls new file mode 100644 index 00000000000..89a7d1a735f --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestWebflux/resources/graphql/schema.graphqls @@ -0,0 +1,3 @@ +type Query { + greeting(name: String!): String! +} diff --git a/test/system-test-runner.py b/test/system-test-runner.py index 3c35578d431..3c739691f24 100644 --- a/test/system-test-runner.py +++ b/test/system-test-runner.py @@ -850,7 +850,6 @@ def get_available_modules(self) -> List[ModuleConfig]: ModuleConfig("sentry-samples-spring-boot-jakarta-opentelemetry-noagent", "false", "true", "false"), 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-opentelemetry-noagent", "false", "true", "false"), ModuleConfig("sentry-samples-spring-boot-4-opentelemetry", "true", "true", "false"), ModuleConfig("sentry-samples-spring-boot-4-opentelemetry", "true", "false", "false"),