From a55b55cff181843b0c5d0834d774ea77527a82ad Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 20 Oct 2026 17:37:54 +0200 Subject: [PATCH 1/2] test(spring-boot): Move the Boot 3 OTel no-agent sample's tests into the module Adds an integrationTestOtelNoAgent suite to sentry-spring-boot-jakarta. The app traces through the OpenTelemetry SDK in its own JVM, set up by sentry-opentelemetry-agentless-spring, and the suite ports the no-agent sample's tests: spans from the OTel annotation, the OTel API and the Sentry API with their feature flags, the OTel database span, trace continuation, client spans, cache spans, GraphQL, and Kafka traced by OTel instead of Sentry's Spring Kafka integration. The sample's Metrics tests were copies of the servlet ones and don't depend on tracing. The suite needs a TestRestTemplate built outside the context: the one Spring Boot provides is instrumented by the OTel starter, which replaced the trace headers the tests send and started a new trace. Sentry exports OTel spans in batches every 5 seconds, so every test waited that long. MockSentryServer gets a hook that runs before each check while waiting, which the suite uses to flush the tracer provider, taking the suite from about 112s to 8s of test time. The sample has no system tests left, so it comes off the system-test runner, the backend workflow and the Spring Boot 3 matrix, which now runs the new suite. integrationTests includes it too. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/spring-boot-3-matrix.yml | 10 +- .github/workflows/system-tests-backend.yml | 3 - build.gradle.kts | 1 + .../build.gradle.kts | 31 --- .../src/test/kotlin/io/sentry/DummyTest.kt | 12 -- .../io/sentry/systemtest/CacheSystemTest.kt | 51 ----- .../DistributedTracingSystemTest.kt | 197 ------------------ .../systemtest/GraphqlGreetingSystemTest.kt | 46 ---- .../systemtest/GraphqlProjectSystemTest.kt | 66 ------ .../systemtest/GraphqlTaskSystemTest.kt | 40 ---- .../KafkaOtelCoexistenceSystemTest.kt | 37 ---- .../io/sentry/systemtest/MetricsSystemTest.kt | 51 ----- .../io/sentry/systemtest/PersonSystemTest.kt | 174 ---------------- .../io/sentry/systemtest/TodoSystemTest.kt | 65 ------ .../src/test/resources/logback.xml | 17 -- sentry-spring-boot-jakarta/build.gradle.kts | 45 +++- .../CacheController.kt | 43 ++++ .../CacheIntegrationTest.kt | 35 ++++ .../DistributedTracingController.kt | 22 ++ .../DistributedTracingIntegrationTest.kt | 137 ++++++++++++ .../GraphqlControllers.kt | 83 ++++++++ .../GraphqlIntegrationTest.kt | 110 ++++++++++ .../IntegrationTest.kt | 85 ++++++++ .../IntegrationTestApplication.kt | 118 +++++++++++ .../KafkaController.kt | 28 +++ .../KafkaOtelCoexistenceIntegrationTest.kt | 44 ++++ .../PersonIntegrationTest.kt | 146 +++++++++++++ .../TodoController.kt | 76 +++++++ .../TodoIntegrationTest.kt | 50 +++++ .../resources/graphql/schema.graphqls | 38 ++++ .../resources/schema.sql | 5 + .../kotlin/io/sentry/test/MockSentryServer.kt | 7 +- .../io/sentry/test/MockSentryServerTest.kt | 12 ++ test/system-test-runner.py | 3 - 34 files changed, 1082 insertions(+), 806 deletions(-) delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/DummyTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/KafkaOtelCoexistenceSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/resources/logback.xml create mode 100644 sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/CacheController.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/CacheIntegrationTest.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/DistributedTracingController.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/DistributedTracingIntegrationTest.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/GraphqlControllers.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/GraphqlIntegrationTest.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/IntegrationTest.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/IntegrationTestApplication.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/KafkaController.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/KafkaOtelCoexistenceIntegrationTest.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/PersonIntegrationTest.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/TodoController.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/TodoIntegrationTest.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/resources/graphql/schema.graphqls create mode 100644 sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/resources/schema.sql diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index 1bb0b79e576..444efa61a87 100644 --- a/.github/workflows/spring-boot-3-matrix.yml +++ b/.github/workflows/spring-boot-3-matrix.yml @@ -73,8 +73,6 @@ jobs: ./gradlew \ :sentry-samples:sentry-samples-spring-boot-jakarta-opentelemetry:bootJar \ :sentry-samples:sentry-samples-spring-boot-jakarta-opentelemetry:testClasses \ - :sentry-samples:sentry-samples-spring-boot-jakarta-opentelemetry-noagent:bootJar \ - :sentry-samples:sentry-samples-spring-boot-jakarta-opentelemetry-noagent:testClasses \ :sentry-samples:sentry-samples-spring-jakarta:war \ :sentry-samples:sentry-samples-spring-jakarta:testClasses \ :sentry-opentelemetry:sentry-opentelemetry-agent:assemble @@ -84,6 +82,7 @@ jobs: ./gradlew \ :sentry-spring-boot-jakarta:integrationTest \ :sentry-spring-boot-jakarta:integrationTestWebflux \ + :sentry-spring-boot-jakarta:integrationTestOtelNoAgent \ :sentry-spring-boot-jakarta:integrationTestOtelAgent - name: Test sentry-samples-spring-boot-jakarta-opentelemetry agent init true @@ -100,13 +99,6 @@ jobs: --agent true \ --auto-init "false" - - name: Test sentry-samples-spring-boot-jakarta-opentelemetry-noagent - run: | - python3 test/system-test-runner.py test \ - --module "sentry-samples-spring-boot-jakarta-opentelemetry-noagent" \ - --agent false \ - --auto-init "true" - - name: Test sentry-samples-spring-jakarta run: | python3 test/system-test-runner.py test \ diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index a5f3aa8a3f6..ad09afb6e94 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-jakarta-opentelemetry-noagent" - agent: "false" - agent-auto-init: "true" - sample: "sentry-samples-spring-boot-jakarta-opentelemetry" agent: "true" agent-auto-init: "true" diff --git a/build.gradle.kts b/build.gradle.kts index be6f418bf9d..94008c293e1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -244,6 +244,7 @@ tasks.register("integrationTests") { ":sentry-spring-boot:integrationTestWebflux", ":sentry-spring-boot-jakarta:integrationTest", ":sentry-spring-boot-jakarta:integrationTestWebflux", + ":sentry-spring-boot-jakarta:integrationTestOtelNoAgent", ":sentry-spring-boot-jakarta:integrationTestOtelAgent", ":sentry-spring-boot-4:integrationTest", ":sentry-spring-boot-4:integrationTestWebflux", diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts index 6d1549842ce..35d870271b6 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts @@ -7,7 +7,6 @@ plugins { alias(libs.plugins.spring.dependency.management) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) - id("io.sentry.systemtest") } group = "io.sentry.sample.spring-boot-jakarta" @@ -73,34 +72,4 @@ dependencies { // database query tracing implementation(projects.sentryJdbc) runtimeOnly(libs.hsqldb) - - testImplementation(kotlin(Config.kotlinStdLib)) - testImplementation(projects.sentrySystemTestSupport) - testImplementation(libs.apollo3.kotlin) - testImplementation(libs.kotlin.test.junit) - testImplementation(libs.slf4j2.api) - testImplementation(libs.springboot3.starter.test) { - exclude(group = "org.junit.vintage", module = "junit-vintage-engine") - } - testImplementation("ch.qos.logback:logback-classic:1.5.16") - testImplementation("ch.qos.logback:logback-core:1.5.16") -} - -tasks.register("systemTest").configure { - group = "verification" - description = "Runs the System tests" - - val test = project.extensions.getByType()["test"] - testClassesDirs = test.output.classesDirs - classpath = test.runtimeClasspath - - maxParallelForks = 1 - - filter { includeTestsMatching("io.sentry.systemtest*") } -} - -tasks.named("test").configure { - require(this is Test) - - filter { excludeTestsMatching("io.sentry.systemtest.*") } } diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/DummyTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/DummyTest.kt deleted file mode 100644 index 6f762b7e453..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/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-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt deleted file mode 100644 index b45e9c10853..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/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-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt deleted file mode 100644 index 3cd16003024..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt +++ /dev/null @@ -1,197 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.protocol.SentryId -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotEquals -import org.junit.Before - -class DistributedTracingSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `get person distributed tracing`() { - val traceId = SentryId() - val restClient = testHelper.restClient - restClient.getPersonDistributedTracing( - 1L, - mapOf( - "sentry-trace" to "$traceId-424cffc8f94feeee-1", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(500, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /tracing/{id}" && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /person/{id}" && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - } - } - - @Test - fun `get person distributed tracing with sampled false`() { - val traceId = SentryId() - val restClient = testHelper.restClient - restClient.getPersonDistributedTracing( - 1L, - mapOf( - "sentry-trace" to "$traceId-424cffc8f94feeee-0", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=false,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(500, restClient.lastKnownStatusCode) - - testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /tracing/{id}" - } - - testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /person/{id}" - } - } - - @Test - fun `get person distributed tracing without sample_rand`() { - val traceId = SentryId() - val restClient = testHelper.restClient - restClient.getPersonDistributedTracing( - 1L, - mapOf( - "sentry-trace" to "$traceId-424cffc8f94feeee-1", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(500, restClient.lastKnownStatusCode) - - var sampleRand1: String? = null - var sampleRand2: String? = null - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - val matches = - transaction.transaction == "GET /tracing/{id}" && - envelopeHeader.traceContext!!.traceId == traceId && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - - if (matches) { - testHelper.logObject(envelopeHeader) - testHelper.logObject(transaction) - sampleRand1 = envelopeHeader.traceContext?.sampleRand - } - - matches - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - val matches = - transaction.transaction == "GET /person/{id}" && - envelopeHeader.traceContext!!.traceId == traceId && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - - if (matches) { - testHelper.logObject(envelopeHeader) - testHelper.logObject(transaction) - sampleRand2 = envelopeHeader.traceContext?.sampleRand - } - - matches - } - - assertEquals(sampleRand1, sampleRand2) - } - - @Test - fun `get person distributed tracing updates sample_rate on deferred decision`() { - val traceId = SentryId() - val restClient = testHelper.restClient - restClient.getPersonDistributedTracing( - 1L, - mapOf( - "sentry-trace" to "$traceId-424cffc8f94feeee", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(500, restClient.lastKnownStatusCode) - - var sampleRate1: String? = null - var sampleRate2: String? = null - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - val matches = - transaction.transaction == "GET /tracing/{id}" && - envelopeHeader.traceContext!!.traceId == traceId && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - - if (matches) { - testHelper.logObject(envelopeHeader) - testHelper.logObject(transaction) - sampleRate1 = envelopeHeader.traceContext?.sampleRate - } - - matches - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - val matches = - transaction.transaction == "GET /person/{id}" && - envelopeHeader.traceContext!!.traceId == traceId && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - - if (matches) { - testHelper.logObject(envelopeHeader) - testHelper.logObject(transaction) - sampleRate2 = envelopeHeader.traceContext?.sampleRate - } - - matches - } - - assertEquals(sampleRate1, sampleRate2) - assertNotEquals(sampleRate1, "0.5") - } - - @Test - fun `create person distributed tracing`() { - val traceId = SentryId() - val restClient = testHelper.restClient - val person = Person("firstA", "lastB") - val returnedPerson = - restClient.createPersonDistributedTracing( - person, - mapOf( - "sentry-trace" to "$traceId-424cffc8f94feeee-1", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(200, restClient.lastKnownStatusCode) - - assertEquals(person.firstName, returnedPerson!!.firstName) - assertEquals(person.lastName, returnedPerson!!.lastName) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "POST /tracing/" && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "POST /person/" && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt deleted file mode 100644 index 76a6024decc..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/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-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt deleted file mode 100644 index fca3956717c..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/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-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt deleted file mode 100644 index f9359ae1496..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt +++ /dev/null @@ -1,40 +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", - ) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/KafkaOtelCoexistenceSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/KafkaOtelCoexistenceSystemTest.kt deleted file mode 100644 index c401c91463e..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/KafkaOtelCoexistenceSystemTest.kt +++ /dev/null @@ -1,37 +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 KafkaOtelCoexistenceSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `Sentry Kafka integration is suppressed when OTel is active`() { - val restClient = testHelper.restClient - - restClient.produceKafkaMessage("otel-coexistence-test") - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, _ -> - transaction.transaction == "GET /kafka/produce" && - transaction.contexts.trace?.origin == "auto.opentelemetry" && - transaction.sdk?.integrationSet?.contains("SpringKafka") != true - } - - testHelper.ensureTransactionReceived { transaction, _ -> - transaction.contexts.trace?.operation == "queue.process" && - transaction.contexts.trace?.origin == "auto.opentelemetry" && - transaction.contexts.trace?.data?.get("messaging.system") == "kafka" && - transaction.sdk?.integrationSet?.contains("SpringKafka") != true - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt deleted file mode 100644 index 039d9d640c7..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/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-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt deleted file mode 100644 index 1fe742b64ce..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ /dev/null @@ -1,174 +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, "inner-feature-flag", true) - } - - testHelper.ensureErrorReceived { event -> - testHelper.doesEventHaveExceptionMessage(event, "Something went wrong [id=1]") && - testHelper.doesEventHaveFlag(event, "inner-feature-flag", true) - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionHave(transaction, op = "http.server") && - testHelper.doesTransactionHaveSpanWith( - transaction, - op = "personSpanThroughOtelAnnotation", - featureFlag = FeatureFlag("flag.evaluation.outer-feature-flag", true), - ) && - testHelper.doesTransactionHaveSpanWith( - transaction, - op = "spanCreatedThroughOtelApi", - featureFlag = FeatureFlag("flag.evaluation.inner-feature-flag", true), - ) && - testHelper.doesTransactionHaveSpanWith( - transaction, - op = "spanCreatedThroughSentryApi", - noFeatureFlags = 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, "spanCreatedThroughOtelApi") && - testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") && - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "db", - "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 - } - } - - @Test - fun `create person creates transaction if no sampled flag in sentry-trace header`() { - val restClient = testHelper.restClient - val person = Person("firstA", "lastB") - val returnedPerson = - restClient.createPerson( - person, - mapOf( - "sentry-trace" to "f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=1,sentry-trace_id=f9118105af4a2d42b4124532cd1065ff,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(200, restClient.lastKnownStatusCode) - - assertEquals(person.firstName, returnedPerson!!.firstName) - assertEquals(person.lastName, returnedPerson!!.lastName) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && - testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") && - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "db", - "insert into person (firstName, lastName) values (?, ?)", - ) - } - } - - @Test - fun `create person creates transaction if sampled true in sentry-trace header`() { - val restClient = testHelper.restClient - val person = Person("firstA", "lastB") - val returnedPerson = - restClient.createPerson( - person, - mapOf( - "sentry-trace" to "f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=1,sentry-trace_id=f9118105af4a2d42b4124532cd1065ff,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(200, restClient.lastKnownStatusCode) - - assertEquals(person.firstName, returnedPerson!!.firstName) - assertEquals(person.lastName, returnedPerson!!.lastName) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && - testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") && - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "db", - "insert into person (firstName, lastName) values (?, ?)", - ) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt deleted file mode 100644 index e92ad0a17f9..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt +++ /dev/null @@ -1,65 +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.doesTransactionContainSpanWithOp(transaction, "todoSpanOtelApi") && - testHelper.doesTransactionContainSpanWithOp(transaction, "todoSpanSentryApi") && - 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.doesTransactionContainSpanWithOp(transaction, "todoRestClientSpanOtelApi") && - testHelper.doesTransactionContainSpanWithOp(transaction, "todoRestClientSpanSentryApi") && - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "http.client", - "GET https://jsonplaceholder.typicode.com/todos/1", - ) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/resources/logback.xml b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/resources/logback.xml deleted file mode 100644 index a36b8f80f76..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/resources/logback.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - %-4relative [%thread] %-5level %logger{35} -%kvp- %msg %n - - - - - - - diff --git a/sentry-spring-boot-jakarta/build.gradle.kts b/sentry-spring-boot-jakarta/build.gradle.kts index e0b962c461b..ce9d22d3ed0 100644 --- a/sentry-spring-boot-jakarta/build.gradle.kts +++ b/sentry-spring-boot-jakarta/build.gradle.kts @@ -230,6 +230,39 @@ testing { } } + // The same with Sentry tracing through the OpenTelemetry SDK in the app's JVM instead of the + // agent. + register("integrationTestOtelNoAgent") { + useJUnit() + dependencies { + implementation(platform(SpringBootPlugin.BOM_COORDINATES)) + implementation(projects.sentrySpringBootStarterJakarta) + implementation(projects.sentryOpentelemetry.sentryOpentelemetryAgentlessSpring) + implementation(projects.sentryAsyncProfiler) + implementation(projects.sentryGraphql22) + implementation(projects.sentryJdbc) + implementation(projects.sentryKafka) + implementation(projects.sentryLogback) + implementation(projects.sentryTestSupport) + // Reactor's automatic context propagation needs it to carry the Sentry scope across + // threads. + implementation(libs.context.propagation) + implementation(libs.kotlin.test.junit) + implementation(libs.google.truth) + implementation(libs.caffeine) + implementation(libs.springboot3.starter.aop) + implementation(libs.springboot3.starter.cache) + implementation(libs.springboot3.starter.graphql) + implementation(libs.springboot3.starter.jdbc) + implementation(libs.springboot3.starter.test) + implementation(libs.springboot3.starter.web) + implementation(libs.springboot3.starter.webflux) + implementation(libs.spring.kafka3) + implementation(libs.spring.kafka3.test) + runtimeOnly(libs.hsqldb) + } + } + register("integrationTestOtelAgent") { useJUnit() dependencies { @@ -253,14 +286,20 @@ testing { // The Spring Boot Gradle plugin compiles apps with parameter names, which Spring reads, for example // to resolve `#id` in a cache key. -listOf("compileIntegrationTestKotlin", "compileIntegrationTestWebfluxKotlin").forEach { - tasks.named(it) { compilerOptions.javaParameters = true } -} +listOf( + "compileIntegrationTestKotlin", + "compileIntegrationTestWebfluxKotlin", + "compileIntegrationTestOtelNoAgentKotlin", + ) + .forEach { + tasks.named(it) { compilerOptions.javaParameters = true } + } tasks.named("check") { dependsOn( testing.suites.named("integrationTest"), testing.suites.named("integrationTestWebflux"), + testing.suites.named("integrationTestOtelNoAgent"), testing.suites.named("integrationTestOtelAgent"), ) } diff --git a/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/CacheController.kt b/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/CacheController.kt new file mode 100644 index 00000000000..df4281b4909 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/CacheController.kt @@ -0,0 +1,43 @@ +package io.sentry.spring.boot.jakarta.otelnoagentintegrationtest + +import java.util.concurrent.ConcurrentHashMap +import org.springframework.cache.annotation.CacheEvict +import org.springframework.cache.annotation.CachePut +import org.springframework.cache.annotation.Cacheable +import org.springframework.stereotype.Service +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/cache/") +open class CacheController(private val todoCache: TodoCache) { + @GetMapping("{id}") open fun get(@PathVariable("id") id: Long): Todo? = todoCache.get(id) + + @PostMapping open fun save(@RequestBody todo: Todo): Todo = todoCache.save(todo) + + @DeleteMapping("{id}") open fun delete(@PathVariable("id") id: Long) = todoCache.delete(id) +} + +/** Reads and writes todos through Spring's cache abstraction, which Sentry traces. */ +@Service +open class TodoCache { + private val store = ConcurrentHashMap() + + @Cacheable(value = ["todos"], key = "#id") open fun get(id: Long): Todo? = store[id] + + @CachePut(value = ["todos"], key = "#todo.id") + open fun save(todo: Todo): Todo { + store[todo.id!!] = todo + return todo + } + + @CacheEvict(value = ["todos"], key = "#id") + open fun delete(id: Long) { + store.remove(id) + } +} diff --git a/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/CacheIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/CacheIntegrationTest.kt new file mode 100644 index 00000000000..953e24b4a52 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/CacheIntegrationTest.kt @@ -0,0 +1,35 @@ +package io.sentry.spring.boot.jakarta.otelnoagentintegrationtest + +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-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/DistributedTracingController.kt b/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/DistributedTracingController.kt new file mode 100644 index 00000000000..94558855a53 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/DistributedTracingController.kt @@ -0,0 +1,22 @@ +package io.sentry.spring.boot.jakarta.otelnoagentintegrationtest + +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.client.RestClient + +/** Forwards to this app's own Person endpoints, so a trace continues through outgoing requests. */ +@RestController +@RequestMapping("/tracing/") +open class DistributedTracingController(private val restClient: RestClient) { + @GetMapping("{id}") + open fun person(@PathVariable("id") id: Long): Person? = + restClient.get().uri(selfUrl("/person/$id")).retrieve().body(Person::class.java) + + @PostMapping + open fun create(@RequestBody person: Person): Person? = + restClient.post().uri(selfUrl("/person/")).body(person).retrieve().body(Person::class.java) +} diff --git a/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/DistributedTracingIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/DistributedTracingIntegrationTest.kt new file mode 100644 index 00000000000..76b75a82d30 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/DistributedTracingIntegrationTest.kt @@ -0,0 +1,137 @@ +package io.sentry.spring.boot.jakarta.otelnoagentintegrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.SentryEnvelope +import io.sentry.protocol.SentryId +import io.sentry.test.transaction +import kotlin.test.Test +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod + +/** + * Sends requests that continue an incoming trace, and checks that both the request's transaction + * and the one for the app's own outgoing request carry the trace. + */ +class DistributedTracingIntegrationTest : IntegrationTest() { + + @Test + fun `sampled trace continues through the outgoing request`() { + val traceId = SentryId() + val status = + get( + "/tracing/1", + sentryTrace = "$traceId-424cffc8f94feeee-1", + baggage = + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ) + assertThat(status).isEqualTo(500) + + awaitTransaction("GET /tracing/{id}", traceId) + awaitTransaction("GET /person/{id}", traceId) + } + + @Test + fun `unsampled trace sends no transactions`() { + val traceId = SentryId() + val status = + get( + "/tracing/1", + sentryTrace = "$traceId-424cffc8f94feeee-0", + baggage = + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=false,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ) + assertThat(status).isEqualTo(500) + + server.assertNoEnvelope { + isTransaction(it, "GET /tracing/{id}", traceId) || + isTransaction(it, "GET /person/{id}", traceId) + } + } + + @Test + fun `sample_rand generated for baggage without one is propagated`() { + val traceId = SentryId() + val status = + get( + "/tracing/1", + sentryTrace = "$traceId-424cffc8f94feeee-1", + baggage = + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ) + assertThat(status).isEqualTo(500) + + val incoming = awaitTransaction("GET /tracing/{id}", traceId).header.traceContext!! + val outgoing = awaitTransaction("GET /person/{id}", traceId).header.traceContext!! + assertThat(incoming.sampleRand).isNotNull() + assertThat(outgoing.sampleRand).isEqualTo(incoming.sampleRand) + } + + @Test + fun `deferred sampling decision updates the propagated sample_rate`() { + val traceId = SentryId() + val status = + get( + "/tracing/1", + sentryTrace = "$traceId-424cffc8f94feeee", + baggage = + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ) + assertThat(status).isEqualTo(500) + + val incoming = awaitTransaction("GET /tracing/{id}", traceId).header.traceContext!! + val outgoing = awaitTransaction("GET /person/{id}", traceId).header.traceContext!! + assertThat(incoming.sampleRate).isNotEqualTo("0.5") + assertThat(outgoing.sampleRate).isEqualTo(incoming.sampleRate) + } + + @Test + fun `sampled trace continues through an outgoing POST`() { + val traceId = SentryId() + val headers = + traceHeaders( + sentryTrace = "$traceId-424cffc8f94feeee-1", + baggage = + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ) + val response = + restTemplate.postForEntity( + "/tracing/", + HttpEntity(Person("firstA", "lastB"), headers), + Person::class.java, + ) + assertThat(response.statusCode.value()).isEqualTo(200) + assertThat(response.body!!.firstName).isEqualTo("firstA") + assertThat(response.body!!.lastName).isEqualTo("lastB") + + awaitTransaction("POST /tracing/", traceId) + awaitTransaction("POST /person/", traceId) + } + + private fun get(path: String, sentryTrace: String, baggage: String): Int = + restTemplate + .exchange( + path, + HttpMethod.GET, + HttpEntity(traceHeaders(sentryTrace, baggage)), + String::class.java, + ) + .statusCode + .value() + + private fun traceHeaders(sentryTrace: String, baggage: String): HttpHeaders = + HttpHeaders().apply { + set("sentry-trace", sentryTrace) + set("baggage", baggage) + } + + private fun awaitTransaction(name: String, traceId: SentryId): SentryEnvelope = + server.awaitEnvelope { + isTransaction(it, name, traceId) + } + + private fun isTransaction(envelope: SentryEnvelope, name: String, traceId: SentryId): Boolean { + val transaction = envelope.transaction() ?: return false + return transaction.transaction == name && transaction.contexts.trace?.traceId == traceId + } +} diff --git a/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/GraphqlControllers.kt b/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/GraphqlControllers.kt new file mode 100644 index 00000000000..35abc863188 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/GraphqlControllers.kt @@ -0,0 +1,83 @@ +package io.sentry.spring.boot.jakarta.otelnoagentintegrationtest + +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 +open class ProjectController { + @QueryMapping + open fun greeting(@Argument name: String): String { + if (name == "crash") { + throw RuntimeException("causing an error for $name") + } + return "Hello $name!" + } + + @QueryMapping open fun project(@Argument slug: String): Project = Project(slug) + + @SchemaMapping(typeName = "Project", field = "status") + open fun status(project: Project): ProjectStatus = ProjectStatus.COMMUNITY + + @MutationMapping + open fun addProject(@Argument slug: String): String { + if (slug == "addprojectcrash") { + throw RuntimeException("causing a project add error for $slug") + } + return UUID.randomUUID().toString() + } + + @QueryMapping + open 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 +open 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") + open fun assignee(tasks: List): Map = + tasks + .filter { it.assigneeId != null } + .associateWith { Assignee(it.assigneeId!!, "Name${it.assigneeId}") } + + @SchemaMapping(typeName = "Task") + open fun creator(task: Task, creators: DataLoader): CompletableFuture? = + task.creatorId?.let { creators.load(it) } +} diff --git a/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/GraphqlIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/GraphqlIntegrationTest.kt new file mode 100644 index 00000000000..dcf93aba8ac --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/GraphqlIntegrationTest.kt @@ -0,0 +1,110 @@ +package io.sentry.spring.boot.jakarta.otelnoagentintegrationtest + +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 GraphqlIntegrationTest : IntegrationTest() { + + @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!") + 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"].asText()).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"].asText()).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").asText(null)).isNull() + val transaction = awaitTransactionOfError("causing a project add error for addprojectcrash") + assertThat(transaction.hasSpanWithOpAndDescription("graphql", "Mutation.addProject")).isTrue() + } + + /** With OpenTelemetry, only the query's own resolver gets a span, not the batch-loaded fields. */ + @Test + fun `query with batch loaded fields is 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"].asText()).isEqualTo("T1") + assertThat(tasks[0]["assignee"]["id"].asText()).isEqualTo("A3") + assertThat(tasks[0]["creator"]["id"].asText()).isEqualTo("C3") + + awaitTransactionWithSpan("Query.tasks") + } + + 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-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/IntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/IntegrationTest.kt new file mode 100644 index 00000000000..742402ce24c --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/IntegrationTest.kt @@ -0,0 +1,85 @@ +package io.sentry.spring.boot.jakarta.otelnoagentintegrationtest + +import io.opentelemetry.api.OpenTelemetry +import io.opentelemetry.sdk.OpenTelemetrySdk +import io.opentelemetry.sdk.trace.SdkTracerProvider +import io.sentry.test.MockSentryServer +import java.util.concurrent.TimeUnit +import org.junit.Before +import org.junit.runner.RunWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.boot.web.client.RestTemplateBuilder +import org.springframework.kafka.test.context.EmbeddedKafka +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]. The app traces + * through the OpenTelemetry SDK in its own JVM, set up by sentry-opentelemetry-agentless-spring. + * + * Subclasses share one application context, one server and one Kafka broker, so each starts once + * per suite. Envelopes that arrive late can outlive [server]'s reset, so tests match on values only + * they produce. + */ +@RunWith(SpringRunner::class) +// An in-JVM KRaft broker, so no Docker is needed. As part of the context, it starts before the +// app's Kafka listener connects and stops after it. +@EmbeddedKafka(topics = [KAFKA_TOPIC], bootstrapServersProperty = "spring.kafka.bootstrap-servers") +@SpringBootTest( + classes = [IntegrationTestApplication::class], + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = + [ + "sentry.traces-sample-rate=1.0", + "sentry.enable-cache-tracing=true", + "sentry.enable-queue-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", + "spring.kafka.consumer.auto-offset-reset=earliest", + "otel.propagators=tracecontext,baggage,sentry", + "otel.traces.exporter=none", + "otel.metrics.exporter=none", + "otel.logs.exporter=none", + ], +) +abstract class IntegrationTest { + @LocalServerPort private var port = 0 + + // The context's TestRestTemplate is instrumented by the OpenTelemetry starter, which would + // replace + // the trace headers tests send with its own, so tests use one built outside the context. + val restTemplate: TestRestTemplate by lazy { + TestRestTemplate(RestTemplateBuilder().rootUri("http://localhost:$port")) + } + + @Autowired private lateinit var openTelemetry: OpenTelemetry + + @Before + fun resetServer() { + tracerProvider = (openTelemetry as OpenTelemetrySdk).sdkTracerProvider + server.reset() + } + + companion object { + @Volatile private var tracerProvider: SdkTracerProvider? = null + + // Sentry exports OpenTelemetry spans in batches every 5 seconds, so flush while waiting. + val server = MockSentryServer { + tracerProvider?.forceFlush()?.join(1, TimeUnit.SECONDS) + } + + @JvmStatic + @DynamicPropertySource + fun sentryDsn(registry: DynamicPropertyRegistry) { + registry.add("sentry.dsn") { server.dsn } + } + } +} diff --git a/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/IntegrationTestApplication.kt b/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/IntegrationTestApplication.kt new file mode 100644 index 00000000000..5b6ba8377f4 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/IntegrationTestApplication.kt @@ -0,0 +1,118 @@ +package io.sentry.spring.boot.jakarta.otelnoagentintegrationtest + +import io.opentelemetry.api.OpenTelemetry +import io.opentelemetry.api.trace.Tracer +import io.opentelemetry.instrumentation.annotations.WithSpan +import io.sentry.Sentry +import io.sentry.spring.jakarta.tracing.SentrySpan +import org.slf4j.LoggerFactory +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.web.client.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 + +// Classes and methods are open because Spring proxies them and this module doesn't apply the +// kotlin-spring plugin. + +@SpringBootApplication +@EnableCaching +open class IntegrationTestApplication { + // Built from Spring Boot's builders, which Sentry customizes to trace outgoing requests. + @Bean open fun restTemplate(builder: RestTemplateBuilder): RestTemplate = builder.build() + + @Bean open fun restClient(builder: RestClient.Builder): RestClient = builder.build() + + @Bean open fun webClient(builder: WebClient.Builder): WebClient = builder.build() + + @Bean + open fun tracer(openTelemetry: OpenTelemetry): Tracer = + openTelemetry.getTracer("integration-test") +} + +/** + * The URL of [path] on this app, which listens on a random port. Only works on a request thread, + * because it reads the address from the current request. + */ +fun selfUrl(path: String): String = + ServletUriComponentsBuilder.fromCurrentContextPath().path(path).toUriString() + +open class Person(var firstName: String? = null, var lastName: String? = null) + +@RestController +@RequestMapping("/person/") +open class PersonController(private val personService: PersonService, private val tracer: Tracer) { + private val logger = LoggerFactory.getLogger(PersonController::class.java) + + /** + * Nests a span from an OpenTelemetry annotation, one from the OpenTelemetry API and one from the + * Sentry API, each with its own feature flags, then fails the request. + */ + @GetMapping("{id}") + @WithSpan("personSpanThroughOtelAnnotation") + open fun person(@PathVariable("id") id: Long): Person { + Sentry.addFeatureFlag("outer-feature-flag", true) + val span = tracer.spanBuilder("spanCreatedThroughOtelApi").startSpan() + try { + span.makeCurrent().use { + 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("inner-feature-flag", true) + val sentrySpan = Sentry.getSpan()!!.startChild("spanCreatedThroughSentryApi") + try { + logger.error("Trying person with id={}", id, RuntimeException("error while loading")) + throw IllegalArgumentException("Something went wrong [id=$id]") + } finally { + sentrySpan.finish() + } + } + } finally { + span.end() + } + } + + @PostMapping + open fun create(@RequestBody person: Person): Person { + val span = tracer.spanBuilder("spanCreatedThroughOtelApi").startSpan() + try { + span.makeCurrent().use { + val sentrySpan = Sentry.getSpan()!!.startChild("spanCreatedThroughSentryApi") + try { + return personService.create(person) + } finally { + sentrySpan.finish() + } + } + } finally { + span.end() + } + } +} + +@Service +@SentrySpan +open class PersonService(private val jdbcTemplate: JdbcTemplate) { + open fun create(person: Person): Person { + jdbcTemplate.update( + "insert into person (firstName, lastName) values (?, ?)", + person.firstName, + person.lastName, + ) + return person + } +} diff --git a/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/KafkaController.kt b/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/KafkaController.kt new file mode 100644 index 00000000000..e336c201455 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/KafkaController.kt @@ -0,0 +1,28 @@ +package io.sentry.spring.boot.jakarta.otelnoagentintegrationtest + +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.stereotype.Component +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +const val KAFKA_TOPIC = "sentry-topic" + +@RestController +@RequestMapping("/kafka") +open class KafkaController(private val kafkaTemplate: KafkaTemplate) { + @GetMapping("/produce") + open fun produce(@RequestParam message: String): String { + kafkaTemplate.send(KAFKA_TOPIC, message) + return "Message sent: $message" + } +} + +@Component +open class KafkaConsumer { + /** Does nothing with the message; Sentry traces the listener invocation. */ + @KafkaListener(topics = [KAFKA_TOPIC], groupId = "sentry-integration-test") + open fun listen(message: String) {} +} diff --git a/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/KafkaOtelCoexistenceIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/KafkaOtelCoexistenceIntegrationTest.kt new file mode 100644 index 00000000000..ada031b82c4 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/KafkaOtelCoexistenceIntegrationTest.kt @@ -0,0 +1,44 @@ +package io.sentry.spring.boot.jakarta.otelnoagentintegrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.protocol.SentryTransaction +import io.sentry.test.transaction +import kotlin.test.Test + +/** With OpenTelemetry active, it traces Kafka instead of Sentry's own Spring Kafka integration. */ +class KafkaOtelCoexistenceIntegrationTest : IntegrationTest() { + + @Test + fun `OpenTelemetry traces both sides of a message instead of Sentry's Kafka integration`() { + val message = "otel-coexistence-test" + assertThat(restTemplate.getForObject("/kafka/produce?message=$message", String::class.java)) + .isEqualTo("Message sent: $message") + + val producer = + server + .awaitEnvelope { + it.transaction()?.let { t -> + t.transaction == "GET /kafka/produce" && t.request?.queryString == "message=$message" + } == true + } + .transaction()!! + assertTracedByOpenTelemetry(producer) + + val traceId = producer.contexts.trace!!.traceId + val consumer = + server + .awaitEnvelope { + it.transaction()?.contexts?.trace?.let { trace -> + trace.operation == "queue.process" && trace.traceId == traceId + } == true + } + .transaction()!! + assertTracedByOpenTelemetry(consumer) + assertThat(consumer.contexts.trace!!.data).containsEntry("messaging.system", "kafka") + } + + private fun assertTracedByOpenTelemetry(transaction: SentryTransaction) { + assertThat(transaction.contexts.trace!!.origin).isEqualTo("auto.opentelemetry") + assertThat(transaction.sdk?.integrationSet.orEmpty()).doesNotContain("SpringKafka") + } +} diff --git a/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/PersonIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/PersonIntegrationTest.kt new file mode 100644 index 00000000000..25ead319e9c --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/PersonIntegrationTest.kt @@ -0,0 +1,146 @@ +package io.sentry.spring.boot.jakarta.otelnoagentintegrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.Sentry +import io.sentry.protocol.FeatureFlag +import io.sentry.protocol.SentryId +import io.sentry.protocol.SentryTransaction +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 +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders + +class PersonIntegrationTest : IntegrationTest() { + + @Test + fun `failed request reports errors, nested spans with their flags, 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("inner-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("inner-feature-flag", true)).isTrue() + + val transaction = + server.awaitEnvelope { it.transaction()?.transaction == "GET /person/{id}" }.transaction()!! + assertThat(transaction.hasOp("http.server")).isTrue() + assertThat( + transaction.hasSpanWithOp( + "personSpanThroughOtelAnnotation", + FeatureFlag("flag.evaluation.outer-feature-flag", true), + ) + ) + .isTrue() + assertThat( + transaction.hasSpanWithOp( + "spanCreatedThroughOtelApi", + FeatureFlag("flag.evaluation.inner-feature-flag", true), + ) + ) + .isTrue() + assertThat(transaction.hasSpanWithOp("spanCreatedThroughSentryApi", noFeatureFlags = 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 OpenTelemetry, Sentry and database spans`() { + assertCreatingPersonIsTraced(HttpHeaders()) + } + + @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 } + } + + @Test + fun `incoming trace without a sampled flag is traced`() { + val traceId = SentryId() + assertCreatingPersonIsTraced(traceHeaders("$traceId-424cffc8f94feeee", traceId), traceId) + } + + @Test + fun `incoming sampled trace is traced`() { + val traceId = SentryId() + assertCreatingPersonIsTraced(traceHeaders("$traceId-424cffc8f94feeee-1", traceId), traceId) + } + + private fun assertCreatingPersonIsTraced(headers: HttpHeaders, traceId: SentryId? = null) { + val response = + restTemplate.postForEntity( + "/person/", + 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") + + val transaction = awaitCreateTransaction(traceId) + assertThat(transaction.hasSpanWithOp("spanCreatedThroughOtelApi")).isTrue() + assertThat(transaction.hasSpanWithOp("spanCreatedThroughSentryApi")).isTrue() + assertThat( + transaction.hasSpanWithOpAndDescription( + "db", + "insert into person (firstName, lastName) values (?, ?)", + ) + ) + .isTrue() + } + + private fun awaitCreateTransaction(traceId: SentryId?): SentryTransaction = + server + .awaitEnvelope { + it.transaction()?.let { t -> + t.transaction == "POST /person/" && + (traceId == null || t.contexts.trace?.traceId == traceId) + } == true + } + .transaction()!! + + private fun traceHeaders(sentryTrace: String, traceId: SentryId): HttpHeaders = + HttpHeaders().apply { + set("sentry-trace", sentryTrace) + set( + "baggage", + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=1,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ) + } +} diff --git a/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/TodoController.kt b/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/TodoController.kt new file mode 100644 index 00000000000..8a8d8bb1d6c --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/TodoController.kt @@ -0,0 +1,76 @@ +package io.sentry.spring.boot.jakarta.otelnoagentintegrationtest + +import io.opentelemetry.api.trace.Tracer +import io.sentry.Sentry +import io.sentry.reactor.SentryReactorUtils +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.client.RestClient +import org.springframework.web.client.RestTemplate +import org.springframework.web.reactive.function.client.WebClient +import reactor.core.publisher.Hooks +import reactor.core.publisher.Mono +import reactor.core.scheduler.Schedulers + +open class Todo(var id: Long? = null, var title: String? = null, var completed: Boolean = false) + +/** Fetches a todo through each HTTP client that Sentry traces. */ +@RestController +open class TodoController( + private val restTemplate: RestTemplate, + private val webClient: WebClient, + private val restClient: RestClient, + private val tracer: Tracer, +) { + @GetMapping("/todo/{id}") + open fun todo(@PathVariable("id") id: Long): Todo? = + inSpans("todoSpanOtelApi", "todoSpanSentryApi") { + restTemplate.getForObject(todoUrl(id), Todo::class.java) + } + + /** Requests from another thread, which the Sentry scope has to follow through Reactor. */ + @GetMapping("/todo-webclient/{id}") + open fun todoWebClient(@PathVariable("id") id: Long): Todo? { + val url = todoUrl(id) + Hooks.enableAutomaticContextPropagation() + return SentryReactorUtils.withSentry( + Mono.just(true).publishOn(Schedulers.boundedElastic()).flatMap { + webClient.get().uri(url).retrieve().bodyToMono(Todo::class.java) + } + ) + .block() + } + + @GetMapping("/todo-restclient/{id}") + open fun todoRestClient(@PathVariable("id") id: Long): Todo? = + inSpans("todoRestClientSpanOtelApi", "todoRestClientSpanSentryApi") { + restClient.get().uri(todoUrl(id)).retrieve().body(Todo::class.java) + } + + /** Runs [block] in a span from the OpenTelemetry API and, inside it, one from the Sentry API. */ + private fun inSpans(otelSpanName: String, sentrySpanOp: String, block: () -> T): T { + val otelSpan = tracer.spanBuilder(otelSpanName).startSpan() + try { + otelSpan.makeCurrent().use { + val sentrySpan = Sentry.getSpan()!!.startChild(sentrySpanOp) + try { + return block() + } finally { + sentrySpan.finish() + } + } + } finally { + otelSpan.end() + } + } + + private fun todoUrl(id: Long): String = selfUrl("/stub/todos/$id") +} + +/** Stands in for the public API the sample fetches todos from, so tests don't need the internet. */ +@RestController +open class TodoStubController { + @GetMapping("/stub/todos/{id}") + open fun todo(@PathVariable("id") id: Long): Todo = Todo(id, "delectus aut autem") +} diff --git a/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/TodoIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/TodoIntegrationTest.kt new file mode 100644 index 00000000000..08cb9cdf69d --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/TodoIntegrationTest.kt @@ -0,0 +1,50 @@ +package io.sentry.spring.boot.jakarta.otelnoagentintegrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.protocol.SentryTransaction +import io.sentry.test.hasSpanWithOp +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 inside OpenTelemetry and Sentry spans`() { + val transaction = fetchTodo("/todo/1", "GET /todo/{id}") + assertThat(transaction.hasSpanWithOp("todoSpanOtelApi")).isTrue() + assertThat(transaction.hasSpanWithOp("todoSpanSentryApi")).isTrue() + } + + @Test + fun `WebClient request is traced`() { + fetchTodo("/todo-webclient/1", "GET /todo-webclient/{id}") + } + + @Test + fun `RestClient request is traced inside OpenTelemetry and Sentry spans`() { + val transaction = fetchTodo("/todo-restclient/1", "GET /todo-restclient/{id}") + assertThat(transaction.hasSpanWithOp("todoRestClientSpanOtelApi")).isTrue() + assertThat(transaction.hasSpanWithOp("todoRestClientSpanSentryApi")).isTrue() + } + + /** Fetches a todo through [path] and returns the request's transaction, with its client span. */ + private fun fetchTodo(path: String, transactionName: String): SentryTransaction { + 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() + return transaction + } +} diff --git a/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/resources/graphql/schema.graphqls b/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/resources/graphql/schema.graphqls new file mode 100644 index 00000000000..fc2a8fd4c27 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/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-jakarta/src/integrationTestOtelNoAgent/resources/schema.sql b/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/resources/schema.sql new file mode 100644 index 00000000000..7ca8a5cbf42 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/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 +); diff --git a/sentry-test-support/src/main/kotlin/io/sentry/test/MockSentryServer.kt b/sentry-test-support/src/main/kotlin/io/sentry/test/MockSentryServer.kt index 52cf59b52d1..158d282e388 100644 --- a/sentry-test-support/src/main/kotlin/io/sentry/test/MockSentryServer.kt +++ b/sentry-test-support/src/main/kotlin/io/sentry/test/MockSentryServer.kt @@ -20,8 +20,11 @@ import java.util.zip.GZIPInputStream * Envelopes travel through the real HTTP transport, so serialization, compression and the DSN are * covered too. It binds a free port so that test tasks running in parallel don't clash, and it * works for an app in a child process as well as in the test's own JVM. + * + * [beforeEachCheck] runs before each look at the received envelopes while waiting, for example to + * flush spans that a batching processor would otherwise hold for seconds. */ -class MockSentryServer : Closeable { +class MockSentryServer(private val beforeEachCheck: () -> Unit = {}) : Closeable { private val serializer = JsonSerializer(SentryOptions.empty()) private val received = CopyOnWriteArrayList() private val server = @@ -43,6 +46,7 @@ class MockSentryServer : Closeable { val deadline = Deadline.after(JavaMonotonicTicker.getInstance(), timeoutMillis, TimeUnit.MILLISECONDS) while (true) { + beforeEachCheck() received.firstOrNull(predicate)?.let { return it } @@ -64,6 +68,7 @@ class MockSentryServer : Closeable { fun assertNoEnvelope(millis: Long = 2_000, predicate: (SentryEnvelope) -> Boolean) { val deadline = Deadline.after(JavaMonotonicTicker.getInstance(), millis, TimeUnit.MILLISECONDS) while (true) { + beforeEachCheck() if (received.any(predicate)) { throw AssertionError("Received an envelope that should not have been sent") } diff --git a/sentry-test-support/src/test/kotlin/io/sentry/test/MockSentryServerTest.kt b/sentry-test-support/src/test/kotlin/io/sentry/test/MockSentryServerTest.kt index e52c78790e3..0f40f27e9af 100644 --- a/sentry-test-support/src/test/kotlin/io/sentry/test/MockSentryServerTest.kt +++ b/sentry-test-support/src/test/kotlin/io/sentry/test/MockSentryServerTest.kt @@ -32,6 +32,18 @@ class MockSentryServerTest { assertFailsWith { server.awaitEnvelope(timeoutMillis = 100) { true } } } + @Test + fun `beforeEachCheck runs while waiting`() { + var checks = 0 + MockSentryServer { checks++ } + .use { server -> + assertFailsWith { server.awaitEnvelope(timeoutMillis = 200) { true } } + server.assertNoEnvelope(millis = 200) { true } + } + + assertThat(checks).isGreaterThan(2) + } + @Test fun `assertNoEnvelope passes when nothing matches`() { initForTest { it.dsn = server.dsn } diff --git a/test/system-test-runner.py b/test/system-test-runner.py index f64c28b5c24..a0fa218805f 100644 --- a/test/system-test-runner.py +++ b/test/system-test-runner.py @@ -73,7 +73,6 @@ "sentry-samples-spring-boot-opentelemetry", "sentry-samples-spring-boot-opentelemetry-noagent", "sentry-samples-spring-boot-jakarta-opentelemetry", - "sentry-samples-spring-boot-jakarta-opentelemetry-noagent", "sentry-samples-spring-boot-4-opentelemetry", "sentry-samples-spring-boot-4-opentelemetry-noagent", } @@ -81,7 +80,6 @@ "sentry-samples-spring-boot-opentelemetry", "sentry-samples-spring-boot-opentelemetry-noagent", "sentry-samples-spring-boot-jakarta-opentelemetry", - "sentry-samples-spring-boot-jakarta-opentelemetry-noagent", "sentry-samples-spring-boot-4-opentelemetry", "sentry-samples-spring-boot-4-opentelemetry-noagent", } @@ -846,7 +844,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-jakarta-opentelemetry-noagent", "false", "true", "false"), ModuleConfig("sentry-samples-spring-boot-jakarta-opentelemetry", "true", "true", "false"), ModuleConfig("sentry-samples-spring-boot-jakarta-opentelemetry", "true", "false", "false"), ModuleConfig("sentry-samples-spring-boot-4-opentelemetry-noagent", "false", "true", "false"), From 7b549c5913b1a5e282fd5ed291111fdc736c3b72 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 20 Oct 2026 18:01:07 +0200 Subject: [PATCH 2/2] test(spring-boot): Rejoin a wrapped comment in the Boot 3 OTel no-agent suite Co-Authored-By: Claude Opus 5.5 (1M context) --- .../boot/jakarta/otelnoagentintegrationtest/IntegrationTest.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/IntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/IntegrationTest.kt index 742402ce24c..092c1824bb6 100644 --- a/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/IntegrationTest.kt +++ b/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/IntegrationTest.kt @@ -54,8 +54,7 @@ abstract class IntegrationTest { @LocalServerPort private var port = 0 // The context's TestRestTemplate is instrumented by the OpenTelemetry starter, which would - // replace - // the trace headers tests send with its own, so tests use one built outside the context. + // replace the trace headers tests send with its own, so tests use one built outside the context. val restTemplate: TestRestTemplate by lazy { TestRestTemplate(RestTemplateBuilder().rootUri("http://localhost:$port")) }