From f8e3073029e974cdea2502825b86ce9af63651b5 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 20 Oct 2026 16:12:05 +0200 Subject: [PATCH 1/2] test(spring-boot): Move the Boot 4 sample's tests into the module Adds an integrationTest suite to sentry-spring-boot-4 that mirrors the jakarta one and ports the Boot 4 sample's Person, DistributedTracing, Todo, Cache, Metrics and GraphQL tests to it. The sample's tests were copies of the jakarta ones, so the suite is too, adjusted for Spring Boot 4: Jackson 3, the split restclient and resttestclient modules, @AutoConfigureTestRestTemplate, and sentry-spring-7's SentrySpan. The Spring Boot 4 matrix runs the suite against every Boot version it covers. The sample keeps its Kafka test until the next PR. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/spring-boot-4-matrix.yml | 3 + .../io/sentry/systemtest/CacheSystemTest.kt | 51 ----- .../DistributedTracingSystemTest.kt | 197 ------------------ .../systemtest/GraphqlGreetingSystemTest.kt | 46 ---- .../systemtest/GraphqlProjectSystemTest.kt | 66 ------ .../systemtest/GraphqlTaskSystemTest.kt | 50 ----- .../io/sentry/systemtest/MetricsSystemTest.kt | 51 ----- .../io/sentry/systemtest/PersonSystemTest.kt | 109 ---------- .../io/sentry/systemtest/TodoSystemTest.kt | 77 ------- sentry-spring-boot-4/build.gradle.kts | 44 ++++ .../boot4/integrationtest/CacheController.kt | 43 ++++ .../integrationtest/CacheIntegrationTest.kt | 35 ++++ .../DistributedTracingController.kt | 22 ++ .../DistributedTracingIntegrationTest.kt | 137 ++++++++++++ .../integrationtest/GraphqlControllers.kt | 83 ++++++++ .../integrationtest/GraphqlIntegrationTest.kt | 111 ++++++++++ .../boot4/integrationtest/IntegrationTest.kt | 57 +++++ .../IntegrationTestApplication.kt | 94 +++++++++ .../boot4/integrationtest/MetricController.kt | 33 +++ .../integrationtest/MetricsIntegrationTest.kt | 50 +++++ .../integrationtest/PersonIntegrationTest.kt | 100 +++++++++ .../boot4/integrationtest/TodoController.kt | 63 ++++++ .../integrationtest/TodoIntegrationTest.kt | 47 +++++ .../resources/graphql/schema.graphqls | 38 ++++ .../src/integrationTest/resources/schema.sql | 5 + 25 files changed, 965 insertions(+), 647 deletions(-) delete mode 100644 sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt create mode 100644 sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/CacheController.kt create mode 100644 sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/CacheIntegrationTest.kt create mode 100644 sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/DistributedTracingController.kt create mode 100644 sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/DistributedTracingIntegrationTest.kt create mode 100644 sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/GraphqlControllers.kt create mode 100644 sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/GraphqlIntegrationTest.kt create mode 100644 sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/IntegrationTest.kt create mode 100644 sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/IntegrationTestApplication.kt create mode 100644 sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/MetricController.kt create mode 100644 sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/MetricsIntegrationTest.kt create mode 100644 sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/PersonIntegrationTest.kt create mode 100644 sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/TodoController.kt create mode 100644 sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/TodoIntegrationTest.kt create mode 100644 sentry-spring-boot-4/src/integrationTest/resources/graphql/schema.graphqls create mode 100644 sentry-spring-boot-4/src/integrationTest/resources/schema.sql diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index a960e00082e..be80d48b1fd 100644 --- a/.github/workflows/spring-boot-4-matrix.yml +++ b/.github/workflows/spring-boot-4-matrix.yml @@ -83,6 +83,9 @@ jobs: :sentry-samples:sentry-samples-spring-7:testClasses \ :sentry-opentelemetry:sentry-opentelemetry-agent:assemble + - name: Test sentry-spring-boot-4 integration suite + run: ./gradlew :sentry-spring-boot-4:integrationTest + - name: Run sentry-samples-spring-boot-4 run: | python3 test/system-test-runner.py test \ diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt deleted file mode 100644 index b45e9c10853..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt +++ /dev/null @@ -1,51 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import org.junit.Before - -class CacheSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `cache put and get produce spans`() { - val restClient = testHelper.restClient - - // Save a todo (triggers @CachePut -> cache.put span) - val todo = Todo(1L, "test-todo", false) - restClient.saveCachedTodo(todo) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, _ -> - testHelper.doesTransactionContainSpanWithOp(transaction, "cache.put") - } - - testHelper.reset() - - // Get the todo (triggers @Cacheable -> cache.get span, should be a hit) - restClient.getCachedTodo(1L) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, _ -> - testHelper.doesTransactionContainSpanWithOp(transaction, "cache.get") - } - } - - @Test - fun `cache evict produces span`() { - val restClient = testHelper.restClient - - restClient.deleteCachedTodo(1L) - - testHelper.ensureTransactionReceived { transaction, _ -> - testHelper.doesTransactionContainSpanWithOp(transaction, "cache.evict") - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt deleted file mode 100644 index 3cd16003024..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt +++ /dev/null @@ -1,197 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.protocol.SentryId -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotEquals -import org.junit.Before - -class DistributedTracingSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `get person distributed tracing`() { - val traceId = SentryId() - val restClient = testHelper.restClient - restClient.getPersonDistributedTracing( - 1L, - mapOf( - "sentry-trace" to "$traceId-424cffc8f94feeee-1", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(500, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /tracing/{id}" && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /person/{id}" && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - } - } - - @Test - fun `get person distributed tracing with sampled false`() { - val traceId = SentryId() - val restClient = testHelper.restClient - restClient.getPersonDistributedTracing( - 1L, - mapOf( - "sentry-trace" to "$traceId-424cffc8f94feeee-0", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=false,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(500, restClient.lastKnownStatusCode) - - testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /tracing/{id}" - } - - testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /person/{id}" - } - } - - @Test - fun `get person distributed tracing without sample_rand`() { - val traceId = SentryId() - val restClient = testHelper.restClient - restClient.getPersonDistributedTracing( - 1L, - mapOf( - "sentry-trace" to "$traceId-424cffc8f94feeee-1", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(500, restClient.lastKnownStatusCode) - - var sampleRand1: String? = null - var sampleRand2: String? = null - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - val matches = - transaction.transaction == "GET /tracing/{id}" && - envelopeHeader.traceContext!!.traceId == traceId && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - - if (matches) { - testHelper.logObject(envelopeHeader) - testHelper.logObject(transaction) - sampleRand1 = envelopeHeader.traceContext?.sampleRand - } - - matches - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - val matches = - transaction.transaction == "GET /person/{id}" && - envelopeHeader.traceContext!!.traceId == traceId && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - - if (matches) { - testHelper.logObject(envelopeHeader) - testHelper.logObject(transaction) - sampleRand2 = envelopeHeader.traceContext?.sampleRand - } - - matches - } - - assertEquals(sampleRand1, sampleRand2) - } - - @Test - fun `get person distributed tracing updates sample_rate on deferred decision`() { - val traceId = SentryId() - val restClient = testHelper.restClient - restClient.getPersonDistributedTracing( - 1L, - mapOf( - "sentry-trace" to "$traceId-424cffc8f94feeee", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(500, restClient.lastKnownStatusCode) - - var sampleRate1: String? = null - var sampleRate2: String? = null - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - val matches = - transaction.transaction == "GET /tracing/{id}" && - envelopeHeader.traceContext!!.traceId == traceId && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - - if (matches) { - testHelper.logObject(envelopeHeader) - testHelper.logObject(transaction) - sampleRate1 = envelopeHeader.traceContext?.sampleRate - } - - matches - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - val matches = - transaction.transaction == "GET /person/{id}" && - envelopeHeader.traceContext!!.traceId == traceId && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - - if (matches) { - testHelper.logObject(envelopeHeader) - testHelper.logObject(transaction) - sampleRate2 = envelopeHeader.traceContext?.sampleRate - } - - matches - } - - assertEquals(sampleRate1, sampleRate2) - assertNotEquals(sampleRate1, "0.5") - } - - @Test - fun `create person distributed tracing`() { - val traceId = SentryId() - val restClient = testHelper.restClient - val person = Person("firstA", "lastB") - val returnedPerson = - restClient.createPersonDistributedTracing( - person, - mapOf( - "sentry-trace" to "$traceId-424cffc8f94feeee-1", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(200, restClient.lastKnownStatusCode) - - assertEquals(person.firstName, returnedPerson!!.firstName) - assertEquals(person.lastName, returnedPerson!!.lastName) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "POST /tracing/" && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "POST /person/" && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt deleted file mode 100644 index 76a6024decc..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt +++ /dev/null @@ -1,46 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import org.junit.Before - -class GraphqlGreetingSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `greeting works`() { - val response = testHelper.graphqlClient.greet("world") - - testHelper.ensureNoErrors(response) - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "graphql", - "Query.greeting", - ) - } - } - - @Test - fun `greeting error`() { - val response = testHelper.graphqlClient.greet("crash") - - testHelper.ensureErrorCount(response, 1) - testHelper.ensureErrorReceived { error -> - error.message?.message?.startsWith("Unresolved RuntimeException for executionId ") ?: false - } - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "graphql", - "Query.greeting", - ) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt deleted file mode 100644 index fca3956717c..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt +++ /dev/null @@ -1,66 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import org.junit.Before - -class GraphqlProjectSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `project query works`() { - val response = testHelper.graphqlClient.project("proj-slug") - - testHelper.ensureNoErrors(response) - assertEquals("proj-slug", response?.data?.project?.slug) - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "graphql", - "Query.project", - ) - } - } - - @Test - fun `project mutation works`() { - val response = testHelper.graphqlClient.addProject("proj-slug") - - testHelper.ensureNoErrors(response) - assertNotNull(response?.data?.addProject) - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "graphql", - "Mutation.addProject", - ) - } - } - - @Test - fun `project mutation error`() { - val response = testHelper.graphqlClient.addProject("addprojectcrash") - - testHelper.ensureErrorCount(response, 1) - assertNull(response?.data?.addProject) - testHelper.ensureErrorReceived { error -> - error.message?.message?.startsWith("Unresolved RuntimeException for executionId ") ?: false - } - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "graphql", - "Mutation.addProject", - ) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt deleted file mode 100644 index 940709c0778..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt +++ /dev/null @@ -1,50 +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 GraphqlTaskSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `tasks and assignees query works`() { - val response = testHelper.graphqlClient.tasksAndAssignees("project-slug") - - testHelper.ensureNoErrors(response) - - assertEquals(5, response?.data?.tasks?.size) - - val firstTask = response?.data?.tasks?.firstOrNull() ?: throw RuntimeException("no task") - assertEquals("T1", firstTask.id) - assertEquals("A3", firstTask.assigneeId) - assertEquals("A3", firstTask.assignee?.id) - assertEquals("C3", firstTask.creatorId) - assertEquals("C3", firstTask.creator?.id) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "graphql", - "Query.tasks", - ) && - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "graphql", - "Task.assignee", - ) && - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "graphql", - "Task.creator", - ) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt deleted file mode 100644 index 039d9d640c7..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt +++ /dev/null @@ -1,51 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import org.junit.Before - -class MetricsSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `count metric`() { - val restClient = testHelper.restClient - assertEquals("count metric increased", restClient.getCountMetric()) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureMetricsReceived { event, header -> - testHelper.doesContainMetric(event, "countMetric", "counter", 1.0) && - testHelper.doesMetricHaveAttribute(event, "countMetric", "user.type", "admin") && - testHelper.doesMetricHaveAttribute(event, "countMetric", "feature.version", 2) - } - } - - @Test - fun `gauge metric`() { - val restClient = testHelper.restClient - assertEquals("gauge metric tracked", restClient.getGaugeMetric(14)) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureMetricsReceived { event, header -> - testHelper.doesContainMetric(event, "memory.free", "gauge", 14.0) - } - } - - @Test - fun `distribution metric`() { - val restClient = testHelper.restClient - assertEquals("distribution metric tracked", restClient.getDistributionMetric(23)) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureMetricsReceived { event, header -> - testHelper.doesContainMetric(event, "distributionMetric", "distribution", 23.0) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt deleted file mode 100644 index 2389734a8b3..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ /dev/null @@ -1,109 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.protocol.FeatureFlag -import io.sentry.protocol.SentryId -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import org.junit.Before - -class PersonSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `get person fails`() { - val restClient = testHelper.restClient - restClient.getPerson(1L) - assertEquals(500, restClient.lastKnownStatusCode) - - testHelper.ensureErrorReceived { event -> - event.message?.formatted == "Trying person with id=1" && - testHelper.doesEventHaveFlag(event, "my-feature-flag", true) - } - - testHelper.ensureErrorReceived { event -> - testHelper.doesEventHaveExceptionMessage(event, "Something went wrong [id=1]") && - testHelper.doesEventHaveFlag(event, "my-feature-flag", true) - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionHave( - transaction, - op = "http.server", - featureFlag = FeatureFlag("flag.evaluation.transaction-feature-flag", true), - ) && - testHelper.doesTransactionHaveSpanWith( - transaction, - op = "spanCreatedThroughSentryApi", - featureFlag = FeatureFlag("flag.evaluation.my-feature-flag", true), - ) - } - - Thread.sleep(10000) - - testHelper.ensureLogsReceived { logs, envelopeHeader -> - testHelper.doesContainLogWithBody(logs, "warn Sentry logging") && - testHelper.doesContainLogWithBody(logs, "error Sentry logging") && - testHelper.doesContainLogWithBody(logs, "hello there world!") && - testHelper.doesLogWithBodyHaveAttribute( - logs, - "warn Sentry logging", - "user.type", - "admin", - ) && - testHelper.doesLogWithBodyHaveAttribute( - logs, - "warn Sentry logging", - "feature.version", - 2, - ) && - testHelper.doesLogWithBodyHaveAttribute(logs, "warn Sentry logging", "debug.enabled", true) - } - } - - @Test - fun `create person works`() { - val restClient = testHelper.restClient - val person = Person("firstA", "lastB") - val returnedPerson = restClient.createPerson(person) - assertEquals(200, restClient.lastKnownStatusCode) - - assertEquals(person.firstName, returnedPerson!!.firstName) - assertEquals(person.lastName, returnedPerson!!.lastName) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOp(transaction, "PersonService.create") && - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "db.query", - "insert into person (firstName, lastName) values (?, ?)", - ) - } - } - - @Test - fun `create person starts a profile linked to the transaction`() { - var profilerId: SentryId? = null - val restClient = testHelper.restClient - val person = Person("firstA", "lastB") - val returnedPerson = restClient.createPerson(person) - assertEquals(200, restClient.lastKnownStatusCode) - - assertEquals(person.firstName, returnedPerson!!.firstName) - assertEquals(person.lastName, returnedPerson!!.lastName) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - profilerId = transaction.contexts.profile?.profilerId - transaction.transaction == "POST /person/" - } - testHelper.ensureProfileChunkReceived { profileChunk, envelopeHeader -> - profileChunk.profilerId == profilerId - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt deleted file mode 100644 index 66600e9753d..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt +++ /dev/null @@ -1,77 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import org.junit.Before - -class TodoSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `get todo works`() { - val restClient = testHelper.restClient - restClient.getTodo(1L) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "http.client", - "GET https://jsonplaceholder.typicode.com/todos/1", - ) - } - } - - @Test - fun `get todo webclient works`() { - val restClient = testHelper.restClient - restClient.getTodoWebclient(1L) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "http.client", - "GET https://jsonplaceholder.typicode.com/todos/1", - ) - } - } - - @Test - fun `get todo restclient works`() { - val restClient = testHelper.restClient - restClient.getTodoRestClient(1L) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "http.client", - "GET https://jsonplaceholder.typicode.com/todos/1", - ) - } - } - - @Test - fun `get todo okhttp works`() { - val restClient = testHelper.restClient - restClient.getTodoOkHttp(1L) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /todo-okhttp/{id}" && - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "http.client", - "GET https://jsonplaceholder.typicode.com/todos/1", - ) - } - } -} diff --git a/sentry-spring-boot-4/build.gradle.kts b/sentry-spring-boot-4/build.gradle.kts index ce3a42989e8..3140080ce6d 100644 --- a/sentry-spring-boot-4/build.gradle.kts +++ b/sentry-spring-boot-4/build.gradle.kts @@ -156,3 +156,47 @@ kotlin { freeCompilerArgs.add("-Xskip-metadata-version-check") } } + +testing { + suites { + // Runs a Spring Boot app in the test's JVM against MockSentryServer. It is a suite of its own + // because the unit tests' classpath carries the agent, which switches Sentry into agent mode. + register("integrationTest") { + useJUnit() + dependencies { + implementation(platform(SpringBootPlugin.BOM_COORDINATES)) + implementation(projects.sentrySpringBoot4Starter) + implementation(projects.sentryAsyncProfiler) + implementation(projects.sentryGraphql22) + implementation(projects.sentryJdbc) + implementation(projects.sentryLogback) + implementation(projects.sentryOkhttp) + implementation(projects.sentryTestSupport) + // Reactor's automatic context propagation needs it to carry the Sentry scope across + // threads. + implementation(libs.context.propagation) + implementation(libs.kotlin.test.junit) + implementation(libs.google.truth) + implementation(libs.caffeine) + implementation(libs.okhttp) + implementation(libs.springboot4.resttestclient) + implementation(libs.springboot4.starter.aspectj) + implementation(libs.springboot4.starter.cache) + implementation(libs.springboot4.starter.graphql) + implementation(libs.springboot4.starter.jdbc) + implementation(libs.springboot4.starter.restclient) + implementation(libs.springboot4.starter.test) + implementation(libs.springboot4.starter.web) + implementation(libs.springboot4.starter.webclient) + implementation(libs.springboot4.starter.webflux) + runtimeOnly(libs.hsqldb) + } + } + } +} + +// 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 } + +tasks.named("check") { dependsOn(testing.suites.named("integrationTest")) } diff --git a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/CacheController.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/CacheController.kt new file mode 100644 index 00000000000..70b48bf02e1 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/CacheController.kt @@ -0,0 +1,43 @@ +package io.sentry.spring.boot4.integrationtest + +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/") +class CacheController(private val todoCache: TodoCache) { + @GetMapping("{id}") fun get(@PathVariable("id") id: Long): Todo? = todoCache.get(id) + + @PostMapping fun save(@RequestBody todo: Todo): Todo = todoCache.save(todo) + + @DeleteMapping("{id}") fun delete(@PathVariable("id") id: Long) = todoCache.delete(id) +} + +/** Reads and writes todos through Spring's cache abstraction, which Sentry traces. */ +@Service +class TodoCache { + private val store = ConcurrentHashMap() + + @Cacheable(value = ["todos"], key = "#id") fun get(id: Long): Todo? = store[id] + + @CachePut(value = ["todos"], key = "#todo.id") + fun save(todo: Todo): Todo { + store[todo.id!!] = todo + return todo + } + + @CacheEvict(value = ["todos"], key = "#id") + fun delete(id: Long) { + store.remove(id) + } +} diff --git a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/CacheIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/CacheIntegrationTest.kt new file mode 100644 index 00000000000..9dd98145f26 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/CacheIntegrationTest.kt @@ -0,0 +1,35 @@ +package io.sentry.spring.boot4.integrationtest + +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 CacheIntegrationTest : IntegrationTest() { + + @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-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/DistributedTracingController.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/DistributedTracingController.kt new file mode 100644 index 00000000000..5a9b8cc3aea --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/DistributedTracingController.kt @@ -0,0 +1,22 @@ +package io.sentry.spring.boot4.integrationtest + +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.client.RestClient + +/** Forwards to this app's own Person endpoints, so a trace continues through outgoing requests. */ +@RestController +@RequestMapping("/tracing/") +class DistributedTracingController(private val restClient: RestClient) { + @GetMapping("{id}") + fun person(@PathVariable("id") id: Long): Person? = + restClient.get().uri(selfUrl("/person/$id")).retrieve().body(Person::class.java) + + @PostMapping + fun create(@RequestBody person: Person): Person? = + restClient.post().uri(selfUrl("/person/")).body(person).retrieve().body(Person::class.java) +} diff --git a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/DistributedTracingIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/DistributedTracingIntegrationTest.kt new file mode 100644 index 00000000000..81a97a4b14e --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/DistributedTracingIntegrationTest.kt @@ -0,0 +1,137 @@ +package io.sentry.spring.boot4.integrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.SentryEnvelope +import io.sentry.protocol.SentryId +import io.sentry.test.transaction +import kotlin.test.Test +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod + +/** + * Sends requests that continue an incoming trace, and checks that both the request's transaction + * and the one for the app's own outgoing request carry the trace. + */ +class DistributedTracingIntegrationTest : IntegrationTest() { + + @Test + fun `sampled trace continues through the outgoing request`() { + val traceId = SentryId() + val status = + get( + "/tracing/1", + sentryTrace = "$traceId-424cffc8f94feeee-1", + baggage = + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ) + assertThat(status).isEqualTo(500) + + awaitTransaction("GET /tracing/{id}", traceId) + awaitTransaction("GET /person/{id}", traceId) + } + + @Test + fun `unsampled trace sends no transactions`() { + val traceId = SentryId() + val status = + get( + "/tracing/1", + sentryTrace = "$traceId-424cffc8f94feeee-0", + baggage = + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=false,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ) + assertThat(status).isEqualTo(500) + + server.assertNoEnvelope { + isTransaction(it, "GET /tracing/{id}", traceId) || + isTransaction(it, "GET /person/{id}", traceId) + } + } + + @Test + fun `sample_rand generated for baggage without one is propagated`() { + val traceId = SentryId() + val status = + get( + "/tracing/1", + sentryTrace = "$traceId-424cffc8f94feeee-1", + baggage = + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ) + assertThat(status).isEqualTo(500) + + val incoming = awaitTransaction("GET /tracing/{id}", traceId).header.traceContext!! + val outgoing = awaitTransaction("GET /person/{id}", traceId).header.traceContext!! + assertThat(incoming.sampleRand).isNotNull() + assertThat(outgoing.sampleRand).isEqualTo(incoming.sampleRand) + } + + @Test + fun `deferred sampling decision updates the propagated sample_rate`() { + val traceId = SentryId() + val status = + get( + "/tracing/1", + sentryTrace = "$traceId-424cffc8f94feeee", + baggage = + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ) + assertThat(status).isEqualTo(500) + + val incoming = awaitTransaction("GET /tracing/{id}", traceId).header.traceContext!! + val outgoing = awaitTransaction("GET /person/{id}", traceId).header.traceContext!! + assertThat(incoming.sampleRate).isNotEqualTo("0.5") + assertThat(outgoing.sampleRate).isEqualTo(incoming.sampleRate) + } + + @Test + fun `sampled trace continues through an outgoing POST`() { + val traceId = SentryId() + val headers = + traceHeaders( + sentryTrace = "$traceId-424cffc8f94feeee-1", + baggage = + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ) + val response = + restTemplate.postForEntity( + "/tracing/", + HttpEntity(Person("firstA", "lastB"), headers), + Person::class.java, + ) + assertThat(response.statusCode.value()).isEqualTo(200) + assertThat(response.body!!.firstName).isEqualTo("firstA") + assertThat(response.body!!.lastName).isEqualTo("lastB") + + awaitTransaction("POST /tracing/", traceId) + awaitTransaction("POST /person/", traceId) + } + + private fun get(path: String, sentryTrace: String, baggage: String): Int = + restTemplate + .exchange( + path, + HttpMethod.GET, + HttpEntity(traceHeaders(sentryTrace, baggage)), + String::class.java, + ) + .statusCode + .value() + + private fun traceHeaders(sentryTrace: String, baggage: String): HttpHeaders = + HttpHeaders().apply { + set("sentry-trace", sentryTrace) + set("baggage", baggage) + } + + private fun awaitTransaction(name: String, traceId: SentryId): SentryEnvelope = + server.awaitEnvelope { + isTransaction(it, name, traceId) + } + + private fun isTransaction(envelope: SentryEnvelope, name: String, traceId: SentryId): Boolean { + val transaction = envelope.transaction() ?: return false + return transaction.transaction == name && transaction.contexts.trace?.traceId == traceId + } +} diff --git a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/GraphqlControllers.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/GraphqlControllers.kt new file mode 100644 index 00000000000..d91a8cae3b2 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/GraphqlControllers.kt @@ -0,0 +1,83 @@ +package io.sentry.spring.boot4.integrationtest + +import java.util.UUID +import java.util.concurrent.CompletableFuture +import org.dataloader.DataLoader +import org.springframework.graphql.data.method.annotation.Argument +import org.springframework.graphql.data.method.annotation.BatchMapping +import org.springframework.graphql.data.method.annotation.MutationMapping +import org.springframework.graphql.data.method.annotation.QueryMapping +import org.springframework.graphql.data.method.annotation.SchemaMapping +import org.springframework.graphql.execution.BatchLoaderRegistry +import org.springframework.stereotype.Controller +import reactor.core.publisher.Mono + +class Project(val slug: String) + +enum class ProjectStatus { + ACTIVE, + COMMUNITY, +} + +class Task(val id: String, val name: String, val assigneeId: String?, val creatorId: String?) + +class Assignee(val id: String, val name: String) + +class Creator(val id: String, val name: String) + +/** Resolvers that throw when given a "crash" argument, so tests can check error reporting. */ +@Controller +class ProjectController { + @QueryMapping + fun greeting(@Argument name: String): String { + if (name == "crash") { + throw RuntimeException("causing an error for $name") + } + return "Hello $name!" + } + + @QueryMapping fun project(@Argument slug: String): Project = Project(slug) + + @SchemaMapping(typeName = "Project", field = "status") + fun status(project: Project): ProjectStatus = ProjectStatus.COMMUNITY + + @MutationMapping + fun addProject(@Argument slug: String): String { + if (slug == "addprojectcrash") { + throw RuntimeException("causing a project add error for $slug") + } + return UUID.randomUUID().toString() + } + + @QueryMapping + fun tasks(@Argument projectSlug: String): List = + listOf( + Task("T1", "Create a new API", "A3", "C3"), + Task("T2", "Update dependencies", "A1", "C1"), + Task("T3", "Document API", "A1", "C1"), + Task("T4", "Merge community PRs", "A2", "C2"), + Task("T5", "Plan more work", null, null), + ) +} + +/** Loads a task's assignee with a batch mapping and its creator with a data loader. */ +@Controller +class TaskRelationsController(batchLoaderRegistry: BatchLoaderRegistry) { + init { + batchLoaderRegistry + .forTypePair(String::class.java, Creator::class.java) + .registerMappedBatchLoader { ids, _ -> + Mono.just(ids.associateWith { Creator(it, "Name$it") }) + } + } + + @BatchMapping(typeName = "Task", field = "assignee") + fun assignee(tasks: List): Map = + tasks + .filter { it.assigneeId != null } + .associateWith { Assignee(it.assigneeId!!, "Name${it.assigneeId}") } + + @SchemaMapping(typeName = "Task") + fun creator(task: Task, creators: DataLoader): CompletableFuture? = + task.creatorId?.let { creators.load(it) } +} diff --git a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/GraphqlIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/GraphqlIntegrationTest.kt new file mode 100644 index 00000000000..11434931326 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/GraphqlIntegrationTest.kt @@ -0,0 +1,111 @@ +package io.sentry.spring.boot4.integrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.protocol.SentryTransaction +import io.sentry.test.event +import io.sentry.test.hasSpanWithOpAndDescription +import io.sentry.test.transaction +import kotlin.test.Test +import tools.jackson.databind.JsonNode + +class GraphqlIntegrationTest : IntegrationTest() { + + @Test + fun `query is traced`() { + val response = graphql("""query { greeting(name: "world") }""") + + assertThat(response.has("errors")).isFalse() + assertThat(response["data"]["greeting"].asString()).isEqualTo("Hello world!") + awaitTransactionWithSpan("Query.greeting") + } + + @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() + } + + @Test + fun `query with a schema mapping is traced`() { + val response = graphql("""query { project(slug: "proj-slug") { slug status } }""") + + assertThat(response.has("errors")).isFalse() + assertThat(response["data"]["project"]["slug"].asString()).isEqualTo("proj-slug") + awaitTransactionWithSpan("Query.project") + } + + @Test + fun `mutation is traced`() { + val response = graphql("""mutation { addProject(slug: "proj-slug") }""") + + assertThat(response.has("errors")).isFalse() + assertThat(response["data"]["addProject"].asString()).isNotEmpty() + awaitTransactionWithSpan("Mutation.addProject") + } + + @Test + fun `mutation error is reported with its transaction`() { + val response = graphql("""mutation { addProject(slug: "addprojectcrash") }""") + + assertThat(response["errors"].size()).isEqualTo(1) + assertThat(response.path("data").path("addProject").asString(null)).isNull() + val transaction = awaitTransactionOfError("causing a project add error for addprojectcrash") + assertThat(transaction.hasSpanWithOpAndDescription("graphql", "Mutation.addProject")).isTrue() + } + + @Test + fun `batch loaded fields are traced`() { + val response = + graphql( + """query { tasks(projectSlug: "project-slug") { id assigneeId assignee { id } creatorId creator { id } } }""" + ) + + assertThat(response.has("errors")).isFalse() + val tasks = response["data"]["tasks"] + assertThat(tasks.size()).isEqualTo(5) + assertThat(tasks[0]["id"].asString()).isEqualTo("T1") + assertThat(tasks[0]["assignee"]["id"].asString()).isEqualTo("A3") + assertThat(tasks[0]["creator"]["id"].asString()).isEqualTo("C3") + + val transaction = awaitTransactionWithSpan("Query.tasks") + assertThat(transaction.hasSpanWithOpAndDescription("graphql", "Task.assignee")).isTrue() + assertThat(transaction.hasSpanWithOpAndDescription("graphql", "Task.creator")).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!! + } + + private fun awaitTransactionWithSpan(description: String): SentryTransaction = + server + .awaitEnvelope { + it.transaction()?.let { t -> + t.transaction == "POST /graphql" && + t.spans.any { span -> span.op == "graphql" && span.description == description } + } == true + } + .transaction()!! + + /** Finds the error for [exceptionMessage], then the transaction it belongs to by trace ID. */ + private fun awaitTransactionOfError(exceptionMessage: String): SentryTransaction { + val error = + server + .awaitEnvelope { envelope -> + envelope.event()?.let { event -> + event.message?.message?.startsWith("Unresolved RuntimeException for executionId ") == + true && event.exceptions?.any { it.value == exceptionMessage } == true + } == true + } + .event()!! + val traceId = error.contexts.trace!!.traceId + return server + .awaitEnvelope { it.transaction()?.contexts?.trace?.traceId == traceId } + .transaction()!! + } +} diff --git a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/IntegrationTest.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/IntegrationTest.kt new file mode 100644 index 00000000000..77259119301 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/IntegrationTest.kt @@ -0,0 +1,57 @@ +package io.sentry.spring.boot4.integrationtest + +import io.sentry.test.MockSentryServer +import org.junit.Before +import org.junit.runner.RunWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.resttestclient.TestRestTemplate +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import org.springframework.test.context.junit4.SpringRunner + +/** + * Drives [IntegrationTestApplication] over HTTP and asserts what reaches [server]. + * + * Subclasses share one application context and one server, so the app starts once per suite. + * Envelopes that arrive late can outlive [server]'s reset, so tests match on values only they + * produce. + */ +@RunWith(SpringRunner::class) +// Spring Boot 4 only provides TestRestTemplate when asked to. +@AutoConfigureTestRestTemplate +@SpringBootTest( + classes = [IntegrationTestApplication::class], + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = + [ + "sentry.traces-sample-rate=1.0", + "sentry.clients.ok-http-enabled=true", + "sentry.enable-cache-tracing=true", + "sentry.logs.enabled=true", + "sentry.profile-session-sample-rate=1.0", + "sentry.profile-lifecycle=TRACE", + "sentry.profiling-traces-dir-path=\${java.io.tmpdir}/sentry-integration-test/profiling-traces", + "spring.datasource.url=jdbc:p6spy:hsqldb:mem:testdb", + "spring.datasource.driver-class-name=com.p6spy.engine.spy.P6SpyDriver", + ], +) +abstract class IntegrationTest { + @Autowired lateinit var restTemplate: TestRestTemplate + + @Before + fun resetServer() { + server.reset() + } + + companion object { + val server = MockSentryServer() + + @JvmStatic + @DynamicPropertySource + fun sentryDsn(registry: DynamicPropertyRegistry) { + registry.add("sentry.dsn") { server.dsn } + } + } +} diff --git a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/IntegrationTestApplication.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/IntegrationTestApplication.kt new file mode 100644 index 00000000000..b14c6d03eaa --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/IntegrationTestApplication.kt @@ -0,0 +1,94 @@ +package io.sentry.spring.boot4.integrationtest + +import io.sentry.Sentry +import io.sentry.spring7.tracing.SentrySpan +import okhttp3.OkHttpClient +import org.slf4j.LoggerFactory +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.restclient.RestTemplateBuilder +import org.springframework.cache.annotation.EnableCaching +import org.springframework.context.annotation.Bean +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.stereotype.Service +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.client.RestClient +import org.springframework.web.client.RestTemplate +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.servlet.support.ServletUriComponentsBuilder + +@SpringBootApplication +@EnableCaching +class IntegrationTestApplication { + // Built from Spring Boot's builders, which Sentry customizes to trace outgoing requests. + @Bean fun restTemplate(builder: RestTemplateBuilder): RestTemplate = builder.build() + + @Bean fun restClient(builder: RestClient.Builder): RestClient = builder.build() + + @Bean fun webClient(builder: WebClient.Builder): WebClient = builder.build() + + // Traced through sentry.clients.ok-http-enabled. + @Bean fun okHttpClient(): OkHttpClient = OkHttpClient() +} + +/** + * The URL of [path] on this app, which listens on a random port. Only works on a request thread, + * because it reads the address from the current request. + */ +fun selfUrl(path: String): String = + ServletUriComponentsBuilder.fromCurrentContextPath().path(path).toUriString() + +class Person(var firstName: String? = null, var lastName: String? = null) + +@RestController +@RequestMapping("/person/") +class PersonController(private val personService: PersonService) { + private val logger = LoggerFactory.getLogger(PersonController::class.java) + + /** Reports to Sentry through every API an app uses, then fails the request. */ + @GetMapping("{id}") + fun person(@PathVariable("id") id: Long): Person { + Sentry.addFeatureFlag("transaction-feature-flag", true) + val sentrySpan = Sentry.getSpan()!!.startChild("spanCreatedThroughSentryApi") + try { + Sentry.setAttribute("user.type", "admin") + Sentry.setAttribute("feature.version", 2) + Sentry.setAttribute("debug.enabled", true) + Sentry.logger().warn("warn Sentry logging") + Sentry.logger().error("error Sentry logging") + Sentry.logger().info("hello %s %s", "there", "world!") + Sentry.addFeatureFlag("my-feature-flag", true) + logger.error("Trying person with id={}", id, RuntimeException("error while loading")) + throw IllegalArgumentException("Something went wrong [id=$id]") + } finally { + sentrySpan.finish() + } + } + + @PostMapping + fun create(@RequestBody person: Person): Person { + val sentrySpan = Sentry.getSpan()!!.startChild("spanCreatedThroughSentryApi") + try { + return personService.create(person) + } finally { + sentrySpan.finish() + } + } +} + +@Service +@SentrySpan +class PersonService(private val jdbcTemplate: JdbcTemplate) { + fun create(person: Person): Person { + jdbcTemplate.update( + "insert into person (firstName, lastName) values (?, ?)", + person.firstName, + person.lastName, + ) + return person + } +} diff --git a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/MetricController.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/MetricController.kt new file mode 100644 index 00000000000..77713284c91 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/MetricController.kt @@ -0,0 +1,33 @@ +package io.sentry.spring.boot4.integrationtest + +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/") +class MetricController { + @GetMapping("count") + fun count(): String { + Sentry.setAttribute("user.type", "admin") + Sentry.setAttribute("feature.version", 2) + Sentry.metrics().count("countMetric") + return "count metric increased" + } + + @GetMapping("gauge/{value}") + fun gauge(@PathVariable("value") value: Long): String { + Sentry.metrics().gauge("memory.free", value.toDouble(), MetricsUnit.Information.BYTE) + return "gauge metric tracked" + } + + @GetMapping("distribution/{value}") + 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-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/MetricsIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/MetricsIntegrationTest.kt new file mode 100644 index 00000000000..784cddfa2f9 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/MetricsIntegrationTest.kt @@ -0,0 +1,50 @@ +package io.sentry.spring.boot4.integrationtest + +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 MetricsIntegrationTest : IntegrationTest() { + + @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-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/PersonIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/PersonIntegrationTest.kt new file mode 100644 index 00000000000..735ce890e3f --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/PersonIntegrationTest.kt @@ -0,0 +1,100 @@ +package io.sentry.spring.boot4.integrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.Sentry +import io.sentry.protocol.FeatureFlag +import io.sentry.test.event +import io.sentry.test.hasFlag +import io.sentry.test.hasLogWithAttribute +import io.sentry.test.hasLogWithBody +import io.sentry.test.hasOp +import io.sentry.test.hasSpanWithOp +import io.sentry.test.hasSpanWithOpAndDescription +import io.sentry.test.logs +import io.sentry.test.profileChunk +import io.sentry.test.transaction +import kotlin.test.Test + +class PersonIntegrationTest : IntegrationTest() { + + @Test + fun `failed request reports errors, the transaction and logs`() { + val response = restTemplate.getForEntity("/person/1", String::class.java) + assertThat(response.statusCode.value()).isEqualTo(500) + Sentry.flush(5000) + + val logged = + server.awaitEnvelope { it.event()?.message?.formatted == "Trying person with id=1" }.event()!! + assertThat(logged.hasFlag("my-feature-flag", true)).isTrue() + + val thrown = + server + .awaitEnvelope { + it.event()?.exceptions?.any { e -> e.value == "Something went wrong [id=1]" } == true + } + .event()!! + assertThat(thrown.hasFlag("my-feature-flag", true)).isTrue() + + val transaction = + server.awaitEnvelope { it.transaction()?.transaction == "GET /person/{id}" }.transaction()!! + assertThat( + transaction.hasOp( + "http.server", + FeatureFlag("flag.evaluation.transaction-feature-flag", true), + ) + ) + .isTrue() + assertThat( + transaction.hasSpanWithOp( + "spanCreatedThroughSentryApi", + FeatureFlag("flag.evaluation.my-feature-flag", true), + ) + ) + .isTrue() + + val logs = + server + .awaitEnvelope { + it.logs()?.items?.any { log -> log.body == "warn Sentry logging" } == true + } + .logs()!! + assertThat(logs.hasLogWithBody("error Sentry logging")).isTrue() + assertThat(logs.hasLogWithBody("hello there world!")).isTrue() + assertThat(logs.hasLogWithAttribute("warn Sentry logging", "user.type", "admin")).isTrue() + assertThat(logs.hasLogWithAttribute("warn Sentry logging", "feature.version", 2)).isTrue() + assertThat(logs.hasLogWithAttribute("warn Sentry logging", "debug.enabled", true)).isTrue() + } + + @Test + fun `creating a person traces the service method and the query`() { + val response = + restTemplate.postForEntity("/person/", Person("firstA", "lastB"), Person::class.java) + assertThat(response.statusCode.value()).isEqualTo(200) + assertThat(response.body!!.firstName).isEqualTo("firstA") + assertThat(response.body!!.lastName).isEqualTo("lastB") + + val transaction = + server.awaitEnvelope { it.transaction()?.transaction == "POST /person/" }.transaction()!! + assertThat(transaction.hasSpanWithOp("PersonService.create")).isTrue() + assertThat( + transaction.hasSpanWithOpAndDescription( + "db.query", + "insert into person (firstName, lastName) values (?, ?)", + ) + ) + .isTrue() + } + + @Test + fun `creating a person starts a profile linked to the transaction`() { + val response = + restTemplate.postForEntity("/person/", Person("firstA", "lastB"), Person::class.java) + assertThat(response.statusCode.value()).isEqualTo(200) + + val transaction = + server.awaitEnvelope { it.transaction()?.transaction == "POST /person/" }.transaction()!! + val profilerId = transaction.contexts.profile?.profilerId + assertThat(profilerId).isNotNull() + server.awaitEnvelope(timeoutMillis = 30_000) { it.profileChunk()?.profilerId == profilerId } + } +} diff --git a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/TodoController.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/TodoController.kt new file mode 100644 index 00000000000..e554718ff26 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/TodoController.kt @@ -0,0 +1,63 @@ +package io.sentry.spring.boot4.integrationtest + +import io.sentry.reactor.SentryReactorUtils +import okhttp3.OkHttpClient +import okhttp3.Request +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.client.RestClient +import org.springframework.web.client.RestTemplate +import org.springframework.web.reactive.function.client.WebClient +import reactor.core.publisher.Hooks +import reactor.core.publisher.Mono +import reactor.core.scheduler.Schedulers +import tools.jackson.databind.ObjectMapper + +class Todo(var id: Long? = null, var title: String? = null, var completed: Boolean = false) + +/** Fetches a todo through each HTTP client that Sentry traces. */ +@RestController +class TodoController( + private val restTemplate: RestTemplate, + private val webClient: WebClient, + private val restClient: RestClient, + private val okHttpClient: OkHttpClient, + private val objectMapper: ObjectMapper, +) { + @GetMapping("/todo/{id}") + fun todo(@PathVariable("id") id: Long): Todo? = + restTemplate.getForObject(todoUrl(id), Todo::class.java) + + /** Requests from another thread, which the Sentry scope has to follow through Reactor. */ + @GetMapping("/todo-webclient/{id}") + fun todoWebClient(@PathVariable("id") id: Long): Todo? { + val url = todoUrl(id) + Hooks.enableAutomaticContextPropagation() + return SentryReactorUtils.withSentry( + Mono.just(true).publishOn(Schedulers.boundedElastic()).flatMap { + webClient.get().uri(url).retrieve().bodyToMono(Todo::class.java) + } + ) + .block() + } + + @GetMapping("/todo-restclient/{id}") + fun todoRestClient(@PathVariable("id") id: Long): Todo? = + restClient.get().uri(todoUrl(id)).retrieve().body(Todo::class.java) + + @GetMapping("/todo-okhttp/{id}") + fun todoOkHttp(@PathVariable("id") id: Long): Todo = + okHttpClient.newCall(Request.Builder().url(todoUrl(id)).build()).execute().use { response -> + objectMapper.readValue(response.body!!.byteStream(), Todo::class.java) + } + + private fun todoUrl(id: Long): String = selfUrl("/stub/todos/$id") +} + +/** Stands in for the public API the sample fetches todos from, so tests don't need the internet. */ +@RestController +class TodoStubController { + @GetMapping("/stub/todos/{id}") + fun todo(@PathVariable("id") id: Long): Todo = Todo(id, "delectus aut autem") +} diff --git a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/TodoIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/TodoIntegrationTest.kt new file mode 100644 index 00000000000..5e0bc317552 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/TodoIntegrationTest.kt @@ -0,0 +1,47 @@ +package io.sentry.spring.boot4.integrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.test.hasSpanWithOpAndDescription +import io.sentry.test.transaction +import kotlin.test.Test +import org.springframework.boot.test.web.server.LocalServerPort + +class TodoIntegrationTest : IntegrationTest() { + @LocalServerPort private var port = 0 + + @Test + fun `RestTemplate request is traced`() { + assertOutgoingRequestIsTraced("/todo/1", "GET /todo/{id}") + } + + @Test + fun `WebClient request is traced`() { + assertOutgoingRequestIsTraced("/todo-webclient/1", "GET /todo-webclient/{id}") + } + + @Test + fun `RestClient request is traced`() { + assertOutgoingRequestIsTraced("/todo-restclient/1", "GET /todo-restclient/{id}") + } + + @Test + fun `OkHttp request is traced`() { + assertOutgoingRequestIsTraced("/todo-okhttp/1", "GET /todo-okhttp/{id}") + } + + private fun assertOutgoingRequestIsTraced(path: String, transactionName: String) { + val response = restTemplate.getForEntity(path, Todo::class.java) + assertThat(response.statusCode.value()).isEqualTo(200) + assertThat(response.body!!.title).isEqualTo("delectus aut autem") + + val transaction = + server.awaitEnvelope { it.transaction()?.transaction == transactionName }.transaction()!! + assertThat( + transaction.hasSpanWithOpAndDescription( + "http.client", + "GET http://localhost:$port/stub/todos/1", + ) + ) + .isTrue() + } +} diff --git a/sentry-spring-boot-4/src/integrationTest/resources/graphql/schema.graphqls b/sentry-spring-boot-4/src/integrationTest/resources/graphql/schema.graphqls new file mode 100644 index 00000000000..fc2a8fd4c27 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTest/resources/graphql/schema.graphqls @@ -0,0 +1,38 @@ +type Query { + greeting(name: String!): String! + project(slug: ID!): Project + tasks(projectSlug: ID!): [Task] +} + +type Mutation { + addProject(slug: ID!): String! +} + +type Project { + slug: ID! + status: ProjectStatus! +} + +enum ProjectStatus { + ACTIVE + COMMUNITY +} + +type Task { + id: String! + name: String! + assigneeId: String + assignee: Assignee + creatorId: String + creator: Creator +} + +type Assignee { + id: String! + name: String! +} + +type Creator { + id: String! + name: String! +} diff --git a/sentry-spring-boot-4/src/integrationTest/resources/schema.sql b/sentry-spring-boot-4/src/integrationTest/resources/schema.sql new file mode 100644 index 00000000000..7ca8a5cbf42 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTest/resources/schema.sql @@ -0,0 +1,5 @@ +CREATE TABLE person ( + id INTEGER IDENTITY PRIMARY KEY, + firstName VARCHAR(50) NOT NULL, + lastName VARCHAR(50) NOT NULL +); From c8d0533069e843105605a84b2252f2dbd89a1558 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 20 Oct 2026 20:45:30 +0200 Subject: [PATCH 2/2] test(spring-boot): Port the tracing, GraphQL and test client fixes to Boot 4 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../DistributedTracingIntegrationTest.kt | 23 ++++++++++++++----- .../integrationtest/GraphqlIntegrationTest.kt | 2 ++ .../boot4/integrationtest/IntegrationTest.kt | 14 +++++++---- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/DistributedTracingIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/DistributedTracingIntegrationTest.kt index 81a97a4b14e..3904cd72546 100644 --- a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/DistributedTracingIntegrationTest.kt +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/DistributedTracingIntegrationTest.kt @@ -4,6 +4,7 @@ import com.google.common.truth.Truth.assertThat import io.sentry.SentryEnvelope import io.sentry.protocol.SentryId import io.sentry.test.transaction +import kotlin.random.Random import kotlin.test.Test import org.springframework.http.HttpEntity import org.springframework.http.HttpHeaders @@ -27,26 +28,26 @@ class DistributedTracingIntegrationTest : IntegrationTest() { ) assertThat(status).isEqualTo(500) - awaitTransaction("GET /tracing/{id}", traceId) + val incoming = awaitTransaction("GET /tracing/{id}", traceId).transaction()!! + assertThat(incoming.request?.url).endsWith("/tracing/1") awaitTransaction("GET /person/{id}", traceId) } @Test fun `unsampled trace sends no transactions`() { val traceId = SentryId() + // An id only this request uses, because a mishandled header could start a trace of its own. + val id = Random.nextLong(2, Long.MAX_VALUE) val status = get( - "/tracing/1", + "/tracing/$id", sentryTrace = "$traceId-424cffc8f94feeee-0", baggage = "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=false,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", ) assertThat(status).isEqualTo(500) - server.assertNoEnvelope { - isTransaction(it, "GET /tracing/{id}", traceId) || - isTransaction(it, "GET /person/{id}", traceId) - } + server.assertNoEnvelope { isForRequest(it, id) } } @Test @@ -63,6 +64,8 @@ class DistributedTracingIntegrationTest : IntegrationTest() { val incoming = awaitTransaction("GET /tracing/{id}", traceId).header.traceContext!! val outgoing = awaitTransaction("GET /person/{id}", traceId).header.traceContext!! + assertThat(incoming.traceId).isEqualTo(traceId) + assertThat(outgoing.traceId).isEqualTo(traceId) assertThat(incoming.sampleRand).isNotNull() assertThat(outgoing.sampleRand).isEqualTo(incoming.sampleRand) } @@ -81,6 +84,8 @@ class DistributedTracingIntegrationTest : IntegrationTest() { val incoming = awaitTransaction("GET /tracing/{id}", traceId).header.traceContext!! val outgoing = awaitTransaction("GET /person/{id}", traceId).header.traceContext!! + assertThat(incoming.traceId).isEqualTo(traceId) + assertThat(outgoing.traceId).isEqualTo(traceId) assertThat(incoming.sampleRate).isNotEqualTo("0.5") assertThat(outgoing.sampleRate).isEqualTo(incoming.sampleRate) } @@ -130,6 +135,12 @@ class DistributedTracingIntegrationTest : IntegrationTest() { isTransaction(it, name, traceId) } + /** Whether [envelope] is a transaction for `/tracing/[id]` or the `/person/[id]` it calls. */ + private fun isForRequest(envelope: SentryEnvelope, id: Long): Boolean { + val url = envelope.transaction()?.request?.url ?: return false + return url.endsWith("/tracing/$id") || url.endsWith("/person/$id") + } + private fun isTransaction(envelope: SentryEnvelope, name: String, traceId: SentryId): Boolean { val transaction = envelope.transaction() ?: return false return transaction.transaction == name && transaction.contexts.trace?.traceId == traceId diff --git a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/GraphqlIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/GraphqlIntegrationTest.kt index 11434931326..c50dca2ec1e 100644 --- a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/GraphqlIntegrationTest.kt +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/GraphqlIntegrationTest.kt @@ -67,6 +67,8 @@ class GraphqlIntegrationTest : IntegrationTest() { val tasks = response["data"]["tasks"] assertThat(tasks.size()).isEqualTo(5) assertThat(tasks[0]["id"].asString()).isEqualTo("T1") + assertThat(tasks[0]["assigneeId"].asString()).isEqualTo("A3") + assertThat(tasks[0]["creatorId"].asString()).isEqualTo("C3") assertThat(tasks[0]["assignee"]["id"].asString()).isEqualTo("A3") assertThat(tasks[0]["creator"]["id"].asString()).isEqualTo("C3") diff --git a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/IntegrationTest.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/IntegrationTest.kt index 77259119301..9f8d8d60a8c 100644 --- a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/IntegrationTest.kt +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/IntegrationTest.kt @@ -3,10 +3,10 @@ package io.sentry.spring.boot4.integrationtest import io.sentry.test.MockSentryServer import org.junit.Before import org.junit.runner.RunWith -import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.restclient.RestTemplateBuilder import org.springframework.boot.resttestclient.TestRestTemplate -import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.server.LocalServerPort import org.springframework.test.context.DynamicPropertyRegistry import org.springframework.test.context.DynamicPropertySource import org.springframework.test.context.junit4.SpringRunner @@ -19,8 +19,6 @@ import org.springframework.test.context.junit4.SpringRunner * produce. */ @RunWith(SpringRunner::class) -// Spring Boot 4 only provides TestRestTemplate when asked to. -@AutoConfigureTestRestTemplate @SpringBootTest( classes = [IntegrationTestApplication::class], webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, @@ -38,7 +36,13 @@ import org.springframework.test.context.junit4.SpringRunner ], ) abstract class IntegrationTest { - @Autowired lateinit var restTemplate: TestRestTemplate + @LocalServerPort private var port = 0 + + // The context's TestRestTemplate comes from the RestTemplateBuilder that Sentry customizes, so + // it would add trace headers of its own to the requests tests send. Tests use a plain one. + val restTemplate: TestRestTemplate by lazy { + TestRestTemplate(RestTemplateBuilder().rootUri("http://localhost:$port")) + } @Before fun resetServer() {