From c5b9d16b3839bb390c0739a8a51c2471e169e845 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 22 Oct 2026 14:38:06 +0200 Subject: [PATCH 1/2] test(spring-boot): Move the Boot 4 OTLP sample's tests into the module Adds an integrationTestOtlp suite to sentry-spring-boot-4: the servlet app with the OpenTelemetry Spring Boot starter, set up by sentry-opentelemetry-otlp-spring, and OpenTelemetryOtlpEventProcessor. It ports the sample's tests, which were the servlet suite's without OkHttp, and keeps the log and metric attribute checks the sample had dropped. The sample's transactions came from Sentry's own tracing, which CI enabled with SENTRY_TRACES_SAMPLE_RATE, so the suite sets the sample rate the same way. A new test checks what OTLP mode is for: an error in a request carries the request's OpenTelemetry trace. Events point at that trace rather than at Sentry's transaction, so the GraphQL errors are checked next to their transactions instead of by trace ID, as the sample did. The sample has no system tests left, so it comes off the system-test runner. It was the backend workflow's last sample, so the workflow goes too. The Spring Boot 4 matrix and integrationTests run the new suite. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/spring-boot-4-matrix.yml | 3 +- .github/workflows/system-tests-backend.yml | 78 ------- build.gradle.kts | 1 + .../build.gradle.kts | 32 --- .../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 | 50 ----- .../io/sentry/systemtest/MetricsSystemTest.kt | 49 ----- .../io/sentry/systemtest/PersonSystemTest.kt | 96 --------- .../io/sentry/systemtest/TodoSystemTest.kt | 61 ------ .../src/test/resources/logback.xml | 17 -- sentry-spring-boot-4/build.gradle.kts | 34 +++ .../otlpintegrationtest/CacheController.kt | 43 ++++ .../CacheIntegrationTest.kt | 35 ++++ .../DistributedTracingController.kt | 22 ++ .../DistributedTracingIntegrationTest.kt | 137 ++++++++++++ .../otlpintegrationtest/GraphqlControllers.kt | 83 ++++++++ .../GraphqlIntegrationTest.kt | 107 ++++++++++ .../otlpintegrationtest/IntegrationTest.kt | 68 ++++++ .../IntegrationTestApplication.kt | 99 +++++++++ .../otlpintegrationtest/MetricController.kt | 33 +++ .../MetricsIntegrationTest.kt | 50 +++++ .../PersonIntegrationTest.kt | 129 ++++++++++++ .../otlpintegrationtest/TodoController.kt | 52 +++++ .../TodoIntegrationTest.kt | 42 ++++ .../resources/graphql/schema.graphqls | 38 ++++ .../integrationTestOtlp/resources/schema.sql | 5 + test/system-test-runner.py | 1 - 31 files changed, 980 insertions(+), 757 deletions(-) delete mode 100644 .github/workflows/system-tests-backend.yml delete mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/DummyTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/resources/logback.xml create mode 100644 sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/CacheController.kt create mode 100644 sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/CacheIntegrationTest.kt create mode 100644 sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/DistributedTracingController.kt create mode 100644 sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/DistributedTracingIntegrationTest.kt create mode 100644 sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/GraphqlControllers.kt create mode 100644 sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/GraphqlIntegrationTest.kt create mode 100644 sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/IntegrationTest.kt create mode 100644 sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/IntegrationTestApplication.kt create mode 100644 sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/MetricController.kt create mode 100644 sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/MetricsIntegrationTest.kt create mode 100644 sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/PersonIntegrationTest.kt create mode 100644 sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/TodoController.kt create mode 100644 sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/TodoIntegrationTest.kt create mode 100644 sentry-spring-boot-4/src/integrationTestOtlp/resources/graphql/schema.graphqls create mode 100644 sentry-spring-boot-4/src/integrationTestOtlp/resources/schema.sql diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index 5c197a243b9..8f91289db98 100644 --- a/.github/workflows/spring-boot-4-matrix.yml +++ b/.github/workflows/spring-boot-4-matrix.yml @@ -66,7 +66,8 @@ jobs: :sentry-spring-boot-4:integrationTestOtelNoAgent \ :sentry-spring-boot-4:integrationTestLog4j2 \ :sentry-spring-boot-4:integrationTestOtelAgent \ - :sentry-spring-boot-4:integrationTestOtelAgentWithoutAutoInit + :sentry-spring-boot-4:integrationTestOtelAgentWithoutAutoInit \ + :sentry-spring-boot-4:integrationTestOtlp - name: Upload test results if: always() diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml deleted file mode 100644 index 0212b0f58da..00000000000 --- a/.github/workflows/system-tests-backend.yml +++ /dev/null @@ -1,78 +0,0 @@ -name: 'System Tests Backend' - -on: - push: - branches: - - main - pull_request: - paths-ignore: - - '*android*/**' - - 'sentry-compose/**' - - 'sentry-samples/sentry-samples-android/**' - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - system-test: - runs-on: ubuntu-latest - continue-on-error: true - env: - SENTRY_URL: http://127.0.0.1:8000 - GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - strategy: - fail-fast: false - matrix: - sample: [ "sentry-samples-spring-boot-4-otlp" ] - agent: [ "false" ] - agent-auto-init: [ "true" ] - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - submodules: 'recursive' - - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: '3.10' - - - name: Install Python dependencies - run: | - python3 -m pip install --upgrade pip - python3 -m pip install -r requirements.txt - - - name: Set up Java - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 - with: - distribution: 'temurin' - java-version: '21' - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 - with: - cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - - - name: Build and run system tests - run: | - python3 test/system-test-runner.py test --module "${{ matrix.sample }}" --agent "${{ matrix.agent }}" --auto-init "${{ matrix.agent-auto-init }}" --build "true" - - - name: Upload test results - if: always() - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 - with: - name: test-results-${{ matrix.sample }}-${{ matrix.agent }}-${{ matrix.agent-auto-init }}-system-test - path: | - **/build/reports/* - sentry-mock-server.txt - spring-server.txt - - - name: Test Report - uses: phoenix-actions/test-reporting@7317eea6e13c47348dd0bb318669485157c518d6 # pin@v16 - if: always() - with: - name: JUnit System Tests ${{ matrix.sample }} - path: | - **/build/test-results/**/*.xml - reporter: java-junit - output-to: step-summary - fail-on-error: false diff --git a/build.gradle.kts b/build.gradle.kts index 44b9ffc7f5e..6e370db2c52 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -240,6 +240,7 @@ tasks.register("integrationTests") { ":sentry-spring-boot-4:integrationTestLog4j2", ":sentry-spring-boot-4:integrationTestOtelAgent", ":sentry-spring-boot-4:integrationTestOtelAgentWithoutAutoInit", + ":sentry-spring-boot-4:integrationTestOtlp", ":sentry-spring:integrationTest", ":sentry-spring-jakarta:integrationTest", ":sentry-spring-7:integrationTest", diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts index c5bc2b96fb3..bad23b8167a 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts @@ -6,7 +6,6 @@ plugins { alias(libs.plugins.spring.dependency.management) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) - id("io.sentry.systemtest") } group = "io.sentry.sample.spring-boot-4-otlp" @@ -54,37 +53,6 @@ dependencies { // database query tracing implementation(projects.sentryJdbc) runtimeOnly(libs.hsqldb) - - testImplementation(kotlin(Config.kotlinStdLib)) - testImplementation(projects.sentry) - testImplementation(projects.sentrySystemTestSupport) - testImplementation(libs.apollo3.kotlin) - testImplementation(libs.kotlin.test.junit) - testImplementation(libs.slf4j2.api) - testImplementation(libs.springboot4.starter.test) { - exclude(group = "org.junit.vintage", module = "junit-vintage-engine") - } - testImplementation("ch.qos.logback:logback-classic:1.5.16") - testImplementation("ch.qos.logback:logback-core:1.5.16") } dependencyManagement { imports { mavenBom(libs.otel.instrumentation.bom.get().toString()) } } - -tasks.register("systemTest").configure { - group = "verification" - description = "Runs the System tests" - - val test = project.extensions.getByType()["test"] - testClassesDirs = test.output.classesDirs - classpath = test.runtimeClasspath - - maxParallelForks = 1 - - filter { includeTestsMatching("io.sentry.systemtest*") } -} - -tasks.named("test").configure { - require(this is Test) - - filter { excludeTestsMatching("io.sentry.systemtest.*") } -} diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/DummyTest.kt b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/DummyTest.kt deleted file mode 100644 index 6f762b7e453..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/DummyTest.kt +++ /dev/null @@ -1,12 +0,0 @@ -package io.sentry - -import kotlin.test.Test -import kotlin.test.assertTrue - -class DummyTest { - @Test - fun `the only test`() { - // only needed to have more than 0 tests and not fail the build - assertTrue(true) - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt deleted file mode 100644 index b45e9c10853..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4-otlp/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-otlp/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt deleted file mode 100644 index 3cd16003024..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4-otlp/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-otlp/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt deleted file mode 100644 index 76a6024decc..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4-otlp/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-otlp/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt deleted file mode 100644 index fca3956717c..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4-otlp/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-otlp/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt deleted file mode 100644 index 940709c0778..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4-otlp/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-otlp/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt deleted file mode 100644 index dc2ca2a10ae..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt +++ /dev/null @@ -1,49 +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) - } - } - - @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-otlp/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt deleted file mode 100644 index 362a8577148..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ /dev/null @@ -1,96 +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!") - } - } - - @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-otlp/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt deleted file mode 100644 index d34485e1388..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt +++ /dev/null @@ -1,61 +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", - ) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/resources/logback.xml b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/resources/logback.xml deleted file mode 100644 index a36b8f80f76..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/resources/logback.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - %-4relative [%thread] %-5level %logger{35} -%kvp- %msg %n - - - - - - - diff --git a/sentry-spring-boot-4/build.gradle.kts b/sentry-spring-boot-4/build.gradle.kts index 79f252125d3..7f4428551b2 100644 --- a/sentry-spring-boot-4/build.gradle.kts +++ b/sentry-spring-boot-4/build.gradle.kts @@ -348,6 +348,39 @@ testing { } targets.all { testTask.configure { runAgentFixture(autoInit = true) } } } + + // The servlet app with the OpenTelemetry Spring Boot starter, set up by + // sentry-opentelemetry-otlp-spring to export traces over OTLP while Sentry handles the rest. + register("integrationTestOtlp") { + useJUnit() + dependencies { + implementation(platform(SpringBootPlugin.BOM_COORDINATES)) + implementation(projects.sentrySpringBoot4Starter) + implementation(projects.sentryOpentelemetry.sentryOpentelemetryOtlpSpring) + implementation(projects.sentryAsyncProfiler) + implementation(projects.sentryGraphql22) + implementation(projects.sentryJdbc) + 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.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) + } + } } } @@ -385,6 +418,7 @@ listOf( "compileIntegrationTestWebfluxKotlin", "compileIntegrationTestOtelNoAgentKotlin", "compileAgentFixtureKotlin", + "compileIntegrationTestOtlpKotlin", ) .forEach { tasks.named(it) { compilerOptions.javaParameters = true } diff --git a/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/CacheController.kt b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/CacheController.kt new file mode 100644 index 00000000000..c401a20506f --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/CacheController.kt @@ -0,0 +1,43 @@ +package io.sentry.spring.boot4.otlpintegrationtest + +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/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/CacheIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/CacheIntegrationTest.kt new file mode 100644 index 00000000000..b0bbf4d08dd --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/CacheIntegrationTest.kt @@ -0,0 +1,35 @@ +package io.sentry.spring.boot4.otlpintegrationtest + +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/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/DistributedTracingController.kt b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/DistributedTracingController.kt new file mode 100644 index 00000000000..6f2066006b6 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/DistributedTracingController.kt @@ -0,0 +1,22 @@ +package io.sentry.spring.boot4.otlpintegrationtest + +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/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/DistributedTracingIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/DistributedTracingIntegrationTest.kt new file mode 100644 index 00000000000..cff4be45a61 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/DistributedTracingIntegrationTest.kt @@ -0,0 +1,137 @@ +package io.sentry.spring.boot4.otlpintegrationtest + +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/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/GraphqlControllers.kt b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/GraphqlControllers.kt new file mode 100644 index 00000000000..7d3375453d2 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/GraphqlControllers.kt @@ -0,0 +1,83 @@ +package io.sentry.spring.boot4.otlpintegrationtest + +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/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/GraphqlIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/GraphqlIntegrationTest.kt new file mode 100644 index 00000000000..810e4339509 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/GraphqlIntegrationTest.kt @@ -0,0 +1,107 @@ +package io.sentry.spring.boot4.otlpintegrationtest + +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 and the query traced`() { + val response = graphql("""query { greeting(name: "crash") }""") + + assertThat(response["errors"].size()).isEqualTo(1) + awaitError("causing an error for crash") + awaitTransactionWithSpan("Query.greeting") + } + + @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 and the mutation traced`() { + val response = graphql("""mutation { addProject(slug: "addprojectcrash") }""") + + assertThat(response["errors"].size()).isEqualTo(1) + assertThat(response.path("data").path("addProject").asString(null)).isNull() + awaitError("causing a project add error for addprojectcrash") + awaitTransactionWithSpan("Mutation.addProject") + } + + @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()!! + + /** + * Waits for the error for [exceptionMessage]. It points at the OpenTelemetry trace rather than at + * Sentry's transaction (see PersonIntegrationTest), so it can't be tied to the transaction. + */ + private fun awaitError(exceptionMessage: String) { + server.awaitEnvelope { envelope -> + envelope.event()?.let { event -> + event.message?.message?.startsWith("Unresolved RuntimeException for executionId ") == + true && event.exceptions?.any { it.value == exceptionMessage } == true + } == true + } + } +} diff --git a/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/IntegrationTest.kt b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/IntegrationTest.kt new file mode 100644 index 00000000000..e944d2ba3d4 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/IntegrationTest.kt @@ -0,0 +1,68 @@ +package io.sentry.spring.boot4.otlpintegrationtest + +import io.sentry.test.MockSentryServer +import org.junit.Before +import org.junit.runner.RunWith +import org.springframework.boot.restclient.RestTemplateBuilder +import org.springframework.boot.resttestclient.TestRestTemplate +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 + +/** + * Drives [IntegrationTestApplication] over HTTP and asserts what reaches [server]. The app has the + * OpenTelemetry Spring Boot starter, which would export its spans over OTLP, and Sentry handles the + * rest, as set up by sentry-opentelemetry-otlp-spring. + * + * Subclasses share one application context and one server, so the app starts once per suite. + * Envelopes that arrive late can outlive [server]'s reset, so tests match on values only they + * produce. + */ +@RunWith(SpringRunner::class) +@SpringBootTest( + classes = [IntegrationTestApplication::class], + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = + [ + // Sentry also traces, as the sample did in CI with SENTRY_TRACES_SAMPLE_RATE, so the tests + // can check its transactions next to OpenTelemetry. + "sentry.traces-sample-rate=1.0", + "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", + "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")) + } + + @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/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/IntegrationTestApplication.kt b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/IntegrationTestApplication.kt new file mode 100644 index 00000000000..0f82a35c00e --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/IntegrationTestApplication.kt @@ -0,0 +1,99 @@ +package io.sentry.spring.boot4.otlpintegrationtest + +import io.sentry.Sentry +import io.sentry.SentryOptions +import io.sentry.opentelemetry.otlp.OpenTelemetryOtlpEventProcessor +import io.sentry.spring7.tracing.SentrySpan +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() + + // Links events to the OpenTelemetry span that's current when they happen. + @Bean + fun otlpEventProcessor() = + Sentry.OptionsConfiguration { + it.addEventProcessor(OpenTelemetryOtlpEventProcessor()) + } +} + +/** + * 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/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/MetricController.kt b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/MetricController.kt new file mode 100644 index 00000000000..96771413aa4 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/MetricController.kt @@ -0,0 +1,33 @@ +package io.sentry.spring.boot4.otlpintegrationtest + +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/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/MetricsIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/MetricsIntegrationTest.kt new file mode 100644 index 00000000000..074f4e022cb --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/MetricsIntegrationTest.kt @@ -0,0 +1,50 @@ +package io.sentry.spring.boot4.otlpintegrationtest + +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/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/PersonIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/PersonIntegrationTest.kt new file mode 100644 index 00000000000..3c0e5ab9feb --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/PersonIntegrationTest.kt @@ -0,0 +1,129 @@ +package io.sentry.spring.boot4.otlpintegrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.Sentry +import io.sentry.protocol.FeatureFlag +import io.sentry.protocol.SentryId +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 +import org.springframework.http.HttpMethod + +class PersonIntegrationTest : IntegrationTest() { + + @Test + fun `error in a request is linked to the request's OpenTelemetry trace`() { + val traceId = SentryId().toString() + val headers = HttpHeaders().apply { set("traceparent", "00-$traceId-00f067aa0ba902b7-01") } + val response = + restTemplate.exchange( + "/person/2", + HttpMethod.GET, + HttpEntity(headers), + String::class.java, + ) + assertThat(response.statusCode.value()).isEqualTo(500) + Sentry.flush(5000) + + // OpenTelemetry continues the incoming trace, and OpenTelemetryOtlpEventProcessor puts its span + // on + // the event, so the error points at the trace that OTLP exports. + server.awaitEnvelope { + it.event()?.let { event -> + event.exceptions?.any { e -> e.value == "Something went wrong [id=2]" } == true && + event.contexts.trace?.traceId?.toString() == traceId + } == true + } + } + + @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/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/TodoController.kt b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/TodoController.kt new file mode 100644 index 00000000000..08c23022f73 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/TodoController.kt @@ -0,0 +1,52 @@ +package io.sentry.spring.boot4.otlpintegrationtest + +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 + +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, +) { + @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) + + 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/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/TodoIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/TodoIntegrationTest.kt new file mode 100644 index 00000000000..b2a158aa13c --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/TodoIntegrationTest.kt @@ -0,0 +1,42 @@ +package io.sentry.spring.boot4.otlpintegrationtest + +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}") + } + + 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/integrationTestOtlp/resources/graphql/schema.graphqls b/sentry-spring-boot-4/src/integrationTestOtlp/resources/graphql/schema.graphqls new file mode 100644 index 00000000000..fc2a8fd4c27 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestOtlp/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/integrationTestOtlp/resources/schema.sql b/sentry-spring-boot-4/src/integrationTestOtlp/resources/schema.sql new file mode 100644 index 00000000000..7ca8a5cbf42 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestOtlp/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/test/system-test-runner.py b/test/system-test-runner.py index 6115fa61680..bdd6608095c 100644 --- a/test/system-test-runner.py +++ b/test/system-test-runner.py @@ -825,7 +825,6 @@ def run_manual_test_mode(self, sample_module: str, java_agent: str, def get_available_modules(self) -> List[ModuleConfig]: """Get list of all available test modules.""" return [ - ModuleConfig("sentry-samples-spring-boot-4-otlp", "false", "true", "false"), ] def _find_module_number(self, module_name: str, agent: str, auto_init: str) -> int: From a2231d9ba57ff10100c648a3d082199cdd5ebb9d Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 20 Oct 2026 21:12:55 +0200 Subject: [PATCH 2/2] Merge the audit fixes from the branches below, and port them to the Boot 4 OTLP suite Co-Authored-By: Claude Opus 5.5 (1M context) --- .../DistributedTracingIntegrationTest.kt | 23 ++++++++++++++----- .../GraphqlIntegrationTest.kt | 2 ++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/DistributedTracingIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/DistributedTracingIntegrationTest.kt index cff4be45a61..468f05fe2de 100644 --- a/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/DistributedTracingIntegrationTest.kt +++ b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/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/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/GraphqlIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/GraphqlIntegrationTest.kt index 810e4339509..b6ebc1e18f6 100644 --- a/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/GraphqlIntegrationTest.kt +++ b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/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")