From 16c75d26baad4e9d7a4e2e3e923e866ea5e27d9a Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 20 Oct 2026 17:08:08 +0200 Subject: [PATCH 1/2] test(spring-boot): Move the Boot 3 WebFlux sample's tests into the module Adds an integrationTestWebflux suite to sentry-spring-boot-jakarta. It runs a reactive app without the servlet stack, so Spring Boot starts a WebFlux server, and ports the WebFlux sample's Person, DistributedTracing, Todo and GraphQL tests. The sample's Cache and Metrics tests were copies of the servlet ones and don't depend on the web stack, so the integrationTest suite already covers them. The WebFlux sample has no system tests left, so it comes off the system-test runner, the backend workflow and the Spring Boot 3 matrix, which now runs the new suite. integrationTests includes it too. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/spring-boot-3-matrix.yml | 10 +- .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 | 89 -------- .../io/sentry/systemtest/TodoSystemTest.kt | 31 --- .../src/test/resources/logback.xml | 17 -- sentry-spring-boot-jakarta/build.gradle.kts | 27 ++- ...istributedTracingWebfluxIntegrationTest.kt | 137 ++++++++++++ .../GraphqlWebfluxIntegrationTest.kt | 59 ++++++ .../PersonWebfluxIntegrationTest.kt | 72 +++++++ .../TodoWebfluxIntegrationTest.kt | 28 +++ .../WebfluxIntegrationTest.kt | 52 +++++ .../WebfluxIntegrationTestApplication.kt | 105 ++++++++++ .../resources/graphql/schema.graphqls | 3 + test/system-test-runner.py | 1 - 21 files changed, 484 insertions(+), 539 deletions(-) delete mode 100644 sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/DummyTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/resources/logback.xml create mode 100644 sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/DistributedTracingWebfluxIntegrationTest.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/GraphqlWebfluxIntegrationTest.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/PersonWebfluxIntegrationTest.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/TodoWebfluxIntegrationTest.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/WebfluxIntegrationTest.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/WebfluxIntegrationTestApplication.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTestWebflux/resources/graphql/schema.graphqls diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index c0cbeb13c29..07b8e553244 100644 --- a/.github/workflows/spring-boot-3-matrix.yml +++ b/.github/workflows/spring-boot-3-matrix.yml @@ -71,8 +71,6 @@ jobs: - name: Build sample artifacts run: | ./gradlew \ - :sentry-samples:sentry-samples-spring-boot-webflux-jakarta:bootJar \ - :sentry-samples:sentry-samples-spring-boot-webflux-jakarta:testClasses \ :sentry-samples:sentry-samples-spring-boot-jakarta-opentelemetry:bootJar \ :sentry-samples:sentry-samples-spring-boot-jakarta-opentelemetry:testClasses \ :sentry-samples:sentry-samples-spring-boot-jakarta-opentelemetry-noagent:bootJar \ @@ -85,16 +83,10 @@ jobs: run: | ./gradlew \ :sentry-spring-boot-jakarta:integrationTest \ + :sentry-spring-boot-jakarta:integrationTestWebflux \ :sentry-spring-boot-jakarta:integrationTestLog4j2 \ :sentry-spring-boot-jakarta:integrationTestOtelAgent - - name: Test sentry-samples-spring-boot-webflux-jakarta - run: | - python3 test/system-test-runner.py test \ - --module "sentry-samples-spring-boot-webflux-jakarta" \ - --agent false \ - --auto-init "true" - - name: Test sentry-samples-spring-boot-jakarta-opentelemetry agent init true run: | python3 test/system-test-runner.py test \ diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index fd4199ab599..8ba719546e5 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -34,9 +34,6 @@ jobs: - sample: "sentry-samples-spring-boot-opentelemetry" agent: "true" agent-auto-init: "false" - - sample: "sentry-samples-spring-boot-webflux-jakarta" - agent: "false" - agent-auto-init: "true" - sample: "sentry-samples-spring-boot-webflux" agent: "false" agent-auto-init: "true" diff --git a/build.gradle.kts b/build.gradle.kts index 567fbecdf29..ce50cdf55e4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -225,6 +225,7 @@ tasks.register("integrationTests") { dependsOn( ":sentry-spring-boot:integrationTest", ":sentry-spring-boot-jakarta:integrationTest", + ":sentry-spring-boot-jakarta:integrationTestWebflux", ":sentry-spring-boot-jakarta:integrationTestLog4j2", ":sentry-spring-boot-jakarta:integrationTestOtelAgent", ":sentry-spring-boot-4:integrationTest", diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts index 342aaa0d70c..e9d4b2f6905 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts @@ -6,7 +6,6 @@ plugins { alias(libs.plugins.spring.dependency.management) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) - id("io.sentry.systemtest") } group = "io.sentry.sample.spring-boot-webflux-jakarta" @@ -38,17 +37,6 @@ dependencies { // cache tracing implementation(libs.springboot3.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.springboot3.starter.test) { - exclude(group = "org.junit.vintage", module = "junit-vintage-engine") - } - testImplementation("ch.qos.logback:logback-classic:1.5.16") - testImplementation("ch.qos.logback:logback-core:1.5.16") } tasks.withType().configureEach { @@ -56,22 +44,3 @@ tasks.withType().configureEach { compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") } } - -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-webflux-jakarta/src/test/kotlin/io/sentry/DummyTest.kt b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/DummyTest.kt deleted file mode 100644 index 6f762b7e453..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/DummyTest.kt +++ /dev/null @@ -1,12 +0,0 @@ -package io.sentry - -import kotlin.test.Test -import kotlin.test.assertTrue - -class DummyTest { - @Test - fun `the only test`() { - // only needed to have more than 0 tests and not fail the build - assertTrue(true) - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt deleted file mode 100644 index b45e9c10853..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/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-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt deleted file mode 100644 index 3cd16003024..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-webflux-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-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt deleted file mode 100644 index 76a6024decc..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/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-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt deleted file mode 100644 index 039d9d640c7..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/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-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt deleted file mode 100644 index 3a2455a14fe..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ /dev/null @@ -1,89 +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-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt deleted file mode 100644 index b06b82a62ee..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/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-webflux-jakarta/src/test/resources/logback.xml b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/resources/logback.xml deleted file mode 100644 index a36b8f80f76..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/resources/logback.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - %-4relative [%thread] %-5level %logger{35} -%kvp- %msg %n - - - - - - - diff --git a/sentry-spring-boot-jakarta/build.gradle.kts b/sentry-spring-boot-jakarta/build.gradle.kts index ce731a76dc3..5458b2b9bad 100644 --- a/sentry-spring-boot-jakarta/build.gradle.kts +++ b/sentry-spring-boot-jakarta/build.gradle.kts @@ -237,6 +237,29 @@ testing { } } + // 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.sentrySpringBootStarterJakarta) + 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.caffeine) + implementation(libs.springboot3.starter.cache) + implementation(libs.springboot3.starter.graphql) + implementation(libs.springboot3.starter.test) + implementation(libs.springboot3.starter.webflux) + } + } + register("integrationTestOtelAgent") { useJUnit() dependencies { @@ -260,4 +283,6 @@ testing { // 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 } +} diff --git a/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/DistributedTracingWebfluxIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/DistributedTracingWebfluxIntegrationTest.kt new file mode 100644 index 00000000000..3044a70d6c9 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/DistributedTracingWebfluxIntegrationTest.kt @@ -0,0 +1,137 @@ +package io.sentry.spring.boot.jakarta.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-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/GraphqlWebfluxIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/GraphqlWebfluxIntegrationTest.kt new file mode 100644 index 00000000000..e84282fb02c --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/GraphqlWebfluxIntegrationTest.kt @@ -0,0 +1,59 @@ +package io.sentry.spring.boot.jakarta.webfluxintegrationtest + +import com.fasterxml.jackson.databind.JsonNode +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 + +class GraphqlWebfluxIntegrationTest : WebfluxIntegrationTest() { + + @Test + fun `query is traced`() { + val response = graphql("""query { greeting(name: "world") }""") + + assertThat(response.has("errors")).isFalse() + assertThat(response["data"]["greeting"].asText()).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-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/PersonWebfluxIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/PersonWebfluxIntegrationTest.kt new file mode 100644 index 00000000000..c72ed553235 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/PersonWebfluxIntegrationTest.kt @@ -0,0 +1,72 @@ +package io.sentry.spring.boot.jakarta.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-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/TodoWebfluxIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/TodoWebfluxIntegrationTest.kt new file mode 100644 index 00000000000..57d51eb41bd --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/TodoWebfluxIntegrationTest.kt @@ -0,0 +1,28 @@ +package io.sentry.spring.boot.jakarta.webfluxintegrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.test.hasSpanWithOpAndDescription +import io.sentry.test.transaction +import kotlin.test.Test + +class TodoWebfluxIntegrationTest : WebfluxIntegrationTest() { + + @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 ${restTemplate.rootUri}/stub/todos/1", + ) + ) + .isTrue() + } +} diff --git a/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/WebfluxIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/WebfluxIntegrationTest.kt new file mode 100644 index 00000000000..ca37d216c04 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/WebfluxIntegrationTest.kt @@ -0,0 +1,52 @@ +package io.sentry.spring.boot.jakarta.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.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 [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) +@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-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/WebfluxIntegrationTestApplication.kt b/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/WebfluxIntegrationTestApplication.kt new file mode 100644 index 00000000000..97bd2011565 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/WebfluxIntegrationTestApplication.kt @@ -0,0 +1,105 @@ +package io.sentry.spring.boot.jakarta.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 + +// Classes and methods are open because Spring proxies them and this module doesn't apply the +// kotlin-spring plugin. + +@SpringBootApplication +open class WebfluxIntegrationTestApplication { + // Built from Spring Boot's builder, which Sentry customizes to trace outgoing requests. + @Bean open 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() + +open class Person(var firstName: String? = null, var lastName: String? = null) + +open class Todo(var id: Long? = null, var title: String? = null, var completed: Boolean = false) + +@RestController +@RequestMapping("/person/") +open class PersonController { + /** Reports to Sentry through every API an app uses, then fails the request. */ + @GetMapping("{id}") + open 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 + open 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/") +open class DistributedTracingController(private val webClient: WebClient) { + @GetMapping("{id}") + open fun person(@PathVariable("id") id: Long, request: ServerHttpRequest): Mono = + webClient.get().uri(selfUrl(request, "/person/$id")).retrieve().bodyToMono(Person::class.java) + + @PostMapping + open fun create(@RequestBody person: Person, request: ServerHttpRequest): Mono = + webClient + .post() + .uri(selfUrl(request, "/person/")) + .bodyValue(person) + .retrieve() + .bodyToMono(Person::class.java) +} + +@RestController +open class TodoController(private val webClient: WebClient) { + @GetMapping("/todo-webclient/{id}") + open 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}") + open fun stub(@PathVariable("id") id: Long): Todo = Todo(id, "delectus aut autem") +} + +/** Throws when given "crash", so tests can check error reporting. */ +@Controller +open class GreetingController { + @QueryMapping + open 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-jakarta/src/integrationTestWebflux/resources/graphql/schema.graphqls b/sentry-spring-boot-jakarta/src/integrationTestWebflux/resources/graphql/schema.graphqls new file mode 100644 index 00000000000..89a7d1a735f --- /dev/null +++ b/sentry-spring-boot-jakarta/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 47be1171d76..f96eef31712 100644 --- a/test/system-test-runner.py +++ b/test/system-test-runner.py @@ -846,7 +846,6 @@ def get_available_modules(self) -> List[ModuleConfig]: ModuleConfig("sentry-samples-spring-boot-opentelemetry-noagent", "false", "true", "false"), ModuleConfig("sentry-samples-spring-boot-opentelemetry", "true", "true", "false"), ModuleConfig("sentry-samples-spring-boot-opentelemetry", "true", "false", "false"), - ModuleConfig("sentry-samples-spring-boot-webflux-jakarta", "false", "true", "false"), ModuleConfig("sentry-samples-spring-boot-webflux", "false", "true", "false"), ModuleConfig("sentry-samples-spring-boot-jakarta-opentelemetry-noagent", "false", "true", "false"), ModuleConfig("sentry-samples-spring-boot-jakarta-opentelemetry", "true", "true", "false"), From 33f8060f048a1da35e46bae14af0db581f36aad0 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 21 Oct 2026 11:14:23 +0200 Subject: [PATCH 2/2] test(spring-boot): Tighten the Boot 3 WebFlux tracing checks and restore Cache and Metrics Co-Authored-By: Claude Opus 5.5 (1M context) --- .../webfluxintegrationtest/CacheController.kt | 43 ++++++++++++++++ .../CacheWebfluxIntegrationTest.kt | 35 +++++++++++++ ...istributedTracingWebfluxIntegrationTest.kt | 23 ++++++--- .../MetricController.kt | 33 ++++++++++++ .../MetricsWebfluxIntegrationTest.kt | 50 +++++++++++++++++++ .../WebfluxIntegrationTest.kt | 12 ++++- .../WebfluxIntegrationTestApplication.kt | 2 + 7 files changed, 190 insertions(+), 8 deletions(-) create mode 100644 sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/CacheController.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/CacheWebfluxIntegrationTest.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/MetricController.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/MetricsWebfluxIntegrationTest.kt diff --git a/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/CacheController.kt b/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/CacheController.kt new file mode 100644 index 00000000000..822da891740 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/CacheController.kt @@ -0,0 +1,43 @@ +package io.sentry.spring.boot.jakarta.webfluxintegrationtest + +import java.util.concurrent.ConcurrentHashMap +import org.springframework.cache.annotation.CacheEvict +import org.springframework.cache.annotation.CachePut +import org.springframework.cache.annotation.Cacheable +import org.springframework.stereotype.Service +import org.springframework.web.bind.annotation.DeleteMapping +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 + +@RestController +@RequestMapping("/cache/") +open class CacheController(private val todoCache: TodoCache) { + @GetMapping("{id}") open fun get(@PathVariable("id") id: Long): Todo? = todoCache.get(id) + + @PostMapping open fun save(@RequestBody todo: Todo): Todo = todoCache.save(todo) + + @DeleteMapping("{id}") open fun delete(@PathVariable("id") id: Long) = todoCache.delete(id) +} + +/** Reads and writes todos through Spring's cache abstraction, which Sentry traces. */ +@Service +open class TodoCache { + private val store = ConcurrentHashMap() + + @Cacheable(value = ["todos"], key = "#id") open fun get(id: Long): Todo? = store[id] + + @CachePut(value = ["todos"], key = "#todo.id") + open fun save(todo: Todo): Todo { + store[todo.id!!] = todo + return todo + } + + @CacheEvict(value = ["todos"], key = "#id") + open fun delete(id: Long) { + store.remove(id) + } +} diff --git a/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/CacheWebfluxIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/CacheWebfluxIntegrationTest.kt new file mode 100644 index 00000000000..cd6eb909212 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/CacheWebfluxIntegrationTest.kt @@ -0,0 +1,35 @@ +package io.sentry.spring.boot.jakarta.webfluxintegrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.test.hasSpanWithOp +import io.sentry.test.transaction +import kotlin.test.Test +import org.springframework.http.HttpEntity +import org.springframework.http.HttpMethod + +class CacheWebfluxIntegrationTest : WebfluxIntegrationTest() { + + @Test + fun `cache put and get produce spans`() { + val saved = restTemplate.postForEntity("/cache/", Todo(1L, "test-todo"), Todo::class.java) + assertThat(saved.statusCode.value()).isEqualTo(200) + assertThat(awaitTransaction("POST /cache/").hasSpanWithOp("cache.put")).isTrue() + + val fetched = restTemplate.getForEntity("/cache/1", Todo::class.java) + assertThat(fetched.statusCode.value()).isEqualTo(200) + assertThat(fetched.body!!.title).isEqualTo("test-todo") + assertThat(awaitTransaction("GET /cache/{id}").hasSpanWithOp("cache.get")).isTrue() + } + + @Test + fun `cache evict produces span`() { + val response = + restTemplate.exchange("/cache/1", HttpMethod.DELETE, HttpEntity.EMPTY, Void::class.java) + assertThat(response.statusCode.value()).isEqualTo(200) + + assertThat(awaitTransaction("DELETE /cache/{id}").hasSpanWithOp("cache.evict")).isTrue() + } + + private fun awaitTransaction(name: String) = + server.awaitEnvelope { it.transaction()?.transaction == name }.transaction()!! +} diff --git a/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/DistributedTracingWebfluxIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/DistributedTracingWebfluxIntegrationTest.kt index 3044a70d6c9..9487dd381cd 100644 --- a/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/DistributedTracingWebfluxIntegrationTest.kt +++ b/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/DistributedTracingWebfluxIntegrationTest.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 DistributedTracingWebfluxIntegrationTest : WebfluxIntegrationTest() { ) 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 DistributedTracingWebfluxIntegrationTest : WebfluxIntegrationTest() { 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 DistributedTracingWebfluxIntegrationTest : WebfluxIntegrationTest() { 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 DistributedTracingWebfluxIntegrationTest : WebfluxIntegrationTest() { 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/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/MetricController.kt b/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/MetricController.kt new file mode 100644 index 00000000000..0c39762abef --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/MetricController.kt @@ -0,0 +1,33 @@ +package io.sentry.spring.boot.jakarta.webfluxintegrationtest + +import io.sentry.Sentry +import io.sentry.metrics.MetricsUnit +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/metric/") +open class MetricController { + @GetMapping("count") + open fun count(): String { + Sentry.setAttribute("user.type", "admin") + Sentry.setAttribute("feature.version", 2) + Sentry.metrics().count("countMetric") + return "count metric increased" + } + + @GetMapping("gauge/{value}") + open fun gauge(@PathVariable("value") value: Long): String { + Sentry.metrics().gauge("memory.free", value.toDouble(), MetricsUnit.Information.BYTE) + return "gauge metric tracked" + } + + @GetMapping("distribution/{value}") + open fun distribution(@PathVariable("value") value: Long): String { + Sentry.metrics() + .distribution("distributionMetric", value.toDouble(), MetricsUnit.Duration.MILLISECOND) + return "distribution metric tracked" + } +} diff --git a/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/MetricsWebfluxIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/MetricsWebfluxIntegrationTest.kt new file mode 100644 index 00000000000..5d5cfb3f732 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/MetricsWebfluxIntegrationTest.kt @@ -0,0 +1,50 @@ +package io.sentry.spring.boot.jakarta.webfluxintegrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.Sentry +import io.sentry.SentryMetricsEvents +import io.sentry.test.hasMetric +import io.sentry.test.hasMetricWithAttribute +import io.sentry.test.metrics +import kotlin.test.Test + +class MetricsWebfluxIntegrationTest : WebfluxIntegrationTest() { + + @Test + fun `count metric carries the scope's attributes`() { + assertThat(restTemplate.getForObject("/metric/count", String::class.java)) + .isEqualTo("count metric increased") + + val metrics = awaitMetric("countMetric") + assertThat(metrics.hasMetric("countMetric", "counter", 1.0)).isTrue() + assertThat(metrics.hasMetricWithAttribute("countMetric", "user.type", "admin")).isTrue() + assertThat(metrics.hasMetricWithAttribute("countMetric", "feature.version", 2)).isTrue() + } + + @Test + fun `gauge metric`() { + assertThat(restTemplate.getForObject("/metric/gauge/14", String::class.java)) + .isEqualTo("gauge metric tracked") + + assertThat(awaitMetric("memory.free").hasMetric("memory.free", "gauge", 14.0)).isTrue() + } + + @Test + fun `distribution metric`() { + assertThat(restTemplate.getForObject("/metric/distribution/23", String::class.java)) + .isEqualTo("distribution metric tracked") + + assertThat( + awaitMetric("distributionMetric").hasMetric("distributionMetric", "distribution", 23.0) + ) + .isTrue() + } + + /** Flushes first because metrics are batched before they're sent. */ + private fun awaitMetric(name: String): SentryMetricsEvents { + Sentry.flush(5000) + return server + .awaitEnvelope { it.metrics()?.items?.any { metric -> metric.name == name } == true } + .metrics()!! + } +} diff --git a/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/WebfluxIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/WebfluxIntegrationTest.kt index ca37d216c04..4d314719e35 100644 --- a/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/WebfluxIntegrationTest.kt +++ b/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/WebfluxIntegrationTest.kt @@ -3,9 +3,10 @@ package io.sentry.spring.boot.jakarta.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.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 @@ -25,6 +26,7 @@ import org.springframework.test.context.junit4.SpringRunner properties = [ "sentry.traces-sample-rate=1.0", + "sentry.enable-cache-tracing=true", "sentry.logs.enabled=true", "sentry.reactive.thread-local-accessor-enabled=true", "sentry.profile-session-sample-rate=1.0", @@ -33,7 +35,13 @@ import org.springframework.test.context.junit4.SpringRunner ], ) abstract class WebfluxIntegrationTest { - @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() { diff --git a/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/WebfluxIntegrationTestApplication.kt b/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/WebfluxIntegrationTestApplication.kt index 97bd2011565..ee88c0e9210 100644 --- a/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/WebfluxIntegrationTestApplication.kt +++ b/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/WebfluxIntegrationTestApplication.kt @@ -3,6 +3,7 @@ package io.sentry.spring.boot.jakarta.webfluxintegrationtest import io.sentry.Sentry import java.time.Duration import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.cache.annotation.EnableCaching import org.springframework.context.annotation.Bean import org.springframework.graphql.data.method.annotation.Argument import org.springframework.graphql.data.method.annotation.QueryMapping @@ -23,6 +24,7 @@ import reactor.core.scheduler.Schedulers // kotlin-spring plugin. @SpringBootApplication +@EnableCaching open class WebfluxIntegrationTestApplication { // Built from Spring Boot's builder, which Sentry customizes to trace outgoing requests. @Bean open fun webClient(builder: WebClient.Builder): WebClient = builder.build()