From a55017aff7bf625e723e4f700d697d57d3957602 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 20 Oct 2026 17:14:15 +0200 Subject: [PATCH 1/2] test(spring-boot): Move the Boot 2 WebFlux sample's tests into the module Adds an integrationTestWebflux suite to sentry-spring-boot that mirrors the Boot 3 one, without the Reactor thread-local accessor that Spring Boot 2's Reactor lacks. The GraphQL greeting lives in its own file so the suite can leave it out below Spring Boot 2.7, like the servlet suite's GraphQL. The Boot 2 WebFlux sample has no system tests left, so it comes off the system-test runner, the backend workflow and the Spring Boot 2 matrix, which now runs the new suite. integrationTests includes it too. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/spring-boot-2-matrix.yml | 13 +- .github/workflows/system-tests-backend.yml | 3 - build.gradle.kts | 1 + .../build.gradle.kts | 37 ---- .../src/test/kotlin/io/sentry/DummyTest.kt | 12 -- .../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/build.gradle.kts | 34 ++- ...istributedTracingWebfluxIntegrationTest.kt | 137 ++++++++++++ .../GraphqlWebfluxIntegrationTest.kt | 59 ++++++ .../GreetingController.kt | 17 ++ .../PersonWebfluxIntegrationTest.kt | 72 +++++++ .../TodoWebfluxIntegrationTest.kt | 28 +++ .../WebfluxIntegrationTest.kt | 51 +++++ .../WebfluxIntegrationTestApplication.kt | 90 ++++++++ .../resources/graphql/schema.graphqls | 3 + test/system-test-runner.py | 1 - 21 files changed, 489 insertions(+), 500 deletions(-) delete mode 100644 sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/DummyTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-webflux/src/test/resources/logback.xml create mode 100644 sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/DistributedTracingWebfluxIntegrationTest.kt create mode 100644 sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/GraphqlWebfluxIntegrationTest.kt create mode 100644 sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/GreetingController.kt create mode 100644 sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/PersonWebfluxIntegrationTest.kt create mode 100644 sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/TodoWebfluxIntegrationTest.kt create mode 100644 sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/WebfluxIntegrationTest.kt create mode 100644 sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/WebfluxIntegrationTestApplication.kt create mode 100644 sentry-spring-boot/src/integrationTestWebflux/resources/graphql/schema.graphqls diff --git a/.github/workflows/spring-boot-2-matrix.yml b/.github/workflows/spring-boot-2-matrix.yml index ca69fc5e1d5..1893d7b918a 100644 --- a/.github/workflows/spring-boot-2-matrix.yml +++ b/.github/workflows/spring-boot-2-matrix.yml @@ -75,8 +75,6 @@ jobs: - name: Build sample artifacts run: | ./gradlew \ - :sentry-samples:sentry-samples-spring-boot-webflux:shadowJar \ - :sentry-samples:sentry-samples-spring-boot-webflux:testClasses \ :sentry-samples:sentry-samples-spring-boot-opentelemetry:shadowJar \ :sentry-samples:sentry-samples-spring-boot-opentelemetry:testClasses \ :sentry-samples:sentry-samples-spring-boot-opentelemetry-noagent:shadowJar \ @@ -85,15 +83,8 @@ jobs: :sentry-samples:sentry-samples-spring:testClasses \ :sentry-opentelemetry:sentry-opentelemetry-agent:assemble - - name: Test sentry-spring-boot integration suite - run: ./gradlew :sentry-spring-boot:integrationTest - - - name: Test sentry-samples-spring-boot-webflux - run: | - python3 test/system-test-runner.py test \ - --module "sentry-samples-spring-boot-webflux" \ - --agent false \ - --auto-init "true" + - name: Test sentry-spring-boot integration suites + run: ./gradlew :sentry-spring-boot:integrationTest :sentry-spring-boot:integrationTestWebflux - name: Test sentry-samples-spring-boot-opentelemetry agent init true run: | diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index 231a6c19876..a5f3aa8a3f6 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" - agent: "false" - agent-auto-init: "true" - sample: "sentry-samples-spring-boot-jakarta-opentelemetry-noagent" agent: "false" agent-auto-init: "true" diff --git a/build.gradle.kts b/build.gradle.kts index 88c00c2a193..be6f418bf9d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -241,6 +241,7 @@ tasks.register("integrationTests") { description = "Runs the integration test suites of every module." dependsOn( ":sentry-spring-boot:integrationTest", + ":sentry-spring-boot:integrationTestWebflux", ":sentry-spring-boot-jakarta:integrationTest", ":sentry-spring-boot-jakarta:integrationTestWebflux", ":sentry-spring-boot-jakarta:integrationTestOtelAgent", diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts index 1ef3a196e9c..c19db9c9934 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts @@ -8,7 +8,6 @@ plugins { alias(libs.plugins.shadow) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) - id("io.sentry.systemtest") } application { mainClass.set("io.sentry.samples.spring.boot.SentryDemoApplication") } @@ -46,18 +45,6 @@ dependencies { implementation(projects.sentryGraphql) } implementation(projects.sentryAsyncProfiler) - - testImplementation(kotlin(Config.kotlinStdLib)) - testImplementation(projects.sentrySystemTestSupport) - testImplementation(libs.apollo3.kotlin) - testImplementation(libs.kotlin.test.junit) - testImplementation(libs.slf4j2.api) - testImplementation(libs.springboot.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") - testImplementation("org.apache.httpcomponents:httpclient") } val runtimeClasspath = configurations.named("runtimeClasspath") @@ -97,27 +84,3 @@ tasks.withType().configureEach { compilerOptions.jvmTarget = JvmTarget.JVM_11 } } - -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*") - if (!includeGraphql) { - excludeTestsMatching("io.sentry.systemtest.Graphql*") - } - } -} - -tasks.named("test").configure { - require(this is Test) - - filter { excludeTestsMatching("io.sentry.systemtest.*") } -} diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/DummyTest.kt b/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/DummyTest.kt deleted file mode 100644 index 6f762b7e453..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-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-webflux/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt deleted file mode 100644 index 3cd16003024..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-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-webflux/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt deleted file mode 100644 index 76a6024decc..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-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-webflux/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt deleted file mode 100644 index 039d9d640c7..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-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-webflux/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt deleted file mode 100644 index 3a2455a14fe..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-webflux/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/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt deleted file mode 100644 index b06b82a62ee..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-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-webflux/src/test/resources/logback.xml b/sentry-samples/sentry-samples-spring-boot-webflux/src/test/resources/logback.xml deleted file mode 100644 index a36b8f80f76..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-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/build.gradle.kts b/sentry-spring-boot/build.gradle.kts index 55d54fe3eeb..604ffe85aa2 100644 --- a/sentry-spring-boot/build.gradle.kts +++ b/sentry-spring-boot/build.gradle.kts @@ -162,16 +162,40 @@ testing { } } } + + // The same for a reactive app: without the servlet stack, Spring Boot starts a WebFlux server. + register("integrationTestWebflux") { + useJUnit() + dependencies { + implementation(platform(libs.springboot2.bom)) + implementation(projects.sentrySpringBootStarter) + implementation(projects.sentryAsyncProfiler) + implementation(projects.sentryLogback) + implementation(projects.sentryTestSupport) + implementation(libs.kotlin.test.junit) + implementation(libs.google.truth) + implementation(libs.springboot.starter.test) + implementation(libs.springboot.starter.webflux) + if (bootSupportsGraphqlAndKafka) { + implementation(projects.sentryGraphql) + implementation(libs.springboot.starter.graphql) + } + } + } } } // 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 - if (!bootSupportsGraphqlAndKafka) { - exclude("**/Graphql*", "**/Kafka*") +listOf("compileIntegrationTestKotlin", "compileIntegrationTestWebfluxKotlin").forEach { + tasks.named(it) { + compilerOptions.javaParameters = true + if (!bootSupportsGraphqlAndKafka) { + exclude("**/Graphql*", "**/Greeting*", "**/Kafka*") + } } } -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/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/DistributedTracingWebfluxIntegrationTest.kt b/sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/DistributedTracingWebfluxIntegrationTest.kt new file mode 100644 index 00000000000..ffec178cfd5 --- /dev/null +++ b/sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/DistributedTracingWebfluxIntegrationTest.kt @@ -0,0 +1,137 @@ +package io.sentry.spring.boot.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/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/GraphqlWebfluxIntegrationTest.kt b/sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/GraphqlWebfluxIntegrationTest.kt new file mode 100644 index 00000000000..fa750f789c6 --- /dev/null +++ b/sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/GraphqlWebfluxIntegrationTest.kt @@ -0,0 +1,59 @@ +package io.sentry.spring.boot.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/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/GreetingController.kt b/sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/GreetingController.kt new file mode 100644 index 00000000000..8899ccf9e5c --- /dev/null +++ b/sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/GreetingController.kt @@ -0,0 +1,17 @@ +package io.sentry.spring.boot.webfluxintegrationtest + +import org.springframework.graphql.data.method.annotation.Argument +import org.springframework.graphql.data.method.annotation.QueryMapping +import org.springframework.stereotype.Controller + +/** 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/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/PersonWebfluxIntegrationTest.kt b/sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/PersonWebfluxIntegrationTest.kt new file mode 100644 index 00000000000..379d6a5913d --- /dev/null +++ b/sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/PersonWebfluxIntegrationTest.kt @@ -0,0 +1,72 @@ +package io.sentry.spring.boot.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/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/TodoWebfluxIntegrationTest.kt b/sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/TodoWebfluxIntegrationTest.kt new file mode 100644 index 00000000000..de7314d0e66 --- /dev/null +++ b/sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/TodoWebfluxIntegrationTest.kt @@ -0,0 +1,28 @@ +package io.sentry.spring.boot.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/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/WebfluxIntegrationTest.kt b/sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/WebfluxIntegrationTest.kt new file mode 100644 index 00000000000..c6e1c4223fa --- /dev/null +++ b/sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/WebfluxIntegrationTest.kt @@ -0,0 +1,51 @@ +package io.sentry.spring.boot.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.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/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/WebfluxIntegrationTestApplication.kt b/sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/WebfluxIntegrationTestApplication.kt new file mode 100644 index 00000000000..88b720564ac --- /dev/null +++ b/sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/WebfluxIntegrationTestApplication.kt @@ -0,0 +1,90 @@ +package io.sentry.spring.boot.webfluxintegrationtest + +import io.sentry.Sentry +import java.time.Duration +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.context.annotation.Bean +import org.springframework.http.server.reactive.ServerHttpRequest +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") +} diff --git a/sentry-spring-boot/src/integrationTestWebflux/resources/graphql/schema.graphqls b/sentry-spring-boot/src/integrationTestWebflux/resources/graphql/schema.graphqls new file mode 100644 index 00000000000..89a7d1a735f --- /dev/null +++ b/sentry-spring-boot/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 3c739691f24..f64c28b5c24 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", "false", "true", "false"), 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"), From 384e41a48b947d178e6853e31550712a16618684 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 20 Oct 2026 20:55:57 +0200 Subject: [PATCH 2/2] test(spring-boot): Tighten the Boot 2 WebFlux tracing checks and restore Metrics Ports the tracing checks and the plain test client from the servlet suite, and runs the WebFlux sample's Metrics tests in the reactive app too, rather than only in the servlet suite. Co-Authored-By: Claude Opus 5.5 (1M context) --- ...istributedTracingWebfluxIntegrationTest.kt | 23 ++++++--- .../MetricController.kt | 33 ++++++++++++ .../MetricsWebfluxIntegrationTest.kt | 50 +++++++++++++++++++ .../WebfluxIntegrationTest.kt | 11 +++- 4 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/MetricController.kt create mode 100644 sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/MetricsWebfluxIntegrationTest.kt diff --git a/sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/DistributedTracingWebfluxIntegrationTest.kt b/sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/DistributedTracingWebfluxIntegrationTest.kt index ffec178cfd5..2c4c94da100 100644 --- a/sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/DistributedTracingWebfluxIntegrationTest.kt +++ b/sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/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/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/MetricController.kt b/sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/MetricController.kt new file mode 100644 index 00000000000..3ff68eb1d26 --- /dev/null +++ b/sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/MetricController.kt @@ -0,0 +1,33 @@ +package io.sentry.spring.boot.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/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/MetricsWebfluxIntegrationTest.kt b/sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/MetricsWebfluxIntegrationTest.kt new file mode 100644 index 00000000000..1b3eb4775df --- /dev/null +++ b/sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/MetricsWebfluxIntegrationTest.kt @@ -0,0 +1,50 @@ +package io.sentry.spring.boot.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/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/WebfluxIntegrationTest.kt b/sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/WebfluxIntegrationTest.kt index c6e1c4223fa..f222e37b4ef 100644 --- a/sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/WebfluxIntegrationTest.kt +++ b/sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/WebfluxIntegrationTest.kt @@ -3,9 +3,10 @@ package io.sentry.spring.boot.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.beans.factory.annotation.Value import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.web.client.TestRestTemplate +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 @@ -32,7 +33,13 @@ import org.springframework.test.context.junit4.SpringRunner ], ) abstract class WebfluxIntegrationTest { - @Autowired lateinit var restTemplate: TestRestTemplate + @Value("\${local.server.port}") 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() {