From f9b8fdc07e1475425c8c18a51152b3c02717dfcd Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 20 Oct 2026 19:25:29 +0200 Subject: [PATCH 1/3] test(spring): Move the Spring 7 WAR sample's tests into the module Adds sentry-spring-jakarta's embedded-Tomcat integrationTest suite to sentry-spring-7, with the module's packages. The sample's tests were the jakarta WAR sample's, so the suite is too. Spring Boot 4's BOM picks Spring 7 and Tomcat 11. The sample has no system tests left, so it comes off the system-test runner, the backend workflow and the Spring Boot 4 matrix. That was the matrix's last runner step, so it drops the Python setup and the runner's logs too. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/spring-boot-4-matrix.yml | 29 +------ .github/workflows/system-tests-backend.yml | 3 - .../sentry-samples-spring-7/build.gradle.kts | 26 ------ .../src/test/kotlin/io/sentry/DummyTest.kt | 12 --- .../io/sentry/systemtest/MetricsSystemTest.kt | 51 ----------- .../io/sentry/systemtest/PersonSystemTest.kt | 85 ------------------- sentry-spring-7/build.gradle.kts | 22 +++++ .../integrationtest/IntegrationTest.kt | 72 ++++++++++++++++ .../IntegrationTestApplication.kt | 66 ++++++++++++++ .../integrationtest/MetricController.kt | 33 +++++++ .../integrationtest/MetricsIntegrationTest.kt | 50 +++++++++++ .../integrationtest/PersonIntegrationTest.kt | 64 ++++++++++++++ .../resources/sentry.properties | 4 + test/system-test-runner.py | 1 - 14 files changed, 313 insertions(+), 205 deletions(-) delete mode 100644 sentry-samples/sentry-samples-spring-7/src/test/kotlin/io/sentry/DummyTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-7/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-7/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt create mode 100644 sentry-spring-7/src/integrationTest/kotlin/io/sentry/spring7/integrationtest/IntegrationTest.kt create mode 100644 sentry-spring-7/src/integrationTest/kotlin/io/sentry/spring7/integrationtest/IntegrationTestApplication.kt create mode 100644 sentry-spring-7/src/integrationTest/kotlin/io/sentry/spring7/integrationtest/MetricController.kt create mode 100644 sentry-spring-7/src/integrationTest/kotlin/io/sentry/spring7/integrationtest/MetricsIntegrationTest.kt create mode 100644 sentry-spring-7/src/integrationTest/kotlin/io/sentry/spring7/integrationtest/PersonIntegrationTest.kt create mode 100644 sentry-spring-7/src/integrationTest/resources/sentry.properties diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index 2fd453e0da4..5c197a243b9 100644 --- a/.github/workflows/spring-boot-4-matrix.yml +++ b/.github/workflows/spring-boot-4-matrix.yml @@ -25,7 +25,6 @@ jobs: name: Spring Boot ${{ matrix.springboot-version }} env: - SENTRY_URL: http://127.0.0.1:8000 GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} steps: @@ -34,16 +33,6 @@ jobs: with: submodules: 'recursive' - - name: Set up Python - 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: @@ -68,15 +57,10 @@ jobs: perl -0pi -e 'BEGIN { $v = shift } s/^springboot4[[:space:]]*=[[:space:]]*"\K[^"]*/$v/m or die "::error::springboot4 version entry not found in gradle/libs.versions.toml\n"' "$springboot_version" gradle/libs.versions.toml echo "Updated Spring Boot 4.x version to $springboot_version" - - name: Build sample artifacts - run: | - ./gradlew \ - :sentry-samples:sentry-samples-spring-7:war \ - :sentry-samples:sentry-samples-spring-7:testClasses - - - name: Test sentry-spring-boot-4 integration suites + - name: Test integration suites run: | ./gradlew \ + :sentry-spring-7:integrationTest \ :sentry-spring-boot-4:integrationTest \ :sentry-spring-boot-4:integrationTestWebflux \ :sentry-spring-boot-4:integrationTestOtelNoAgent \ @@ -84,13 +68,6 @@ jobs: :sentry-spring-boot-4:integrationTestOtelAgent \ :sentry-spring-boot-4:integrationTestOtelAgentWithoutAutoInit - - name: Run sentry-samples-spring-7 - run: | - python3 test/system-test-runner.py test \ - --module "sentry-samples-spring-7" \ - --agent false \ - --auto-init "true" - - name: Upload test results if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 @@ -99,8 +76,6 @@ jobs: path: | **/build/reports/* **/build/test-results/**/*.xml - sentry-mock-server.txt - spring-server.txt - name: Test Report uses: phoenix-actions/test-reporting@7317eea6e13c47348dd0bb318669485157c518d6 # pin@v16 diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index 9f2f6b61d2d..36b03cca255 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -40,9 +40,6 @@ jobs: - sample: "sentry-samples-spring-boot-4-otlp" agent: "false" agent-auto-init: "true" - - sample: "sentry-samples-spring-7" - agent: "false" - agent-auto-init: "true" - sample: "sentry-samples-spring" agent: "false" agent-auto-init: "true" diff --git a/sentry-samples/sentry-samples-spring-7/build.gradle.kts b/sentry-samples/sentry-samples-spring-7/build.gradle.kts index 82a18d86c73..83017cba374 100644 --- a/sentry-samples/sentry-samples-spring-7/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-7/build.gradle.kts @@ -10,7 +10,6 @@ plugins { alias(libs.plugins.kotlin.spring) id("war") alias(libs.plugins.gretty) - id("io.sentry.systemtest") } application { mainClass.set("io.sentry.samples.spring7.Main") } @@ -47,12 +46,6 @@ dependencies { implementation(libs.tomcat.catalina.jakarta) implementation(libs.tomcat.embed.jasper.jakarta) - - testImplementation(projects.sentrySystemTestSupport) - testImplementation(libs.kotlin.test.junit) - testImplementation(libs.springboot4.starter.test) { - exclude(group = "org.junit.vintage", module = "junit-vintage-engine") - } } tasks.withType().configureEach { @@ -65,22 +58,3 @@ tasks.withType().configureEach { compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } } - -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-7/src/test/kotlin/io/sentry/DummyTest.kt b/sentry-samples/sentry-samples-spring-7/src/test/kotlin/io/sentry/DummyTest.kt deleted file mode 100644 index 6f762b7e453..00000000000 --- a/sentry-samples/sentry-samples-spring-7/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-7/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt b/sentry-samples/sentry-samples-spring-7/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt deleted file mode 100644 index 0091de63008..00000000000 --- a/sentry-samples/sentry-samples-spring-7/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/sentry-samples-spring-7-0.0.1-SNAPSHOT") - 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-7/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-7/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt deleted file mode 100644 index f0d6bd177e8..00000000000 --- a/sentry-samples/sentry-samples-spring-7/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ /dev/null @@ -1,85 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.protocol.SentryId -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import org.junit.Before - -class PersonSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080/sentry-samples-spring-7-0.0.1-SNAPSHOT") - testHelper.reset() - } - - @Test - fun `get person fails`() { - val restClient = testHelper.restClient - restClient.getPerson(11L) - assertEquals(500, restClient.lastKnownStatusCode) - - testHelper.ensureErrorReceived { event -> - testHelper.doesEventHaveExceptionMessage(event, "Something went wrong [id=11]") && - testHelper.doesEventHaveFlag(event, "my-feature-flag", true) - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionHaveOp(transaction, "http.server") - } - - Thread.sleep(10000) - - testHelper.ensureLogsReceived { logs, envelopeHeader -> - testHelper.doesContainLogWithBody(logs, "warn Sentry logging") && - testHelper.doesContainLogWithBody(logs, "error Sentry logging") && - testHelper.doesContainLogWithBody(logs, "hello there world!") && - testHelper.doesLogWithBodyHaveAttribute( - logs, - "warn Sentry logging", - "user.type", - "admin", - ) && - testHelper.doesLogWithBodyHaveAttribute( - logs, - "warn Sentry logging", - "feature.version", - 2, - ) && - testHelper.doesLogWithBodyHaveAttribute(logs, "warn Sentry logging", "debug.enabled", true) - } - } - - @Test - fun `create person works`() { - val restClient = testHelper.restClient - val person = Person("firstA", "lastB") - val returnedPerson = restClient.createPerson(person) - assertEquals(200, restClient.lastKnownStatusCode) - - assertEquals(person.firstName, returnedPerson!!.firstName) - assertEquals(person.lastName, returnedPerson!!.lastName) - } - - @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-spring-7/build.gradle.kts b/sentry-spring-7/build.gradle.kts index c848c9416a0..b9d418abe4d 100644 --- a/sentry-spring-7/build.gradle.kts +++ b/sentry-spring-7/build.gradle.kts @@ -120,3 +120,25 @@ kotlin { freeCompilerArgs.add("-Xskip-metadata-version-check") } } + +testing { + suites { + // Runs a plain Spring MVC app in an embedded Tomcat against MockSentryServer, the way a servlet + // container runs a WAR. Spring Boot isn't on its classpath, because Sentry behaves differently + // with it. + register("integrationTest") { + useJUnit() + dependencies { + implementation(platform(SpringBootPlugin.BOM_COORDINATES)) + implementation(project()) + implementation(projects.sentryAsyncProfiler) + implementation(projects.sentryTestSupport) + implementation(Config.Libs.springWeb) + implementation(libs.jackson.databind) + implementation(libs.kotlin.test.junit) + implementation(libs.google.truth) + implementation(libs.tomcat.embed.core) + } + } + } +} diff --git a/sentry-spring-7/src/integrationTest/kotlin/io/sentry/spring7/integrationtest/IntegrationTest.kt b/sentry-spring-7/src/integrationTest/kotlin/io/sentry/spring7/integrationtest/IntegrationTest.kt new file mode 100644 index 00000000000..4ea42572521 --- /dev/null +++ b/sentry-spring-7/src/integrationTest/kotlin/io/sentry/spring7/integrationtest/IntegrationTest.kt @@ -0,0 +1,72 @@ +package io.sentry.spring7.integrationtest + +import io.sentry.test.MockSentryServer +import jakarta.servlet.ServletContainerInitializer +import java.nio.file.Files +import java.util.ServiceLoader +import org.apache.catalina.startup.Tomcat +import org.junit.Before +import org.springframework.http.client.ClientHttpResponse +import org.springframework.web.client.DefaultResponseErrorHandler +import org.springframework.web.client.RestTemplate +import org.springframework.web.util.DefaultUriBuilderFactory + +/** + * Drives [AppInitializer]'s app over HTTP and asserts what reaches [server]. + * + * Subclasses share one app and one server. Envelopes that arrive late can outlive [server]'s reset, + * so tests match on values only they produce. + */ +abstract class IntegrationTest { + val server = TomcatApp.server + val restTemplate = TomcatApp.restTemplate + + @Before + fun resetServer() { + server.reset() + } +} + +/** + * The app in an embedded Tomcat, with no Spring Boot on the classpath, so Sentry takes the same + * path as in a WAR. Tomcat starts once per test JVM. + */ +object TomcatApp { + val server = MockSentryServer() + + init { + System.setProperty("sentry.dsn", server.dsn) + // The profiler rejects a traces path with a dot anywhere in it, which the module's absolute + // path can have. + System.setProperty( + "sentry.profiling-traces-dir-path", + "${System.getProperty("java.io.tmpdir")}/sentry-integration-test/profiling-traces", + ) + } + + private val tomcat = + Tomcat().apply { + setBaseDir(Files.createTempDirectory("tomcat").toString()) + setPort(0) + // Creates the default connector. + connector + val context = addContext("", Files.createTempDirectory("docbase").toString()) + // As Tomcat does for a WAR, runs each initializer the classpath registers: Spring's starts + // AppInitializer's app, and Sentry's adds SentrySpringFilter. Only Spring's uses the set. + ServiceLoader.load(ServletContainerInitializer::class.java).forEach { + context.addServletContainerInitializer(it, setOf(AppInitializer::class.java)) + } + start() + } + + val restTemplate = + RestTemplate().apply { + uriTemplateHandler = + DefaultUriBuilderFactory("http://localhost:${tomcat.connector.localPort}") + // Returns error responses rather than throwing them, so tests can check a failed request. + errorHandler = + object : DefaultResponseErrorHandler() { + override fun hasError(response: ClientHttpResponse): Boolean = false + } + } +} diff --git a/sentry-spring-7/src/integrationTest/kotlin/io/sentry/spring7/integrationtest/IntegrationTestApplication.kt b/sentry-spring-7/src/integrationTest/kotlin/io/sentry/spring7/integrationtest/IntegrationTestApplication.kt new file mode 100644 index 00000000000..251d3b01899 --- /dev/null +++ b/sentry-spring-7/src/integrationTest/kotlin/io/sentry/spring7/integrationtest/IntegrationTestApplication.kt @@ -0,0 +1,66 @@ +package io.sentry.spring7.integrationtest + +import io.sentry.Sentry +import io.sentry.spring7.EnableSentry +import io.sentry.spring7.tracing.SentryTracingFilter +import jakarta.servlet.Filter +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +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.servlet.config.annotation.EnableWebMvc +import org.springframework.web.servlet.support.AbstractAnnotationConfigDispatcherServletInitializer + +// Classes and methods are open because Spring proxies them and this module doesn't apply the +// kotlin-spring plugin. + +/** Sets the app up the way a WAR does, for the servlet container to start. */ +class AppInitializer : AbstractAnnotationConfigDispatcherServletInitializer() { + override fun getRootConfigClasses(): Array> = arrayOf(AppConfig::class.java) + + override fun getServletConfigClasses(): Array> = arrayOf(WebConfig::class.java) + + override fun getServletMappings(): Array = arrayOf("/*") + + // Creates Sentry transactions around incoming requests. + override fun getServletFilters(): Array = arrayOf(SentryTracingFilter()) +} + +// The DSN and the traces path come from system properties, and the other options from +// sentry.properties, all of which Sentry reads as external configuration. +@Configuration @EnableSentry(dsn = "", sendDefaultPii = true) open class AppConfig + +// Declares the controllers rather than scanning for them, which would also pick up AppConfig and +// initialize Sentry a second time in the servlet's context. +@Configuration +@EnableWebMvc +open class WebConfig { + @Bean open fun personController() = PersonController() + + @Bean open fun metricController() = MetricController() +} + +open class Person(var firstName: String? = null, var lastName: String? = null) + +@RestController +@RequestMapping("/person/") +open class PersonController { + /** Reports to Sentry through every API an app uses, then fails the request. */ + @GetMapping("{id}") + open fun person(@PathVariable("id") id: Long): Person { + Sentry.setAttribute("user.type", "admin") + Sentry.setAttribute("feature.version", 2) + Sentry.setAttribute("debug.enabled", true) + Sentry.logger().warn("warn Sentry logging") + Sentry.logger().error("error Sentry logging") + Sentry.logger().info("hello %s %s", "there", "world!") + Sentry.addFeatureFlag("my-feature-flag", true) + throw IllegalArgumentException("Something went wrong [id=$id]") + } + + @PostMapping open fun create(@RequestBody person: Person): Person = person +} diff --git a/sentry-spring-7/src/integrationTest/kotlin/io/sentry/spring7/integrationtest/MetricController.kt b/sentry-spring-7/src/integrationTest/kotlin/io/sentry/spring7/integrationtest/MetricController.kt new file mode 100644 index 00000000000..b3b9956bb98 --- /dev/null +++ b/sentry-spring-7/src/integrationTest/kotlin/io/sentry/spring7/integrationtest/MetricController.kt @@ -0,0 +1,33 @@ +package io.sentry.spring7.integrationtest + +import io.sentry.Sentry +import io.sentry.metrics.MetricsUnit +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/metric/") +open class MetricController { + @GetMapping("count") + open fun count(): String { + Sentry.setAttribute("user.type", "admin") + Sentry.setAttribute("feature.version", 2) + Sentry.metrics().count("countMetric") + return "count metric increased" + } + + @GetMapping("gauge/{value}") + open fun gauge(@PathVariable("value") value: Long): String { + Sentry.metrics().gauge("memory.free", value.toDouble(), MetricsUnit.Information.BYTE) + return "gauge metric tracked" + } + + @GetMapping("distribution/{value}") + open fun distribution(@PathVariable("value") value: Long): String { + Sentry.metrics() + .distribution("distributionMetric", value.toDouble(), MetricsUnit.Duration.MILLISECOND) + return "distribution metric tracked" + } +} diff --git a/sentry-spring-7/src/integrationTest/kotlin/io/sentry/spring7/integrationtest/MetricsIntegrationTest.kt b/sentry-spring-7/src/integrationTest/kotlin/io/sentry/spring7/integrationtest/MetricsIntegrationTest.kt new file mode 100644 index 00000000000..c3178cb1188 --- /dev/null +++ b/sentry-spring-7/src/integrationTest/kotlin/io/sentry/spring7/integrationtest/MetricsIntegrationTest.kt @@ -0,0 +1,50 @@ +package io.sentry.spring7.integrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.Sentry +import io.sentry.SentryMetricsEvents +import io.sentry.test.hasMetric +import io.sentry.test.hasMetricWithAttribute +import io.sentry.test.metrics +import kotlin.test.Test + +class MetricsIntegrationTest : IntegrationTest() { + + @Test + fun `count metric carries the scope's attributes`() { + assertThat(restTemplate.getForObject("/metric/count", String::class.java)) + .isEqualTo("count metric increased") + + val metrics = awaitMetric("countMetric") + assertThat(metrics.hasMetric("countMetric", "counter", 1.0)).isTrue() + assertThat(metrics.hasMetricWithAttribute("countMetric", "user.type", "admin")).isTrue() + assertThat(metrics.hasMetricWithAttribute("countMetric", "feature.version", 2)).isTrue() + } + + @Test + fun `gauge metric`() { + assertThat(restTemplate.getForObject("/metric/gauge/14", String::class.java)) + .isEqualTo("gauge metric tracked") + + assertThat(awaitMetric("memory.free").hasMetric("memory.free", "gauge", 14.0)).isTrue() + } + + @Test + fun `distribution metric`() { + assertThat(restTemplate.getForObject("/metric/distribution/23", String::class.java)) + .isEqualTo("distribution metric tracked") + + assertThat( + awaitMetric("distributionMetric").hasMetric("distributionMetric", "distribution", 23.0) + ) + .isTrue() + } + + /** Flushes first because metrics are batched before they're sent. */ + private fun awaitMetric(name: String): SentryMetricsEvents { + Sentry.flush(5000) + return server + .awaitEnvelope { it.metrics()?.items?.any { metric -> metric.name == name } == true } + .metrics()!! + } +} diff --git a/sentry-spring-7/src/integrationTest/kotlin/io/sentry/spring7/integrationtest/PersonIntegrationTest.kt b/sentry-spring-7/src/integrationTest/kotlin/io/sentry/spring7/integrationtest/PersonIntegrationTest.kt new file mode 100644 index 00000000000..137fafe09cd --- /dev/null +++ b/sentry-spring-7/src/integrationTest/kotlin/io/sentry/spring7/integrationtest/PersonIntegrationTest.kt @@ -0,0 +1,64 @@ +package io.sentry.spring7.integrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.Sentry +import io.sentry.test.event +import io.sentry.test.hasFlag +import io.sentry.test.hasLogWithAttribute +import io.sentry.test.hasLogWithBody +import io.sentry.test.logs +import io.sentry.test.profileChunk +import io.sentry.test.transaction +import kotlin.test.Test + +class PersonIntegrationTest : IntegrationTest() { + + @Test + fun `failed request reports the error with its request, the transaction and logs`() { + val response = restTemplate.getForEntity("/person/11", String::class.java) + assertThat(response.statusCode.value()).isEqualTo(500) + Sentry.flush(5000) + + val error = + server + .awaitEnvelope { + it.event()?.exceptions?.any { e -> e.value == "Something went wrong [id=11]" } == true + } + .event()!! + assertThat(error.hasFlag("my-feature-flag", true)).isTrue() + // Without Spring Boot, Sentry's servlet container initializer registers SentrySpringFilter, + // which attaches the request. + assertThat(error.request?.url).endsWith("/person/11") + + val transaction = + server.awaitEnvelope { it.transaction()?.transaction == "GET /person/{id}" }.transaction()!! + assertThat(transaction.contexts.trace?.operation).isEqualTo("http.server") + + val logs = + server + .awaitEnvelope { + it.logs()?.items?.any { log -> log.body == "warn Sentry logging" } == true + } + .logs()!! + assertThat(logs.hasLogWithBody("error Sentry logging")).isTrue() + assertThat(logs.hasLogWithBody("hello there world!")).isTrue() + assertThat(logs.hasLogWithAttribute("warn Sentry logging", "user.type", "admin")).isTrue() + assertThat(logs.hasLogWithAttribute("warn Sentry logging", "feature.version", 2)).isTrue() + assertThat(logs.hasLogWithAttribute("warn Sentry logging", "debug.enabled", true)).isTrue() + } + + @Test + fun `creating a person starts a profile linked to the transaction`() { + 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()!! + val profilerId = transaction.contexts.profile?.profilerId + assertThat(profilerId).isNotNull() + server.awaitEnvelope(timeoutMillis = 30_000) { it.profileChunk()?.profilerId == profilerId } + } +} diff --git a/sentry-spring-7/src/integrationTest/resources/sentry.properties b/sentry-spring-7/src/integrationTest/resources/sentry.properties new file mode 100644 index 00000000000..31e3807a8b2 --- /dev/null +++ b/sentry-spring-7/src/integrationTest/resources/sentry.properties @@ -0,0 +1,4 @@ +traces-sample-rate=1.0 +logs.enabled=true +profile-session-sample-rate=1.0 +profile-lifecycle=TRACE diff --git a/test/system-test-runner.py b/test/system-test-runner.py index 52e9b0a4050..45fd4786b77 100644 --- a/test/system-test-runner.py +++ b/test/system-test-runner.py @@ -828,7 +828,6 @@ def get_available_modules(self) -> List[ModuleConfig]: """Get list of all available test modules.""" return [ ModuleConfig("sentry-samples-spring", "false", "true", "false"), - ModuleConfig("sentry-samples-spring-7", "false", "true", "false"), ModuleConfig("sentry-samples-spring-boot-4-otlp", "false", "true", "false"), ModuleConfig("sentry-samples-console", "false", "true", "false"), ModuleConfig("sentry-samples-console-otlp", "false", "true", "false"), From caaadf4037dd6a8ccc76678ec20573cc655891a6 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 27 Oct 2026 09:07:46 +0100 Subject: [PATCH 2/3] test(spring): [Sample Tests 29] Move the Spring WAR sample's tests into the module (#6321) * test(spring): Move the Spring WAR sample's tests into the module Adds sentry-spring-jakarta's embedded-Tomcat integrationTest suite to sentry-spring, with the module's packages and javax.servlet. The sample's tests were the jakarta WAR sample's, so the suite is too. Spring Boot 2's BOM picks Spring 5.3 and Tomcat 9. The sample has no system tests left, so it comes off the system-test runner, the backend workflow and the Spring Boot 2 matrix. That was the matrix's last runner step, so it drops the Python setup, the runner's logs and the exclude properties only the samples and the runner read. Co-Authored-By: Claude Opus 5.5 (1M context) * test(log4j2): [Sample Tests 30] Move the log4j2 sample's system test into sentry-log4j2 (#6322) * test(log4j2): Move the log4j2 sample's system test into sentry-log4j2 Adds SentryAppenderIntegrationTest, which configures the Sentry appender from XML in a logger context of its own and checks what reaches MockSentryServer: the warning event with its context tag and flag, the error with its exception and breadcrumbs, and the logs with their attributes. Logs are enabled through external configuration, as the sample's sentry.properties does, because the appender has no attribute for them. The sample has no system tests left, so it comes off the system-test runner and the backend workflow, and drops the Kotlin plugin it only needed for the test. Co-Authored-By: Claude Opus 5.5 (1M context) * test(jul): [Sample Tests 31] Move the JUL sample's system test into sentry-jul (#6323) * test(jul): Move the JUL sample's system test into sentry-jul Adds SentryHandlerIntegrationTest, which configures the Sentry handler through a logging properties file read by LogManager, as the sample does, and checks what reaches MockSentryServer: the warning with its flag, the error with its exception and breadcrumbs, and the logs with their attributes. Its logger is outside io.sentry, whose loggers the handler drops, and the test restores the LogManager configuration the other tests read. The sample has no system tests left, so it comes off the system-test runner and the backend workflow, and drops the Kotlin plugin it only needed for the test. Co-Authored-By: Claude Opus 5.5 (1M context) * test: [Sample Tests 32] Add end-to-end tests of the core APIs and a profiled transaction (#6324) * test(core): Add an end-to-end test of the core APIs over the HTTP transport SentryEndToEndTest reports through the APIs an app uses without integrations and checks what reaches MockSentryServer: messages at the scope's and an explicit level, an exception with its feature flag, an event with a fingerprint, breadcrumbs from an event processor and a beforeBreadcrumb callback, ten events in a row, a transaction with a child span, and metrics with the scope's attributes. It ports the core part of the console sample's system test, which ran the sample's JAR against the Python mock server. The sample's profiling and Kafka parts follow in their own modules. Co-Authored-By: Claude Opus 5.5 (1M context) * Merge the audit fixes from the branches below, and check the exception type end to end The console sample's test checked that the captured exception arrives with its type, RuntimeException, not just its value. Co-Authored-By: Claude Opus 5.5 (1M context) * test(core): Check that the shutdown hook delivers what an app reported before exiting The console samples' system tests ran each sample in its own JVM, asserted that it exited with 0 within 30 seconds, and relied on the shutdown hook to send everything, because the samples never flush or close Sentry. The in-process end-to-end tests flush explicitly, so neither the clean exit nor the hook's flush was tested any more. Run a small app in a child JVM that returns from main without flushing, and check that it exits with 0 and that its event, log and metric arrive. Co-Authored-By: Claude Opus 5.5 (1M context) * test(async-profiler): Add an end-to-end test of a profiled transaction ContinuousProfilerEndToEndTest starts a sampled transaction with the TRACE profile lifecycle and checks that its profile chunk reaches MockSentryServer, linked to the transaction by the profiler ID. It ports the profiling part of the console sample's system test. The profiler rejects a traces path with a dot anywhere in its absolute form, so the test puts it in the temp dir. Co-Authored-By: Claude Opus 5.5 (1M context) * test(kafka): [Sample Tests 34] Add an end-to-end test of the raw Kafka client's tracing (#6326) * test(async-profiler): Add an end-to-end test of a profiled transaction ContinuousProfilerEndToEndTest starts a sampled transaction with the TRACE profile lifecycle and checks that its profile chunk reaches MockSentryServer, linked to the transaction by the profiler ID. It ports the profiling part of the console sample's system test. The profiler rejects a traces path with a dot anywhere in its absolute form, so the test puts it in the temp dir. Co-Authored-By: Claude Opus 5.5 (1M context) * test(kafka): Add an end-to-end test of the raw Kafka client's tracing SentryKafkaEndToEndTest publishes a message through a wrapped KafkaProducer to an in-JVM KRaft broker, consumes it with SentryKafkaConsumerTracing, and checks what reaches MockSentryServer: the queue.publish span in the producing transaction, and a queue.process transaction with the consumer origin and the Kafka system that continues the producer's trace. It ports the Kafka part of the console sample's system test, which needed a Redpanda container. The broker comes from spring-kafka-test 3, which needs Java 17, so the test classpaths resolve Java 17 variants. The tests run on it anyway. With its core, profiling and Kafka parts ported, the console sample has no system test left, so it comes off the system-test runner and the backend workflow, whose base row moves to the console OTLP sample. Co-Authored-By: Claude Opus 5.5 (1M context) * test(opentelemetry): [Sample Tests 35] Add an end-to-end test of the agentless OpenTelemetry setup (#6327) * test(opentelemetry): Add an end-to-end test of the agentless OpenTelemetry setup AgentlessEndToEndTest builds the OpenTelemetry SDK through its autoconfiguration, which loads Sentry's customizer, and checks what reaches MockSentryServer: events, a transaction traced through OpenTelemetry with its child span and profile, and metrics. It ports sentry-samples-console-opentelemetry-noagent's system test, which no CI job ran. The customizer only initializes Sentry when the environment asks it to, so the test task sets SENTRY_AUTO_INIT. These are the module's first tests, so it applies the Kotlin plugin and gets an API dump. The sample has no system test left, so it comes off the system-test runner and drops the Kotlin plugin it only needed for the test. Co-Authored-By: Claude Opus 5.5 (1M context) * Merge the audit fixes from the branches below, and check the exception type in agentless mode Co-Authored-By: Claude Opus 5.5 (1M context) * test(opentelemetry): [Sample Tests 36] Add an end-to-end test of the OTLP event linking (#6328) * test(opentelemetry): Add an end-to-end test of the OTLP event linking OtlpEndToEndTest captures a message, a log and a metric inside an OpenTelemetry span with OpenTelemetryOtlpEventProcessor installed, and checks what reaches MockSentryServer: the event carries the span's trace and span IDs, and the log and the metric arrive. It ports the OTLP-specific part of the console OTLP sample's system test, which never checked the link. The sample's other assertions are the console sample's, which SentryEndToEndTest covers. The sample has no system test left, so it comes off the system-test runner and the backend workflow, whose base row moves to the Spring Boot 4 OTLP sample, and drops the Kotlin plugin it only needed for the test. Co-Authored-By: Claude Opus 5.5 (1M context) * test(spring-boot): [Sample Tests 37] Move the Boot 4 OTLP sample's tests into the module (#6329) * 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 runs the new suite. Co-Authored-By: Claude Opus 5.5 (1M context) * 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) * test: [Sample Tests 38] Remove the system-test runner and its Python mock server (#6330) * test: Remove the system-test runner and its Python mock server Every sample's system test now lives in the module it tests, so the Python runner, its mock Sentry server, requirements.txt and the Makefile targets that ran them have nothing left to run. Co-Authored-By: Claude Opus 5.5 (1M context) * test: [Sample Tests 39] Remove the io.sentry.systemtest plugin and sentry-system-test-support (#6331) * test: Remove the io.sentry.systemtest plugin and sentry-system-test-support With the runner gone, nothing applies the io.sentry.systemtest convention plugin or depends on sentry-system-test-support, which held the HTTP and GraphQL clients and the TestHelper the system tests used. The envelope matchers it delegated to stay in sentry-test-support. Co-Authored-By: Claude Opus 5.5 (1M context) * docs: [Sample Tests 40] Describe the in-module end-to-end tests instead of the system tests (#6332) * docs: Describe the in-module end-to-end tests instead of the system tests The e2e_tests rule now covers MockSentryServer, where each kind of end-to-end test lives, the Spring suites per flavor, the OpenTelemetry agent fixture, how to run them and the gotchas the migration found. The test skill runs integration suites by their source set instead of the Python runner, the new_module rule no longer asks for sample tests, and AGENTS.md drops sentry-system-test-support. Co-Authored-By: Claude Opus 5.5 (1M context) * Merge the audit fixes from the branches below, and document the test client and log4j2 suite The context's TestRestTemplate adds trace headers of its own whenever Sentry customizes the RestTemplateBuilder, not only in OpenTelemetry mode, and the Spring Boot modules have an integrationTestLog4j2 suite. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .claude/skills/test/SKILL.md | 38 +- .cursor/rules/e2e_tests.mdc | 84 +- .cursor/rules/new_module.mdc | 4 +- .github/workflows/spring-boot-2-matrix.yml | 33 +- .github/workflows/spring-boot-4-matrix.yml | 3 +- .github/workflows/system-tests-backend.yml | 94 -- AGENTS.md | 9 +- Makefile | 16 +- .../kotlin/io.sentry.systemtest.gradle.kts | 38 - .../io/sentry/gradle/SystemTestExtension.kt | 17 - build.gradle.kts | 5 +- requirements.txt | 5 - sentry-async-profiler/build.gradle.kts | 1 + .../ContinuousProfilerEndToEndTest.kt | 48 + sentry-jul/build.gradle.kts | 1 + .../jul/SentryHandlerIntegrationTest.kt | 69 + ...sentry-handler-integration-test.properties | 7 + sentry-kafka/build.gradle.kts | 3 + .../sentry/kafka/SentryKafkaEndToEndTest.kt | 103 ++ sentry-log4j2/build.gradle.kts | 1 + .../log4j2/SentryAppenderIntegrationTest.kt | 77 + .../sentry-appender-integration-test.xml | 17 + .../api/sentry-opentelemetry-agentless.api | 9 + .../build.gradle.kts | 16 + .../agentless/AgentlessEndToEndTest.kt | 163 +++ .../build.gradle.kts | 1 + .../src/test/kotlin/OtlpEndToEndTest.kt | 66 + .../build.gradle.kts | 36 - .../src/test/kotlin/sentry/DummyTest.kt | 12 - .../ConsoleApplicationSystemTest.kt | 119 -- .../build.gradle.kts | 36 - .../src/test/kotlin/io/sentry/DummyTest.kt | 12 - .../ConsoleApplicationSystemTest.kt | 107 -- .../sentry-samples-console/build.gradle.kts | 36 - .../src/test/kotlin/io/sentry/DummyTest.kt | 12 - .../ConsoleApplicationSystemTest.kt | 137 -- .../sentry-samples-jul/build.gradle.kts | 36 - .../src/test/kotlin/io/sentry/DummyTest.kt | 12 - .../ConsoleApplicationSystemTest.kt | 83 -- .../sentry-samples-log4j2/build.gradle.kts | 36 - .../src/test/kotlin/io/sentry/DummyTest.kt | 12 - .../ConsoleApplicationSystemTest.kt | 85 -- .../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-samples-spring/build.gradle.kts | 29 - .../src/test/kotlin/io/sentry/DummyTest.kt | 12 - .../io/sentry/systemtest/MetricsSystemTest.kt | 51 - .../io/sentry/systemtest/PersonSystemTest.kt | 85 -- sentry-spring-boot-4/build.gradle.kts | 34 + .../otlpintegrationtest/CacheController.kt | 43 + .../CacheIntegrationTest.kt | 35 + .../DistributedTracingController.kt | 22 + .../DistributedTracingIntegrationTest.kt | 148 ++ .../otlpintegrationtest/GraphqlControllers.kt | 83 ++ .../GraphqlIntegrationTest.kt | 109 ++ .../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 + sentry-spring/build.gradle.kts | 22 + .../spring/integrationtest/IntegrationTest.kt | 72 + .../IntegrationTestApplication.kt | 66 + .../integrationtest/MetricController.kt | 33 + .../integrationtest/MetricsIntegrationTest.kt | 50 + .../integrationtest/PersonIntegrationTest.kt | 64 + .../resources/sentry.properties | 4 + sentry-system-test-support/build.gradle.kts | 44 - .../src/main/graphql/greeting.graphql | 3 - .../src/main/graphql/project.graphql | 11 - .../src/main/graphql/schema.graphqls | 70 - .../src/main/graphql/task.graphql | 16 - .../io/sentry/systemtest/ResponseTypes.kt | 7 - .../systemtest/graphql/GraphqlTestClient.kt | 38 - .../util/LoggingInsecureRestClient.kt | 83 -- .../sentry/systemtest/util/RestTestClient.kt | 115 -- .../systemtest/util/SentryMockServerClient.kt | 35 - .../io/sentry/systemtest/util/TestHelper.kt | 298 ---- .../main/kotlin/io/sentry/test/ChildJvmApp.kt | 17 +- .../test/java/io/sentry/SentryEndToEndTest.kt | 113 ++ .../io/sentry/ShutdownHookEndToEndTest.kt | 56 + settings.gradle.kts | 1 - test/system-test-runner.py | 1254 ----------------- test/system-test-sentry-server.py | 168 --- 97 files changed, 2164 insertions(+), 4019 deletions(-) delete mode 100644 .github/workflows/system-tests-backend.yml delete mode 100644 build-logic/src/main/kotlin/io.sentry.systemtest.gradle.kts delete mode 100644 build-logic/src/main/kotlin/io/sentry/gradle/SystemTestExtension.kt delete mode 100644 requirements.txt create mode 100644 sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/ContinuousProfilerEndToEndTest.kt create mode 100644 sentry-jul/src/test/kotlin/io/sentry/jul/SentryHandlerIntegrationTest.kt create mode 100644 sentry-jul/src/test/resources/sentry-handler-integration-test.properties create mode 100644 sentry-kafka/src/test/kotlin/io/sentry/kafka/SentryKafkaEndToEndTest.kt create mode 100644 sentry-log4j2/src/test/kotlin/io/sentry/log4j2/SentryAppenderIntegrationTest.kt create mode 100644 sentry-log4j2/src/test/resources/sentry-appender-integration-test.xml create mode 100644 sentry-opentelemetry/sentry-opentelemetry-agentless/api/sentry-opentelemetry-agentless.api create mode 100644 sentry-opentelemetry/sentry-opentelemetry-agentless/src/test/kotlin/io/sentry/opentelemetry/agentless/AgentlessEndToEndTest.kt create mode 100644 sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OtlpEndToEndTest.kt delete mode 100644 sentry-samples/sentry-samples-console-opentelemetry-noagent/src/test/kotlin/sentry/DummyTest.kt delete mode 100644 sentry-samples/sentry-samples-console-opentelemetry-noagent/src/test/kotlin/sentry/systemtest/ConsoleApplicationSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-console-otlp/src/test/kotlin/io/sentry/DummyTest.kt delete mode 100644 sentry-samples/sentry-samples-console-otlp/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-console/src/test/kotlin/io/sentry/DummyTest.kt delete mode 100644 sentry-samples/sentry-samples-console/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-jul/src/test/kotlin/io/sentry/DummyTest.kt delete mode 100644 sentry-samples/sentry-samples-jul/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-log4j2/src/test/kotlin/io/sentry/DummyTest.kt delete mode 100644 sentry-samples/sentry-samples-log4j2/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt 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 delete mode 100644 sentry-samples/sentry-samples-spring/src/test/kotlin/io/sentry/DummyTest.kt delete mode 100644 sentry-samples/sentry-samples-spring/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt 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 create mode 100644 sentry-spring/src/integrationTest/kotlin/io/sentry/spring/integrationtest/IntegrationTest.kt create mode 100644 sentry-spring/src/integrationTest/kotlin/io/sentry/spring/integrationtest/IntegrationTestApplication.kt create mode 100644 sentry-spring/src/integrationTest/kotlin/io/sentry/spring/integrationtest/MetricController.kt create mode 100644 sentry-spring/src/integrationTest/kotlin/io/sentry/spring/integrationtest/MetricsIntegrationTest.kt create mode 100644 sentry-spring/src/integrationTest/kotlin/io/sentry/spring/integrationtest/PersonIntegrationTest.kt create mode 100644 sentry-spring/src/integrationTest/resources/sentry.properties delete mode 100644 sentry-system-test-support/build.gradle.kts delete mode 100644 sentry-system-test-support/src/main/graphql/greeting.graphql delete mode 100644 sentry-system-test-support/src/main/graphql/project.graphql delete mode 100644 sentry-system-test-support/src/main/graphql/schema.graphqls delete mode 100644 sentry-system-test-support/src/main/graphql/task.graphql delete mode 100644 sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/ResponseTypes.kt delete mode 100644 sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt delete mode 100644 sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt delete mode 100644 sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/RestTestClient.kt delete mode 100644 sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt delete mode 100644 sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/TestHelper.kt create mode 100644 sentry/src/test/java/io/sentry/SentryEndToEndTest.kt create mode 100644 sentry/src/test/java/io/sentry/ShutdownHookEndToEndTest.kt delete mode 100644 test/system-test-runner.py delete mode 100755 test/system-test-sentry-server.py diff --git a/.claude/skills/test/SKILL.md b/.claude/skills/test/SKILL.md index d7b28f6d260..0a066c76de0 100644 --- a/.claude/skills/test/SKILL.md +++ b/.claude/skills/test/SKILL.md @@ -1,13 +1,14 @@ --- name: test -description: Run tests for a specific SDK module. Use when asked to "run tests", "test module", "run unit tests", "run system tests", "run e2e tests", or test a specific class. Auto-detects unit vs system tests. Supports interactive mode. +description: Run tests for a specific SDK module. Use when asked to "run tests", "test module", "run unit tests", "run integration tests", "run e2e tests", or test a specific class. Auto-detects unit tests vs integration test suites. Supports interactive mode. allowed-tools: Bash, Read, Glob, AskUserQuestion argument-hint: [interactive] [test-class-filter] --- # Run Tests -Run tests for a specific module. Auto-detects whether to run unit tests or system tests. +Run tests for a specific module. Auto-detects whether to run unit tests or an integration test +suite. ## Step 0: Check for Interactive Mode @@ -19,7 +20,7 @@ In interactive mode, use AskUserQuestion at decision points as described in the The argument can be either: - A **file path** (e.g., `@sentry/src/test/java/io/sentry/ScopesTest.kt`) -- A **module name** (e.g., `sentry-android-core`, `sentry-samples-spring-boot-4`) +- A **module name** (e.g., `sentry-android-core`, `sentry-spring-boot-4`) - A **module name + test filter** (e.g., `sentry ScopesTest`) Extract the module name and optional test class filter from the argument. @@ -30,10 +31,13 @@ Extract the module name and optional test class filter from the argument. | Signal | Test Type | |--------|-----------| -| Path contains `sentry-samples/` | System test | -| Module name starts with `sentry-samples-` | System test | +| Path contains `/src/integrationTest*/` or `/src/agentFixture/` | Integration test suite | +| Path contains `sentry-samples/`, or module name starts with `sentry-samples-` | None: samples have no tests | | Everything else | Unit test | +For a sample, tell the user that samples have no tests, and that the end-to-end tests for what +the sample shows live in the module it uses (see the `e2e_tests` rule). + ## Step 3a: Run Unit Tests Determine the Gradle test task: @@ -60,30 +64,20 @@ Without a filter: ./gradlew '::' --info ``` -## Step 3b: Run System Tests - -System tests require the Python-based test runner which manages a mock Sentry server and sample app lifecycle. - -1. Ensure the Python venv exists: -```bash -test -d .venv || make setupPython -``` +## Step 3b: Run an Integration Test Suite -2. Extract the sample module name. For file paths like `sentry-samples//src/...`, the sample module is the directory name (e.g., `sentry-samples-spring`). +The suite's task is named after its source set directory: `src/integrationTestWebflux/...` runs +with `integrationTestWebflux`. The OpenTelemetry agent suite runs in two tasks, one per init mode, +so for `src/integrationTestOtelAgent/` or `src/agentFixture/` run both: -3. Run the system test: ```bash -.venv/bin/python test/system-test-runner.py test --module +./gradlew '::integrationTestOtelAgent' '::integrationTestOtelAgentWithoutAutoInit' ``` -This starts the mock Sentry server, starts the sample app (Spring Boot/Tomcat/CLI), runs tests via `./gradlew :sentry-samples::systemTest`, and cleans up afterwards. - -To run **every** system test instead of one module, use the Makefile targets — they also create the -venv for you: +Otherwise, with or without a test class filter: ```bash -make systemTest # all system tests (--all) -make systemTestInteractive # pick the setups to run (--interactive) +./gradlew '::' --tests="**" ``` ## Step 4: Report Results diff --git a/.cursor/rules/e2e_tests.mdc b/.cursor/rules/e2e_tests.mdc index 17e088774b5..1785e61b5c1 100644 --- a/.cursor/rules/e2e_tests.mdc +++ b/.cursor/rules/e2e_tests.mdc @@ -3,26 +3,82 @@ alwaysApply: false description: Java SDK End to End Tests --- -# Java SDK End to End Tests (System Tests) +# Java SDK End to End Tests -The samples in the `sentry-samples` directory are used to run end to end tests against them. +End-to-end tests live in the module whose code they test, not in `sentry-samples`. Samples have no +tests. A test runs the code the way an app would and asserts what reaches Sentry over the real HTTP +transport. -There is a python script (`system-test-runner.py`) that can be used to run one (using `--module SAMPLE_NAME`) or all (using `--all`) system tests. +## MockSentryServer -The script has an interactive mode (`-i`) which allows selection of test setups to execute, whether to run the tests or just prepare infrastructure for testing from IDE. +`MockSentryServer` (`sentry-test-support`) is an in-process HTTP server on a free port. Point the +SDK at `server.dsn`, then: -The tests run a mock Sentry server via `system-test-sentry-server.py`. Any system under test will then have a DSN set that reflects this local mock server like `http://502f25099c204a2fbf4cb16edc5975d1@localhost:8000/0`. -By using this local DSN, the system under test sends events to the local mock server. -The tests can then use `TestHelper` to assert envelopes that were received by the mock server. -`TestHelper` uses HTTP requests to retrieve the JSON payload of the received events and deserialize them back to objects for easier assertion. -Tests can then assert events, transactions, logs etc. similar to how they would appear in `beforeSend` and similar callbacks. +- `awaitEnvelope(timeoutMillis) { predicate }` polls for a matching envelope and fails with what it + received on timeout. +- `assertNoEnvelope(millis) { predicate }` checks that nothing matching arrives. +- `MockSentryServer { ... }` takes a hook that runs before each check while waiting, for example to + flush an OpenTelemetry tracer provider. -`TestHelper` has a lot of helper methods for asserting, e.g. by span name, log body etc. +`EnvelopeMatchers.kt` has the accessors and matchers: `event()`, `transaction()`, `logs()`, +`metrics()`, `profileChunk()`, `hasSpanWithOp`, `hasFlag`, `hasLogWithAttribute`, `hasMetric` and +so on. -The end to end tests either expect the system under test to either be running on a server or call `java -jar` to execute a CLI system under test. +Call `Sentry.flush()` before awaiting when Sentry runs in the test's JVM: logs and metrics are +batched. Envelopes that arrive late can outlive a server's `reset()`, so tests that share a server +match on values only they produce. -For Spring Boot, we spin up the Spring Boot server. The tests then send requests to that server and assert what is sent to Sentry. +## Where the tests are -End to end tests are also executed on CI using a matrix build, as defined in `.github/workflows/system-tests-backend.yml`. +- **Core and plain integrations** use their module's `src/test`. Examples: `SentryEndToEndTest` + (`sentry`), `SentryAppenderIntegrationTest` (`sentry-logback`, `sentry-log4j2`, loading the + appender from XML), `SentryHandlerIntegrationTest` (`sentry-jul`), + `ContinuousProfilerEndToEndTest` (`sentry-async-profiler`), `SentryKafkaEndToEndTest` + (`sentry-kafka`), `AgentlessEndToEndTest` and `OtlpEndToEndTest` (`sentry-opentelemetry-*`). +- **Spring Boot** (`sentry-spring-boot`, `sentry-spring-boot-jakarta`, `sentry-spring-boot-4`) has + one JVM test suite per flavor, because a flavor is a classpath: Sentry's auto-configuration and + OpenTelemetry mode switch on which classes are present. + - `integrationTest`: servlet app. + - `integrationTestWebflux`: reactive app. + - `integrationTestOtelNoAgent`: `sentry-opentelemetry-agentless-spring`. + - `integrationTestOtelAgent` and `integrationTestOtelAgentWithoutAutoInit`: the built agent. + - `integrationTestOtlp` (Boot 4): `sentry-opentelemetry-otlp-spring`. + - `integrationTestLog4j2` (Boot 3, 4): Log4j2 instead of Logback, for the auto-configured + Log4j2 appender. -Some of the samples are tested in multiple ways, e.g. with OpenTelemetry Agent auto init turned on and off. + Each suite has a base class with `@SpringBootTest(RANDOM_PORT)` and a `@DynamicPropertySource` + that sets `sentry.dsn`. Subclasses share one context and one server. +- **Plain Spring** (`sentry-spring`, `sentry-spring-jakarta`, `sentry-spring-7`) has an + `integrationTest` suite that starts the app in an embedded Tomcat, the way a WAR starts. Spring + Boot stays off its classpath, because `SentrySpringServletContainerInitializer` behaves + differently with it. + +## The OpenTelemetry agent + +The agent must not run in the test's JVM, because it would instrument the test's own HTTP client +and server. The Spring Boot modules have an `agentFixture` source set with the app. `AgentApp` +starts it once per test JVM in a child process, with `ChildJvmApp` and the agent jar passed through +`ChildJvmTestArguments`. The suite's task runs with the agent initializing Sentry, and +`integrationTestOtelAgentWithoutAutoInit` runs the same tests with the Spring Boot starter doing +it. Gradle runs the two in parallel. + +## Running them + +- The `spring-boot-{2,3,4}-matrix.yml` workflows run the suites against each supported Spring Boot + version, by setting the version in `gradle/libs.versions.toml`. The suites aren't part of + `check`, so a new suite only runs on CI once it's added to its matrix. + +## Gotchas + +- Don't use the context's `TestRestTemplate`. It comes from the `RestTemplateBuilder` that Sentry + customizes, and that the OpenTelemetry starter instruments, so it adds or replaces trace headers + on the requests a test sends. Build one outside the context. +- Sentry exports OpenTelemetry spans in batches every 5 seconds. In-process, flush the tracer + provider from the server's hook. With the agent this isn't possible, so each test waits for a + batch and negative checks wait at least 6 seconds. +- Sentry is global, so a second live Spring context in an OpenTelemetry suite takes it over. Keep + one live context per JVM (`@DirtiesContext` when a test needs its own). +- The async profiler rejects a traces path with a `.` anywhere in its absolute form. Put the path + in `java.io.tmpdir`. +- Kafka uses an in-JVM broker (`@EmbeddedKafka` or spring-kafka-test's broker classes), so no + Docker is needed. diff --git a/.cursor/rules/new_module.mdc b/.cursor/rules/new_module.mdc index d499acf03a0..b9fdde3df6a 100644 --- a/.cursor/rules/new_module.mdc +++ b/.cursor/rules/new_module.mdc @@ -69,8 +69,8 @@ static { addIntegrationToSdkVersion("{IntegrationName}"); ``` -7. If adding a JVM sample, add E2E (system) tests, following the structure we have in the existing JVM examples. - The test should then be added to `test/system-test-runner.py` and `.github/workflows/system-tests-backend.yml`. +7. Samples have no tests. Add end-to-end coverage of what the sample shows to the module it uses, + against `MockSentryServer`, as described in the `e2e_tests` rule. `sentry-bom` and the root `build.gradle.kts` need no change — they iterate over subprojects. diff --git a/.github/workflows/spring-boot-2-matrix.yml b/.github/workflows/spring-boot-2-matrix.yml index 30f7c8e7020..edfe9e74fc0 100644 --- a/.github/workflows/spring-boot-2-matrix.yml +++ b/.github/workflows/spring-boot-2-matrix.yml @@ -25,7 +25,6 @@ jobs: name: Spring Boot ${{ matrix.springboot-version }} env: - SENTRY_URL: http://127.0.0.1:8000 GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} steps: @@ -34,16 +33,6 @@ jobs: with: submodules: 'recursive' - - name: Set up Python - 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: @@ -65,35 +54,19 @@ jobs: - name: Update Spring Boot 2.x version run: | springboot_version="${{ matrix.springboot-version }}" - if [[ ! "$springboot_version" =~ ^2\.7\. ]]; then - echo "ORG_GRADLE_PROJECT_excludeGraphql=true" >> "$GITHUB_ENV" - echo "ORG_GRADLE_PROJECT_excludeKafka=true" >> "$GITHUB_ENV" - fi perl -0pi -e 'BEGIN { $v = shift } s/^springboot2[[:space:]]*=[[:space:]]*"\K[^"]*/$v/m or die "::error::springboot2 version entry not found in gradle/libs.versions.toml\n"' "$springboot_version" gradle/libs.versions.toml echo "Updated Spring Boot 2.x version to $springboot_version" - - name: Build sample artifacts - run: | - ./gradlew \ - :sentry-samples:sentry-samples-spring:war \ - :sentry-samples:sentry-samples-spring:testClasses - - - name: Test sentry-spring-boot integration suites + - name: Test integration suites run: | ./gradlew \ + :sentry-spring:integrationTest \ :sentry-spring-boot:integrationTest \ :sentry-spring-boot:integrationTestWebflux \ :sentry-spring-boot:integrationTestOtelNoAgent \ :sentry-spring-boot:integrationTestOtelAgent \ :sentry-spring-boot:integrationTestOtelAgentWithoutAutoInit - - name: Test sentry-samples-spring - run: | - python3 test/system-test-runner.py test \ - --module "sentry-samples-spring" \ - --agent false \ - --auto-init "true" - - name: Upload test results if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 @@ -102,8 +75,6 @@ jobs: path: | **/build/reports/* **/build/test-results/**/*.xml - sentry-mock-server.txt - spring-server.txt - name: Test Report uses: phoenix-actions/test-reporting@7317eea6e13c47348dd0bb318669485157c518d6 # pin@v16 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 36b03cca255..00000000000 --- a/.github/workflows/system-tests-backend.yml +++ /dev/null @@ -1,94 +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-console" ] - agent: [ "false" ] - agent-auto-init: [ "true" ] - include: - - sample: "sentry-samples-console-otlp" - agent: "false" - agent-auto-init: "true" - - sample: "sentry-samples-log4j2" - agent: "false" - agent-auto-init: "true" - - sample: "sentry-samples-jul" - agent: "false" - agent-auto-init: "true" - - sample: "sentry-samples-spring-boot-4-otlp" - agent: "false" - agent-auto-init: "true" - - sample: "sentry-samples-spring" - 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/AGENTS.md b/AGENTS.md index badfba1b3e4..1ffec6c02b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ rule file in `.cursor/rules/`: | `continuous_profiling_jvm` | `sentry-async-profiler`, `IContinuousProfiler`, `ProfileChunk`, JFR files, `ProfileLifecycle` | | `opentelemetry` | `sentry-opentelemetry-*`, agent vs agentless, span processing, sampling, context propagation | | `new_module` | Adding a new integration or sample module | -| `e2e_tests` | System tests, sample applications, `system-test-runner.py`, mock Sentry server | +| `e2e_tests` | End-to-end tests, `MockSentryServer`, integration test suites, the OpenTelemetry agent fixture | Rules can be combined — a tracing scope issue may need both `scopes` and `opentelemetry`. There is no rule for Android profiling yet; read the `sentry-android-core` profiling code @@ -55,7 +55,7 @@ The project uses **Gradle** with Kotlin DSL. Key build files: ``` To run tests, use the `test` skill rather than composing the Gradle invocation by hand — it -resolves the per-module test task and the unit-test vs system-test split for you. +resolves the per-module test task, including integration test suites, for you. ### Android-Specific Commands ```bash @@ -109,8 +109,7 @@ The repository is organized into multiple modules: - **Other**: `sentry-spotlight`, `sentry-kotlin-extensions`, `sentry-android-distribution` ### Utility Modules -- **`sentry-test-support`** - Shared test utilities -- **`sentry-system-test-support`** - System testing infrastructure +- **`sentry-test-support`** - Shared test utilities, including `MockSentryServer` and envelope matchers - **`sentry-samples`** - Example applications - **`sentry-bom`** - Bill of Materials for dependency management @@ -158,7 +157,7 @@ PR description why the broad catch is necessary. ### Testing Requirements - Write comprehensive unit tests for new features - Android modules require both unit tests and instrumented tests where applicable -- System tests validate end-to-end functionality with sample applications +- End-to-end tests run in the module they test, against `MockSentryServer`. Samples have no tests - **Assertions**: For new unit tests, prefer [Google Truth](https://truth.dev/) (`com.google.common.truth.Truth.assertThat`) over `kotlin.test`/JUnit assertions for its readable, fluent API. Keep using `kotlin.test` for test structure (`@Test`, `assertFailsWith`). See `sentry/src/test/java/io/sentry/DsnTest.kt` for the style. Don't rewrite existing `kotlin.test` assertions solely to switch libraries. - Truth is wired into the `sentry` module. When adding Truth-based tests to another module, add `testImplementation(libs.google.truth)` to that module's `build.gradle.kts`. diff --git a/Makefile b/Makefile index 3941eb93ec3..7a57ed54e09 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all clean compile javadocs dryRelease update checkFormat api assembleUiTestRelease assembleUiTestCriticalRelease runUiTestCritical setupPython systemTest systemTestInteractive check preMerge publish +.PHONY: all clean compile javadocs dryRelease update checkFormat api assembleUiTestRelease assembleUiTestCriticalRelease runUiTestCritical check preMerge publish all: stop clean javadocs compile assembleUiTests: assembleUiTestRelease @@ -46,20 +46,6 @@ assembleUiTestCriticalRelease: runUiTestCritical: ./scripts/test-ui-critical.sh -# Create the Python virtual environment for system tests, and install the necessary dependencies -setupPython: - @test -d .venv || python3 -m venv .venv - .venv/bin/pip install --upgrade pip - .venv/bin/pip install -r requirements.txt - -# Run system tests for sample applications -systemTest: setupPython - .venv/bin/python test/system-test-runner.py test --all - -# Run system tests with interactive module selection -systemTestInteractive: setupPython - .venv/bin/python test/system-test-runner.py test --interactive - # Run tests and lint check: ./gradlew check diff --git a/build-logic/src/main/kotlin/io.sentry.systemtest.gradle.kts b/build-logic/src/main/kotlin/io.sentry.systemtest.gradle.kts deleted file mode 100644 index a21079e1336..00000000000 --- a/build-logic/src/main/kotlin/io.sentry.systemtest.gradle.kts +++ /dev/null @@ -1,38 +0,0 @@ -import io.sentry.gradle.SystemTestExtension -import org.gradle.api.tasks.ClasspathNormalizer - -val systemTest = extensions.create("sentrySystemTest") - -// The sample system tests launch the packaged app (war/shadowJar/bootJar) from build/libs as a -// separate process, so the archive is a real input even though it is not on the test classpath. -// Agent-based samples are additionally launched with -javaagent:, another runtime -// input not on the classpath. See test/system-test-runner.py. -tasks.matching { it.name == "systemTest" }.configureEach { - val archiveTask = - listOf("war", "shadowJar", "bootJar").firstOrNull { it in tasks.names } - ?: throw GradleException( - "io.sentry.systemtest is applied to $path but none of war/shadowJar/bootJar " + - "exist to provide the launched app archive for the systemTest task" - ) - // Declaring the archive as an input also wires the dependency on its producing task. - inputs - .files(tasks.named(archiveTask)) - .withPropertyName("appArchive") - .withNormalizer(ClasspathNormalizer::class.java) - - if (systemTest.usesOpenTelemetryAgent.get()) { - // The runner builds the agent and launches the app with -javaagent before invoking this task, - // so the agent jar is tracked for content only (by path, no cross-project task dependency): a - // change to it makes systemTest out of date even though it runs outside the test JVM. - val version = providers.gradleProperty("versionName").get() - inputs - .files( - rootProject.layout.projectDirectory.file( - "sentry-opentelemetry/sentry-opentelemetry-agent/build/libs/" + - "sentry-opentelemetry-agent-$version.jar" - ) - ) - .withPropertyName("openTelemetryAgent") - .withNormalizer(ClasspathNormalizer::class.java) - } -} diff --git a/build-logic/src/main/kotlin/io/sentry/gradle/SystemTestExtension.kt b/build-logic/src/main/kotlin/io/sentry/gradle/SystemTestExtension.kt deleted file mode 100644 index 9111ce17b1f..00000000000 --- a/build-logic/src/main/kotlin/io/sentry/gradle/SystemTestExtension.kt +++ /dev/null @@ -1,17 +0,0 @@ -package io.sentry.gradle - -import org.gradle.api.provider.Property - -/** Configuration for the `io.sentry.systemtest` convention plugin. */ -abstract class SystemTestExtension { - /** - * Set to `true` for samples that the system-test runner launches with the Sentry OpenTelemetry - * Java agent (`-javaagent`). The agent jar is then tracked as a `systemTest` input so the task - * re-runs when the agent changes, even though it is started outside the test JVM. - */ - abstract val usesOpenTelemetryAgent: Property - - init { - usesOpenTelemetryAgent.convention(false) - } -} diff --git a/build.gradle.kts b/build.gradle.kts index 898b429cb5d..3aceb123fba 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -87,8 +87,7 @@ apiValidation { "test-app-size", "sentry-samples-netflix-dgs", "sentry-samples-console-otlp", - "sentry-test-support", - "sentry-system-test-support" + "sentry-test-support" ) ) } @@ -126,7 +125,7 @@ subprojects { } } - if (!this.name.contains("sample") && !this.name.contains("integration-tests") && this.name != "sentry-system-test-support" && this.name != "sentry-test-support") { + if (!this.name.contains("sample") && !this.name.contains("integration-tests") && this.name != "sentry-test-support") { apply() apply() diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 62c248385cd..00000000000 --- a/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -certifi==2026.10.19 -charset-normalizer==3.4.2 -idna==3.15 -requests==2.33.0 -urllib3==2.8.0 diff --git a/sentry-async-profiler/build.gradle.kts b/sentry-async-profiler/build.gradle.kts index 3bcdefd1d82..fbb4a8fc353 100644 --- a/sentry-async-profiler/build.gradle.kts +++ b/sentry-async-profiler/build.gradle.kts @@ -32,6 +32,7 @@ dependencies { // tests testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(projects.sentryTestSupport) + testImplementation(libs.google.truth) testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.core) diff --git a/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/ContinuousProfilerEndToEndTest.kt b/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/ContinuousProfilerEndToEndTest.kt new file mode 100644 index 00000000000..25f8d0b9940 --- /dev/null +++ b/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/ContinuousProfilerEndToEndTest.kt @@ -0,0 +1,48 @@ +package io.sentry.asyncprofiler + +import com.google.common.truth.Truth.assertThat +import io.sentry.ProfileLifecycle +import io.sentry.Sentry +import io.sentry.test.MockSentryServer +import io.sentry.test.initForTest +import io.sentry.test.profileChunk +import io.sentry.test.transaction +import kotlin.test.AfterTest +import kotlin.test.Test + +/** Profiles a transaction with the async profiler and sends both over the real HTTP transport. */ +class ContinuousProfilerEndToEndTest { + private val server = MockSentryServer() + + @AfterTest + fun teardown() { + Sentry.close() + server.close() + } + + @Test + fun `a transaction starts a profile whose chunk reaches Sentry`() { + initForTest { + it.dsn = server.dsn + it.tracesSampleRate = 1.0 + it.profileSessionSampleRate = 1.0 + it.profileLifecycle = ProfileLifecycle.TRACE + // The profiler rejects a traces path with a dot anywhere in it, which the module's absolute + // path can have. + it.profilingTracesDirPath = + "${System.getProperty("java.io.tmpdir")}/sentry-profiler-end-to-end-test" + } + + val transaction = Sentry.startTransaction("transaction name", "op") + val span = transaction.startChild("child") + Thread.sleep(100) + span.finish() + transaction.finish() + + val sent = + server.awaitEnvelope { it.transaction()?.transaction == "transaction name" }.transaction()!! + val profilerId = sent.contexts.profile?.profilerId + assertThat(profilerId).isNotNull() + server.awaitEnvelope(timeoutMillis = 30_000) { it.profileChunk()?.profilerId == profilerId } + } +} diff --git a/sentry-jul/build.gradle.kts b/sentry-jul/build.gradle.kts index 130b46ad5ed..451cbf1991b 100644 --- a/sentry-jul/build.gradle.kts +++ b/sentry-jul/build.gradle.kts @@ -28,6 +28,7 @@ dependencies { // tests testImplementation(projects.sentryTestSupport) testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(libs.google.truth) testImplementation(libs.kotlin.test.junit) testImplementation(libs.logback.classic) testImplementation(libs.mockito.kotlin) diff --git a/sentry-jul/src/test/kotlin/io/sentry/jul/SentryHandlerIntegrationTest.kt b/sentry-jul/src/test/kotlin/io/sentry/jul/SentryHandlerIntegrationTest.kt new file mode 100644 index 00000000000..95856f56aca --- /dev/null +++ b/sentry-jul/src/test/kotlin/io/sentry/jul/SentryHandlerIntegrationTest.kt @@ -0,0 +1,69 @@ +package io.sentry.jul + +import com.google.common.truth.Truth.assertThat +import io.sentry.Sentry +import io.sentry.SentryLevel +import io.sentry.test.MockSentryServer +import io.sentry.test.event +import io.sentry.test.hasFlag +import io.sentry.test.logs +import java.util.logging.Level +import java.util.logging.LogManager +import java.util.logging.Logger +import kotlin.test.AfterTest +import kotlin.test.Test + +class SentryHandlerIntegrationTest { + private val server = MockSentryServer() + + @AfterTest + fun teardown() { + // Back to the configuration the other tests read, which also closes this test's handler. + LogManager.getLogManager().readConfiguration() + Sentry.close() + server.close() + System.clearProperty("sentry.dsn") + } + + @Test + fun `handler configured from logging properties sends events, breadcrumbs and logs`() { + // The handler reads the DSN as external configuration, as an app's sentry.properties would + // set it. The module's test sentry.properties enables logs. + System.setProperty("sentry.dsn", server.dsn) + javaClass.getResourceAsStream("/sentry-handler-integration-test.properties")!!.use { + LogManager.getLogManager().readConfiguration(it) + } + // Outside io.sentry, whose loggers the handler drops so it doesn't capture the SDK's own logs. + val logger = Logger.getLogger("com.example.App") + + logger.config("Hello Sentry!") + Sentry.setAttribute("user.type", "admin") + Sentry.setAttribute("feature.version", 2) + Sentry.setAttribute("debug.enabled", true) + Sentry.addFeatureFlag("my-feature-flag", true) + logger.warning("important warning") + logger.log(Level.INFO, "User has made a purchase of product: %d", 445) + logger.log(Level.SEVERE, "Something went wrong", RuntimeException("Invalid productId=445")) + Sentry.flush(5000) + + val warning = server.awaitEnvelope { it.event()?.level == SentryLevel.WARNING }.event()!! + assertThat(warning.message!!.message).isEqualTo("important warning") + assertThat(warning.hasFlag("my-feature-flag", true)).isTrue() + + val error = server.awaitEnvelope { it.event()?.level == SentryLevel.ERROR }.event()!! + assertThat(error.message!!.message).isEqualTo("Something went wrong") + assertThat(error.exceptions!!.map { it.type to it.value }) + .contains("RuntimeException" to "Invalid productId=445") + assertThat(error.breadcrumbs!!.map { it.message to it.level }) + .containsAtLeast( + "Hello Sentry!" to SentryLevel.DEBUG, + "User has made a purchase of product: 445" to SentryLevel.INFO, + ) + + val logs = server.awaitEnvelope { it.logs() != null }.logs()!! + assertThat(logs.items.map { it.body }).contains("User has made a purchase of product: 445") + val failureLog = logs.items.single { it.body == "Something went wrong" } + assertThat(failureLog.attributes!!.mapValues { it.value.value }) + .containsAtLeast("user.type", "admin", "feature.version", 2, "debug.enabled", true) + } +} diff --git a/sentry-jul/src/test/resources/sentry-handler-integration-test.properties b/sentry-jul/src/test/resources/sentry-handler-integration-test.properties new file mode 100644 index 00000000000..f9e257cafcb --- /dev/null +++ b/sentry-jul/src/test/resources/sentry-handler-integration-test.properties @@ -0,0 +1,7 @@ +handlers=io.sentry.jul.SentryHandler +.level=FINEST +io.sentry.jul.SentryHandler.level=FINEST +io.sentry.jul.SentryHandler.minimumEventLevel=INFO +io.sentry.jul.SentryHandler.minimumBreadcrumbLevel=CONFIG +io.sentry.jul.SentryHandler.minimumLevel=INFO +io.sentry.jul.SentryHandler.printfStyle=true diff --git a/sentry-kafka/build.gradle.kts b/sentry-kafka/build.gradle.kts index 37240c8305e..eb293ada057 100644 --- a/sentry-kafka/build.gradle.kts +++ b/sentry-kafka/build.gradle.kts @@ -28,10 +28,13 @@ dependencies { // tests testImplementation(projects.sentryTestSupport) testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(libs.google.truth) testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.core) testImplementation(libs.kafka.clients) + // For an in-JVM broker. It brings no Spring application into the tests. + testImplementation(libs.spring.kafka3.test) } tasks.withType().configureEach { diff --git a/sentry-kafka/src/test/kotlin/io/sentry/kafka/SentryKafkaEndToEndTest.kt b/sentry-kafka/src/test/kotlin/io/sentry/kafka/SentryKafkaEndToEndTest.kt new file mode 100644 index 00000000000..2c42817a4ec --- /dev/null +++ b/sentry-kafka/src/test/kotlin/io/sentry/kafka/SentryKafkaEndToEndTest.kt @@ -0,0 +1,103 @@ +package io.sentry.kafka + +import com.google.common.truth.Truth.assertThat +import io.sentry.Sentry +import io.sentry.test.MockSentryServer +import io.sentry.test.initForTest +import io.sentry.test.transaction +import java.time.Duration +import java.util.Properties +import kotlin.test.AfterTest +import kotlin.test.Test +import org.apache.kafka.clients.consumer.ConsumerConfig +import org.apache.kafka.clients.consumer.ConsumerRecord +import org.apache.kafka.clients.consumer.KafkaConsumer +import org.apache.kafka.clients.producer.KafkaProducer +import org.apache.kafka.clients.producer.ProducerConfig +import org.apache.kafka.clients.producer.ProducerRecord +import org.apache.kafka.common.serialization.StringDeserializer +import org.apache.kafka.common.serialization.StringSerializer +import org.springframework.kafka.test.EmbeddedKafkaKraftBroker + +/** + * Sends a message through the plain Kafka client to an in-JVM KRaft broker, so no Docker is needed, + * and checks that both sides reach Sentry over the real HTTP transport. + */ +class SentryKafkaEndToEndTest { + private val server = MockSentryServer() + private val kafka = EmbeddedKafkaKraftBroker(1, 1, TOPIC).apply { afterPropertiesSet() } + + @AfterTest + fun teardown() { + Sentry.close() + kafka.destroy() + server.close() + } + + @Test + fun `a message is traced where it's published and where it's processed`() { + initForTest { + it.dsn = server.dsn + it.tracesSampleRate = 1.0 + it.isEnableQueueTracing = true + } + + val transaction = Sentry.startTransaction("kafka-demo", "demo") + transaction.makeCurrent().use { + SentryKafkaProducer.wrap(KafkaProducer(producerProperties())).use { producer + -> + producer.send(ProducerRecord(TOPIC, "sentry-kafka message")).get() + } + } + transaction.finish() + + KafkaConsumer(consumerProperties()).use { consumer -> + consumer.subscribe(listOf(TOPIC)) + val record = pollOne(consumer) + SentryKafkaConsumerTracing.withTracing(record, Runnable {}) + } + + val producer = + server.awaitEnvelope { it.transaction()?.transaction == "kafka-demo" }.transaction()!! + assertThat(producer.spans.map { it.op }).contains("queue.publish") + + val consumer = + server + .awaitEnvelope { it.transaction()?.contexts?.trace?.operation == "queue.process" } + .transaction()!! + val trace = consumer.contexts.trace!! + assertThat(trace.origin).isEqualTo(SentryKafkaConsumerTracing.TRACE_ORIGIN) + assertThat(trace.data).containsEntry("messaging.system", "kafka") + // The producer's span puts the trace in the message's headers, and the consumer continues it. + assertThat(trace.traceId).isEqualTo(producer.contexts.trace!!.traceId) + } + + private fun pollOne(consumer: KafkaConsumer): ConsumerRecord { + repeat(20) { + consumer.poll(Duration.ofMillis(500)).firstOrNull()?.let { + return it + } + } + error("No message arrived") + } + + private fun producerProperties(): Properties = + Properties().apply { + put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, kafka.brokersAsString) + put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer::class.java.name) + put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer::class.java.name) + } + + private fun consumerProperties(): Properties = + Properties().apply { + put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, kafka.brokersAsString) + put(ConsumerConfig.GROUP_ID_CONFIG, "sentry-kafka-end-to-end-test") + put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest") + put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer::class.java.name) + put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer::class.java.name) + } + + private companion object { + const val TOPIC = "sentry-topic" + } +} diff --git a/sentry-log4j2/build.gradle.kts b/sentry-log4j2/build.gradle.kts index 2d2444a2395..92f98d6c7c0 100644 --- a/sentry-log4j2/build.gradle.kts +++ b/sentry-log4j2/build.gradle.kts @@ -30,6 +30,7 @@ dependencies { // tests testImplementation(projects.sentryTestSupport) testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(libs.google.truth) testImplementation(libs.kotlin.test.junit) testImplementation(libs.log4j.api) testImplementation(libs.log4j.core) diff --git a/sentry-log4j2/src/test/kotlin/io/sentry/log4j2/SentryAppenderIntegrationTest.kt b/sentry-log4j2/src/test/kotlin/io/sentry/log4j2/SentryAppenderIntegrationTest.kt new file mode 100644 index 00000000000..76b9e55a5f9 --- /dev/null +++ b/sentry-log4j2/src/test/kotlin/io/sentry/log4j2/SentryAppenderIntegrationTest.kt @@ -0,0 +1,77 @@ +package io.sentry.log4j2 + +import com.google.common.truth.Truth.assertThat +import io.sentry.Sentry +import io.sentry.SentryLevel +import io.sentry.test.MockSentryServer +import io.sentry.test.event +import io.sentry.test.hasFlag +import io.sentry.test.logs +import kotlin.test.AfterTest +import kotlin.test.Test +import org.apache.logging.log4j.ThreadContext +import org.apache.logging.log4j.core.LoggerContext + +class SentryAppenderIntegrationTest { + private val server = MockSentryServer() + + // A context of its own, so the other tests' context keeps its configuration. + private val loggerContext = + LoggerContext( + "sentry-appender-integration-test", + null, + javaClass.getResource("/sentry-appender-integration-test.xml")!!.toURI(), + ) + + @AfterTest + fun teardown() { + loggerContext.stop() + Sentry.close() + server.close() + ThreadContext.clearAll() + System.clearProperty("integrationTestDsn") + System.clearProperty("sentry.logs.enabled") + } + + @Test + fun `appender configured from XML sends events, breadcrumbs and logs`() { + System.setProperty("integrationTestDsn", server.dsn) + // The appender has no attribute for logs, so they're enabled the way an app's sentry.properties + // would: as external configuration. + System.setProperty("sentry.logs.enabled", "true") + loggerContext.start() + val logger = loggerContext.getLogger(SentryAppenderIntegrationTest::class.java.name) + + logger.debug("Hello Sentry!") + ThreadContext.put("userId", "user-1") + Sentry.setAttribute("user.type", "admin") + Sentry.setAttribute("feature.version", 2) + Sentry.setAttribute("debug.enabled", true) + Sentry.addFeatureFlag("my-feature-flag", true) + logger.info("User has made a purchase of product: {}", 445) + logger.warn("Important warning") + logger.error("Something went wrong", RuntimeException("Invalid productId=445")) + Sentry.flush(5000) + + val warning = server.awaitEnvelope { it.event()?.level == SentryLevel.WARNING }.event()!! + assertThat(warning.message!!.message).isEqualTo("Important warning") + assertThat(warning.tags).containsEntry("userId", "user-1") + assertThat(warning.hasFlag("my-feature-flag", true)).isTrue() + + val error = server.awaitEnvelope { it.event()?.level == SentryLevel.ERROR }.event()!! + assertThat(error.message!!.formatted).isEqualTo("Something went wrong") + assertThat(error.exceptions!!.map { it.type to it.value }) + .contains("RuntimeException" to "Invalid productId=445") + assertThat(error.breadcrumbs!!.map { it.message to it.level }) + .containsAtLeast( + "Hello Sentry!" to SentryLevel.DEBUG, + "User has made a purchase of product: 445" to SentryLevel.INFO, + ) + + val logs = server.awaitEnvelope { it.logs() != null }.logs()!! + assertThat(logs.items.map { it.body }).contains("User has made a purchase of product: 445") + val failureLog = logs.items.single { it.body == "Something went wrong" } + assertThat(failureLog.attributes!!.mapValues { it.value.value }) + .containsAtLeast("user.type", "admin", "feature.version", 2, "debug.enabled", true) + } +} diff --git a/sentry-log4j2/src/test/resources/sentry-appender-integration-test.xml b/sentry-log4j2/src/test/resources/sentry-appender-integration-test.xml new file mode 100644 index 00000000000..55ca1301e37 --- /dev/null +++ b/sentry-log4j2/src/test/resources/sentry-appender-integration-test.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + diff --git a/sentry-opentelemetry/sentry-opentelemetry-agentless/api/sentry-opentelemetry-agentless.api b/sentry-opentelemetry/sentry-opentelemetry-agentless/api/sentry-opentelemetry-agentless.api new file mode 100644 index 00000000000..45011c308e2 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-agentless/api/sentry-opentelemetry-agentless.api @@ -0,0 +1,9 @@ +public final class io/sentry/opentelemetry/agent/AgentlessMarker { + public fun ()V +} + +public final class io/sentry/opentelemetry/agentless/BuildConfig { + public static final field SENTRY_OPENTELEMETRY_AGENTLESS_SDK_NAME Ljava/lang/String; + public static final field VERSION_NAME Ljava/lang/String; +} + diff --git a/sentry-opentelemetry/sentry-opentelemetry-agentless/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agentless/build.gradle.kts index 72508d73737..32d029746c1 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agentless/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agentless/build.gradle.kts @@ -1,10 +1,17 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + plugins { `java-library` id("io.sentry.javadoc") + alias(libs.plugins.kotlin.jvm) alias(libs.plugins.buildconfig) id("io.sentry.animalsniffer") } +tasks.named("compileKotlin") { + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 +} + dependencies { api(projects.sentry) implementation(projects.sentryOpentelemetry.sentryOpentelemetryBootstrap) @@ -13,8 +20,17 @@ dependencies { api(libs.otel.semconv) api(libs.otel.semconv.incubating) api(libs.otel.extension.autoconfigure) + + testImplementation(projects.sentryAsyncProfiler) + testImplementation(projects.sentryTestSupport) + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.google.truth) } +// Sentry's OpenTelemetry customizer only initializes Sentry when the environment asks it to. +tasks.test { environment("SENTRY_AUTO_INIT", "true") } + buildConfig { useJavaOutput() packageName("io.sentry.opentelemetry.agentless") diff --git a/sentry-opentelemetry/sentry-opentelemetry-agentless/src/test/kotlin/io/sentry/opentelemetry/agentless/AgentlessEndToEndTest.kt b/sentry-opentelemetry/sentry-opentelemetry-agentless/src/test/kotlin/io/sentry/opentelemetry/agentless/AgentlessEndToEndTest.kt new file mode 100644 index 00000000000..8f9d5d0fb8a --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-agentless/src/test/kotlin/io/sentry/opentelemetry/agentless/AgentlessEndToEndTest.kt @@ -0,0 +1,163 @@ +package io.sentry.opentelemetry.agentless + +import com.google.common.truth.Truth.assertThat +import io.opentelemetry.api.GlobalOpenTelemetry +import io.opentelemetry.sdk.OpenTelemetrySdk +import io.opentelemetry.sdk.autoconfigure.AutoConfiguredOpenTelemetrySdk +import io.sentry.Breadcrumb +import io.sentry.EventProcessor +import io.sentry.Hint +import io.sentry.Sentry +import io.sentry.SentryEvent +import io.sentry.SentryLevel +import io.sentry.protocol.Message +import io.sentry.test.MockSentryServer +import io.sentry.test.event +import io.sentry.test.hasFlag +import io.sentry.test.hasMetric +import io.sentry.test.hasMetricWithAttribute +import io.sentry.test.metrics +import io.sentry.test.profileChunk +import io.sentry.test.transaction +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.Test + +/** + * Sets Sentry up the way an app without the agent does: the OpenTelemetry SDK's autoconfiguration + * loads Sentry's customizer, which initializes Sentry and has it trace through OpenTelemetry. + */ +class AgentlessEndToEndTest { + private val server = MockSentryServer() + private var openTelemetry: OpenTelemetrySdk? = null + + @AfterTest + fun teardown() { + openTelemetry?.close() + GlobalOpenTelemetry.resetForTest() + Sentry.close() + server.close() + SENTRY_PROPERTIES.forEach { System.clearProperty(it) } + } + + @Test + fun `Sentry set up by OpenTelemetry reports events, a profiled transaction and metrics`() { + // The customizer's Sentry.init reads these as external configuration. + System.setProperty("sentry.dsn", server.dsn) + System.setProperty("sentry.traces-sample-rate", "1.0") + System.setProperty("sentry.profile-session-sample-rate", "1.0") + System.setProperty("sentry.profile-lifecycle", "TRACE") + // The profiler rejects a traces path with a dot anywhere in it, which the module's absolute + // path can have. + System.setProperty( + "sentry.profiling-traces-dir-path", + "${System.getProperty("java.io.tmpdir")}/sentry-agentless-end-to-end-test", + ) + val openTelemetry = + AutoConfiguredOpenTelemetrySdk.builder() + .setResultAsGlobal() + .addPropertiesSupplier { + mapOf( + "otel.logs.exporter" to "none", + "otel.metrics.exporter" to "none", + "otel.traces.exporter" to "none", + ) + } + .build() + .openTelemetrySdk + this.openTelemetry = openTelemetry + + Sentry.configureScope { + it.addEventProcessor( + object : EventProcessor { + override fun process(event: SentryEvent, hint: Hint): SentryEvent = event.apply { + if ((level?.ordinal ?: 0) > SentryLevel.INFO.ordinal) { + addBreadcrumb(Breadcrumb("Processed by the event processor")) + } + } + } + ) + } + Sentry.withScope { + it.level = SentryLevel.FATAL + Sentry.captureMessage("Fatal message!") + } + Sentry.captureMessage("Some warning!", SentryLevel.WARNING) + Sentry.addFeatureFlag("my-feature-flag", true) + Sentry.setAttribute("user.type", "admin") + Sentry.setAttribute("feature.version", 2) + Sentry.metrics().count("countMetric") + Sentry.metrics().gauge("gaugeMetric", 5.0) + Sentry.metrics().distribution("distributionMetric", 7.0) + Sentry.captureException(RuntimeException("Some error!")) + Sentry.captureEvent( + SentryEvent().apply { + message = Message().apply { message = "Detailed event" } + fingerprints = listOf("NewClientDebug") + level = SentryLevel.DEBUG + } + ) + repeat(10) { i -> + Sentry.captureEvent( + SentryEvent().apply { message = Message().apply { message = "event $i" } } + ) + } + val transaction = Sentry.startTransaction("transaction name", "op") + val span = transaction.startChild("child") + Thread.sleep(100) + span.finish() + transaction.finish() + Sentry.flush(5000) + // Sentry exports OpenTelemetry spans in batches every 5 seconds. + openTelemetry.sdkTracerProvider.forceFlush().join(5, TimeUnit.SECONDS) + + val fatal = + server.awaitEnvelope { it.event()?.message?.formatted == "Fatal message!" }.event()!! + assertThat(fatal.level).isEqualTo(SentryLevel.FATAL) + assertThat(fatal.breadcrumbs!!.map { it.message }).contains("Processed by the event processor") + + val warning = + server.awaitEnvelope { it.event()?.message?.formatted == "Some warning!" }.event()!! + assertThat(warning.level).isEqualTo(SentryLevel.WARNING) + + val error = + server + .awaitEnvelope { it.event()?.exceptions?.any { e -> e.value == "Some error!" } == true } + .event()!! + assertThat(error.exceptions!!.map { it.type to it.value }) + .contains("RuntimeException" to "Some error!") + assertThat(error.hasFlag("my-feature-flag", true)).isTrue() + + val detailed = + server.awaitEnvelope { it.event()?.message?.message == "Detailed event" }.event()!! + assertThat(detailed.fingerprints).containsExactly("NewClientDebug") + assertThat(detailed.level).isEqualTo(SentryLevel.DEBUG) + + repeat(10) { i -> server.awaitEnvelope { it.event()?.message?.message == "event $i" } } + + val sent = + server.awaitEnvelope { it.transaction()?.transaction == "transaction name" }.transaction()!! + assertThat(sent.spans.map { it.op }).contains("child") + val profilerId = sent.contexts.profile?.profilerId + assertThat(profilerId).isNotNull() + server.awaitEnvelope(timeoutMillis = 30_000) { it.profileChunk()?.profilerId == profilerId } + + val metrics = server.awaitEnvelope { it.metrics() != null }.metrics()!! + assertThat(metrics.hasMetric("countMetric", "counter", 1.0)).isTrue() + assertThat(metrics.hasMetric("gaugeMetric", "gauge", 5.0)).isTrue() + assertThat(metrics.hasMetric("distributionMetric", "distribution", 7.0)).isTrue() + assertThat(metrics.hasMetricWithAttribute("countMetric", "user.type", "admin")).isTrue() + assertThat(metrics.hasMetricWithAttribute("countMetric", "feature.version", 2)).isTrue() + } + + private companion object { + val SENTRY_PROPERTIES = + listOf( + "sentry.dsn", + "sentry.traces-sample-rate", + "sentry.profile-session-sample-rate", + "sentry.profile-lifecycle", + "sentry.profiling-traces-dir-path", + ) + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts index afab9ef180f..fb95d722d6f 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts @@ -34,6 +34,7 @@ dependencies { testImplementation(projects.sentryTestSupport) testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(libs.awaitility.kotlin) + testImplementation(libs.google.truth) testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.kotlin) diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OtlpEndToEndTest.kt b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OtlpEndToEndTest.kt new file mode 100644 index 00000000000..fb3db9731e8 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OtlpEndToEndTest.kt @@ -0,0 +1,66 @@ +package io.sentry.opentelemetry.otlp + +import com.google.common.truth.Truth.assertThat +import io.opentelemetry.sdk.OpenTelemetrySdk +import io.opentelemetry.sdk.trace.SdkTracerProvider +import io.sentry.Sentry +import io.sentry.test.MockSentryServer +import io.sentry.test.event +import io.sentry.test.hasMetric +import io.sentry.test.initForTest +import io.sentry.test.logs +import io.sentry.test.metrics +import kotlin.test.AfterTest +import kotlin.test.Test + +/** + * An app that exports its OpenTelemetry spans over OTLP and reports everything else to Sentry, as + * the console OTLP sample does. + */ +class OtlpEndToEndTest { + private val server = MockSentryServer() + private val openTelemetry = + OpenTelemetrySdk.builder().setTracerProvider(SdkTracerProvider.builder().build()).build() + + @AfterTest + fun teardown() { + Sentry.close() + openTelemetry.close() + server.close() + } + + @Test + fun `an event inside an OpenTelemetry span is linked to it`() { + initForTest { + it.dsn = server.dsn + it.logs.isEnabled = true + it.addEventProcessor(OpenTelemetryOtlpEventProcessor()) + } + + val span = openTelemetry.getTracer("otlp-end-to-end-test").spanBuilder("otelSpan").startSpan() + span.makeCurrent().use { + Sentry.captureMessage("this message is connected to the outerSpan") + Sentry.logger().error("Some error log message") + Sentry.metrics().count("invocations") + } + span.end() + Sentry.flush(5000) + + val event = + server + .awaitEnvelope { + it.event()?.message?.formatted == "this message is connected to the outerSpan" + } + .event()!! + // The span's trace goes over OTLP, so the event points to it rather than to a Sentry span. + val trace = event.contexts.trace!! + assertThat(trace.traceId.toString()).isEqualTo(span.spanContext.traceId) + assertThat(trace.spanId.toString()).isEqualTo(span.spanContext.spanId) + + val logs = server.awaitEnvelope { it.logs() != null }.logs()!! + assertThat(logs.items.map { it.body }).contains("Some error log message") + + val metrics = server.awaitEnvelope { it.metrics() != null }.metrics()!! + assertThat(metrics.hasMetric("invocations", "counter", 1.0)).isTrue() + } +} diff --git a/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts index 57d1dea0e8d..7bac9a5a200 100644 --- a/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts @@ -1,32 +1,15 @@ -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile - plugins { java application - alias(libs.plugins.kotlin.jvm) alias(libs.plugins.gradle.versions) alias(libs.plugins.shadow) - id("io.sentry.systemtest") } application { mainClass.set("io.sentry.samples.console.Main") } -tasks.withType().configureEach { - kotlin { - compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") - } -} - dependencies { implementation(projects.sentryOpentelemetry.sentryOpentelemetryAgentless) implementation(projects.sentryAsyncProfiler) - - testImplementation(kotlin(Config.kotlinStdLib)) - testImplementation(projects.sentry) - testImplementation(projects.sentrySystemTestSupport) - testImplementation(libs.kotlin.test.junit) - testImplementation(libs.slf4j.api) - testImplementation(libs.slf4j.jdk14) } // Configure the Shadow JAR (executable JAR with all dependencies) @@ -45,22 +28,3 @@ tasks.jar { // Fix the startScripts task dependency tasks.startScripts { dependsOn(tasks.shadowJar) } - -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-console-opentelemetry-noagent/src/test/kotlin/sentry/DummyTest.kt b/sentry-samples/sentry-samples-console-opentelemetry-noagent/src/test/kotlin/sentry/DummyTest.kt deleted file mode 100644 index 6f762b7e453..00000000000 --- a/sentry-samples/sentry-samples-console-opentelemetry-noagent/src/test/kotlin/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-console-opentelemetry-noagent/src/test/kotlin/sentry/systemtest/ConsoleApplicationSystemTest.kt b/sentry-samples/sentry-samples-console-opentelemetry-noagent/src/test/kotlin/sentry/systemtest/ConsoleApplicationSystemTest.kt deleted file mode 100644 index 4f21ebefd70..00000000000 --- a/sentry-samples/sentry-samples-console-opentelemetry-noagent/src/test/kotlin/sentry/systemtest/ConsoleApplicationSystemTest.kt +++ /dev/null @@ -1,119 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.protocol.SentryId -import io.sentry.systemtest.util.TestHelper -import java.util.concurrent.TimeUnit -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test - -class ConsoleApplicationSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8000") - testHelper.reset() - } - - @Test - fun `console application sends expected events when run as JAR`() { - val jarFile = testHelper.findJar("sentry-samples-console-opentelemetry-noagent") - val process = - testHelper.launch( - jarFile, - mapOf( - "SENTRY_DSN" to testHelper.dsn, - // "SENTRY_AUTO_INIT" to "true", - "SENTRY_TRACES_SAMPLE_RATE" to "1.0", - "SENTRY_ENABLE_PRETTY_SERIALIZATION_OUTPUT" to "false", - "SENTRY_DEBUG" to "true", - "SENTRY_PROFILE_SESSION_SAMPLE_RATE" to "1.0", - "SENTRY_PROFILE_LIFECYCLE" to "TRACE", - "OTEL_METRICS_EXPORTER" to "none", - "OTEL_LOGS_EXPORTER" to "none", - "OTEL_TRACES_EXPORTER" to "none", - ), - ) - - process.waitFor(30, TimeUnit.SECONDS) - assertEquals(0, process.exitValue()) - - // Verify that we received the expected events - verifyExpectedEvents() - } - - private fun verifyExpectedEvents() { - var profilerId: SentryId? = null - - // Verify we received a "Fatal message!" event - testHelper.ensureErrorReceived { event -> - event.message?.formatted == "Fatal message!" && event.level?.name == "FATAL" - } - - // Verify we received a "Some warning!" event - testHelper.ensureErrorReceived { event -> - event.message?.formatted == "Some warning!" && event.level?.name == "WARNING" - } - - // Verify we received the RuntimeException - testHelper.ensureErrorReceived { event -> - event.exceptions?.any { ex -> ex.type == "RuntimeException" && ex.value == "Some error!" } == - true && testHelper.doesEventHaveFlag(event, "my-feature-flag", true) - } - - // Verify we received the detailed event with fingerprint - testHelper.ensureErrorReceived { event -> - event.message?.message == "Detailed event" && - event.fingerprints?.contains("NewClientDebug") == true && - event.level?.name == "DEBUG" - } - - // Verify we received transaction events - testHelper.ensureTransactionReceived { transaction, _ -> - profilerId = transaction.contexts.profile?.profilerId - transaction.transaction == "transaction name" && - transaction.spans?.any { span -> span.op == "child" } == true - } - - testHelper.ensureProfileChunkReceived { profileChunk, envelopeHeader -> - profileChunk.profilerId == profilerId - } - - // Verify we received the loop messages (should be 10 of them) - var loopMessageCount = 0 - try { - for (i in 0..9) { - testHelper.ensureErrorReceived { event -> - val matches = - event.message?.message?.contains("items we'll wait to flush to Sentry!") == true - if (matches) loopMessageCount++ - matches - } - } - } catch (e: Exception) { - // Some loop messages might be missing, but we should have at least some - } - - assertTrue( - "Should receive at least 5 loop messages, got $loopMessageCount", - loopMessageCount >= 5, - ) - - // Verify we have breadcrumbs - testHelper.ensureErrorReceived { event -> - event.breadcrumbs?.any { breadcrumb -> - breadcrumb.message?.contains("Processed by") == true - } == true - } - - testHelper.ensureMetricsReceived { metricsEvents, sentryEnvelopeHeader -> - testHelper.doesContainMetric(metricsEvents, "countMetric", "counter", 1.0) && - testHelper.doesContainMetric(metricsEvents, "gaugeMetric", "gauge", 5.0) && - testHelper.doesContainMetric(metricsEvents, "distributionMetric", "distribution", 7.0) && - testHelper.doesMetricHaveAttribute(metricsEvents, "countMetric", "user.type", "admin") && - testHelper.doesMetricHaveAttribute(metricsEvents, "countMetric", "feature.version", 2) - } - } -} diff --git a/sentry-samples/sentry-samples-console-otlp/build.gradle.kts b/sentry-samples/sentry-samples-console-otlp/build.gradle.kts index 58a8578c7d1..134697014b7 100644 --- a/sentry-samples/sentry-samples-console-otlp/build.gradle.kts +++ b/sentry-samples/sentry-samples-console-otlp/build.gradle.kts @@ -1,35 +1,18 @@ -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile - plugins { java application - alias(libs.plugins.kotlin.jvm) alias(libs.plugins.gradle.versions) alias(libs.plugins.shadow) - id("io.sentry.systemtest") } application { mainClass.set("io.sentry.samples.console.Main") } -tasks.withType().configureEach { - kotlin { - compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") - } -} - dependencies { implementation(projects.sentryOpentelemetry.sentryOpentelemetryOtlp) implementation(projects.sentryAsyncProfiler) implementation(libs.otel.semconv) implementation(libs.otel.semconv.incubating) implementation(libs.otel.exporter.logging) - - testImplementation(kotlin(Config.kotlinStdLib)) - testImplementation(projects.sentry) - testImplementation(projects.sentrySystemTestSupport) - testImplementation(libs.kotlin.test.junit) - testImplementation(libs.slf4j.api) - testImplementation(libs.slf4j.jdk14) } // Configure the Shadow JAR (executable JAR with all dependencies) @@ -48,22 +31,3 @@ tasks.jar { // Fix the startScripts task dependency tasks.startScripts { dependsOn(tasks.shadowJar) } - -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-console-otlp/src/test/kotlin/io/sentry/DummyTest.kt b/sentry-samples/sentry-samples-console-otlp/src/test/kotlin/io/sentry/DummyTest.kt deleted file mode 100644 index 6f762b7e453..00000000000 --- a/sentry-samples/sentry-samples-console-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-console-otlp/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt b/sentry-samples/sentry-samples-console-otlp/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt deleted file mode 100644 index 1333fa50387..00000000000 --- a/sentry-samples/sentry-samples-console-otlp/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt +++ /dev/null @@ -1,107 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.systemtest.util.TestHelper -import java.util.concurrent.TimeUnit -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test - -class ConsoleApplicationSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8000") - testHelper.reset() - } - - @Test - fun `console application sends expected events when run as JAR`() { - val jarFile = testHelper.findJar("sentry-samples-console-otlp") - val process = - testHelper.launch( - jarFile, - mapOf( - "SENTRY_DSN" to testHelper.dsn, - "SENTRY_ENABLE_PRETTY_SERIALIZATION_OUTPUT" to "false", - "SENTRY_DEBUG" to "true", - ), - ) - - process.waitFor(30, TimeUnit.SECONDS) - assertEquals(0, process.exitValue()) - - // Verify that we received the expected events - verifyExpectedEvents() - } - - private fun verifyExpectedEvents() { - // Verify we received a "Fatal message!" event - testHelper.ensureErrorReceived { event -> - event.message?.formatted == "Fatal message!" && event.level?.name == "FATAL" - } - - // Verify we received a "Some warning!" event - testHelper.ensureErrorReceived { event -> - event.message?.formatted == "Some warning!" && event.level?.name == "WARNING" - } - - // Verify we received the RuntimeException - testHelper.ensureErrorReceived { event -> - event.exceptions?.any { ex -> ex.type == "RuntimeException" && ex.value == "Some error!" } == - true && testHelper.doesEventHaveFlag(event, "my-feature-flag", true) - } - - // Verify we received the detailed event with fingerprint - testHelper.ensureErrorReceived { event -> - event.message?.message == "Detailed event" && - event.fingerprints?.contains("NewClientDebug") == true && - event.level?.name == "DEBUG" - } - - // Verify we received the loop messages (should be 10 of them) - var loopMessageCount = 0 - try { - for (i in 0..9) { - testHelper.ensureErrorReceived { event -> - val matches = - event.message?.message?.contains("items we'll wait to flush to Sentry!") == true - if (matches) loopMessageCount++ - matches - } - } - } catch (e: Exception) { - // Some loop messages might be missing, but we should have at least some - } - - assertTrue( - "Should receive at least 5 loop messages, got $loopMessageCount", - loopMessageCount >= 5, - ) - - // Verify we received the message captured within the OTel span - testHelper.ensureErrorReceived { event -> - event.message?.formatted == "this message is connected to the outerSpan" - } - - // Verify we have breadcrumbs - testHelper.ensureErrorReceived { event -> - event.breadcrumbs?.any { breadcrumb -> - breadcrumb.message?.contains("Processed by") == true - } == true - } - - testHelper.ensureMetricsReceived { metricsEvents, sentryEnvelopeHeader -> - testHelper.doesContainMetric(metricsEvents, "countMetric", "counter", 1.0) && - testHelper.doesContainMetric(metricsEvents, "gaugeMetric", "gauge", 5.0) && - testHelper.doesContainMetric(metricsEvents, "distributionMetric", "distribution", 7.0) && - testHelper.doesContainMetric(metricsEvents, "invocations", "counter", 1.0) - } - - // Verify we received the log message captured within the OTel span - testHelper.ensureLogsReceived { logs, _ -> - testHelper.doesContainLogWithBody(logs, "Some error log message") - } - } -} diff --git a/sentry-samples/sentry-samples-console/build.gradle.kts b/sentry-samples/sentry-samples-console/build.gradle.kts index f0b0d862896..d691d7ceb24 100644 --- a/sentry-samples/sentry-samples-console/build.gradle.kts +++ b/sentry-samples/sentry-samples-console/build.gradle.kts @@ -1,22 +1,12 @@ -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile - plugins { java application - alias(libs.plugins.kotlin.jvm) alias(libs.plugins.gradle.versions) alias(libs.plugins.shadow) - id("io.sentry.systemtest") } application { mainClass.set("io.sentry.samples.console.Main") } -tasks.withType().configureEach { - kotlin { - compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") - } -} - dependencies { implementation(projects.sentry) implementation(projects.sentryAsyncProfiler) @@ -25,13 +15,6 @@ dependencies { implementation(libs.jcache) implementation(libs.caffeine.jcache) implementation(libs.kafka.clients) - - testImplementation(kotlin(Config.kotlinStdLib)) - testImplementation(projects.sentry) - testImplementation(projects.sentrySystemTestSupport) - testImplementation(libs.kotlin.test.junit) - testImplementation(libs.slf4j.api) - testImplementation(libs.slf4j.jdk14) } // Configure the Shadow JAR (executable JAR with all dependencies) @@ -49,22 +32,3 @@ tasks.jar { // Fix the startScripts task dependency tasks.startScripts { dependsOn(tasks.shadowJar) } - -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-console/src/test/kotlin/io/sentry/DummyTest.kt b/sentry-samples/sentry-samples-console/src/test/kotlin/io/sentry/DummyTest.kt deleted file mode 100644 index 6f762b7e453..00000000000 --- a/sentry-samples/sentry-samples-console/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-console/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt b/sentry-samples/sentry-samples-console/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt deleted file mode 100644 index db6f54a616b..00000000000 --- a/sentry-samples/sentry-samples-console/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt +++ /dev/null @@ -1,137 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.protocol.SentryId -import io.sentry.systemtest.util.TestHelper -import java.util.concurrent.TimeUnit -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test - -class ConsoleApplicationSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8000") - testHelper.reset() - } - - @Test - fun `console application sends expected events when run as JAR`() { - val process = launchConsoleProcess() - - process.waitFor(30, TimeUnit.SECONDS) - assertEquals(0, process.exitValue()) - - // Verify that we received the expected events - verifyExpectedEvents() - } - - @Test - fun `console application sends kafka producer and consumer tracing when kafka is enabled`() { - val process = - launchConsoleProcess(mapOf("SENTRY_SAMPLE_KAFKA_BOOTSTRAP_SERVERS" to "localhost:9092")) - - process.waitFor(30, TimeUnit.SECONDS) - assertEquals(0, process.exitValue()) - - testHelper.ensureTransactionReceived { transaction, _ -> - transaction.transaction == "kafka-demo" && - testHelper.doesTransactionContainSpanWithOp(transaction, "queue.publish") - } - - testHelper.ensureTransactionReceived { transaction, _ -> - testHelper.doesTransactionHaveOp(transaction, "queue.process") && - transaction.contexts.trace?.origin == "manual.queue.kafka.consumer" && - transaction.contexts.trace?.data?.get("messaging.system") == "kafka" - } - } - - private fun launchConsoleProcess(overrides: Map = emptyMap()): Process { - val jarFile = testHelper.findJar("sentry-samples-console") - val env = - mutableMapOf( - "SENTRY_DSN" to testHelper.dsn, - "SENTRY_TRACES_SAMPLE_RATE" to "1.0", - "SENTRY_ENABLE_PRETTY_SERIALIZATION_OUTPUT" to "false", - "SENTRY_DEBUG" to "true", - "SENTRY_PROFILE_SESSION_SAMPLE_RATE" to "1.0", - "SENTRY_PROFILE_LIFECYCLE" to "TRACE", - ) - env.putAll(overrides) - return testHelper.launch(jarFile, env) - } - - private fun verifyExpectedEvents() { - var profilerId: SentryId? = null - // Verify we received a "Fatal message!" event - testHelper.ensureErrorReceived { event -> - event.message?.formatted == "Fatal message!" && event.level?.name == "FATAL" - } - - // Verify we received a "Some warning!" event - testHelper.ensureErrorReceived { event -> - event.message?.formatted == "Some warning!" && event.level?.name == "WARNING" - } - - // Verify we received the RuntimeException - testHelper.ensureErrorReceived { event -> - event.exceptions?.any { ex -> ex.type == "RuntimeException" && ex.value == "Some error!" } == - true && testHelper.doesEventHaveFlag(event, "my-feature-flag", true) - } - - // Verify we received the detailed event with fingerprint - testHelper.ensureErrorReceived { event -> - event.message?.message == "Detailed event" && - event.fingerprints?.contains("NewClientDebug") == true && - event.level?.name == "DEBUG" - } - - // Verify we received transaction events - testHelper.ensureTransactionReceived { transaction, _ -> - profilerId = transaction.contexts.profile?.profilerId - transaction.transaction == "transaction name" && - transaction.spans?.any { span -> span.op == "child" } == true - } - - testHelper.ensureProfileChunkReceived { profileChunk, envelopeHeader -> - profileChunk.profilerId == profilerId - } - - // Verify we received the loop messages (should be 10 of them) - var loopMessageCount = 0 - try { - for (i in 0..9) { - testHelper.ensureErrorReceived { event -> - val matches = - event.message?.message?.contains("items we'll wait to flush to Sentry!") == true - if (matches) loopMessageCount++ - matches - } - } - } catch (e: Exception) { - // Some loop messages might be missing, but we should have at least some - } - - assertTrue( - "Should receive at least 5 loop messages, got $loopMessageCount", - loopMessageCount >= 5, - ) - - // Verify we have breadcrumbs - testHelper.ensureErrorReceived { event -> - event.breadcrumbs?.any { breadcrumb -> - breadcrumb.message?.contains("Processed by") == true - } == true - } - - testHelper.ensureMetricsReceived { metricsEvents, sentryEnvelopeHeader -> - testHelper.doesContainMetric(metricsEvents, "countMetric", "counter", 1.0) && - testHelper.doesContainMetric(metricsEvents, "gaugeMetric", "gauge", 5.0) && - testHelper.doesContainMetric(metricsEvents, "distributionMetric", "distribution", 7.0) && - testHelper.doesMetricHaveAttribute(metricsEvents, "countMetric", "user.type", "admin") && - testHelper.doesMetricHaveAttribute(metricsEvents, "countMetric", "feature.version", 2) - } - } -} diff --git a/sentry-samples/sentry-samples-jul/build.gradle.kts b/sentry-samples/sentry-samples-jul/build.gradle.kts index 6fa4bbb8c8b..981455c455e 100644 --- a/sentry-samples/sentry-samples-jul/build.gradle.kts +++ b/sentry-samples/sentry-samples-jul/build.gradle.kts @@ -1,32 +1,15 @@ -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile - plugins { java application - alias(libs.plugins.kotlin.jvm) alias(libs.plugins.gradle.versions) alias(libs.plugins.shadow) - id("io.sentry.systemtest") } application { mainClass.set("io.sentry.samples.jul.Main") } -tasks.withType().configureEach { - kotlin { - compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") - } -} - dependencies { implementation(projects.sentryJul) implementation(libs.slf4j.jdk14) - - testImplementation(kotlin(Config.kotlinStdLib)) - testImplementation(projects.sentry) - testImplementation(projects.sentrySystemTestSupport) - testImplementation(libs.kotlin.test.junit) - testImplementation(libs.slf4j.api) - testImplementation(libs.slf4j.jdk14) } // Configure the Shadow JAR (executable JAR with all dependencies) @@ -44,22 +27,3 @@ tasks.jar { // Fix the startScripts task dependency tasks.startScripts { dependsOn(tasks.shadowJar) } - -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-jul/src/test/kotlin/io/sentry/DummyTest.kt b/sentry-samples/sentry-samples-jul/src/test/kotlin/io/sentry/DummyTest.kt deleted file mode 100644 index 6f762b7e453..00000000000 --- a/sentry-samples/sentry-samples-jul/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-jul/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt b/sentry-samples/sentry-samples-jul/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt deleted file mode 100644 index 9750dcd728f..00000000000 --- a/sentry-samples/sentry-samples-jul/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt +++ /dev/null @@ -1,83 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.SentryLevel -import io.sentry.systemtest.util.TestHelper -import java.util.concurrent.TimeUnit -import org.junit.Assert -import org.junit.Before -import org.junit.Test - -class ConsoleApplicationSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8000") - testHelper.reset() - } - - @Test - fun `jul application sends expected events when run as JAR`() { - val jarFile = testHelper.findJar("sentry-samples-jul") - val process = - testHelper.launch( - jarFile, - mapOf( - "SENTRY_DSN" to testHelper.dsn, - "SENTRY_TRACES_SAMPLE_RATE" to "1.0", - "SENTRY_ENABLE_PRETTY_SERIALIZATION_OUTPUT" to "false", - "SENTRY_DEBUG" to "true", - ), - ) - - process.waitFor(30, TimeUnit.SECONDS) - Assert.assertEquals(0, process.exitValue()) - - // Verify that we received the expected events - verifyExpectedEvents() - } - - private fun verifyExpectedEvents() { - // Verify we received the RuntimeException - testHelper.ensureErrorReceived { event -> - event.exceptions?.any { ex -> - ex.type == "RuntimeException" && ex.value == "Invalid productId=445" - } == true && event.message?.message == "Something went wrong" && event.level?.name == "ERROR" - } - - testHelper.ensureErrorReceived { event -> - event.breadcrumbs?.firstOrNull { - it.message == "Hello Sentry!" && it.level == SentryLevel.DEBUG - } != null - } - - testHelper.ensureErrorReceived { event -> - event.breadcrumbs?.firstOrNull { - it.message == "User has made a purchase of product: 445" && it.level == SentryLevel.INFO - } != null - } - - testHelper.ensureErrorReceived { event -> - event.message?.message == "important warning" && - testHelper.doesEventHaveFlag(event, "my-feature-flag", true) - } - - testHelper.ensureLogsReceived { logs, _ -> - testHelper.doesContainLogWithBody(logs, "User has made a purchase of product: 445") && - testHelper.doesContainLogWithBody(logs, "Something went wrong") && - testHelper.doesLogWithBodyHaveAttribute( - logs, - "Something went wrong", - "user.type", - "admin", - ) && - testHelper.doesLogWithBodyHaveAttribute( - logs, - "Something went wrong", - "feature.version", - 2, - ) && - testHelper.doesLogWithBodyHaveAttribute(logs, "Something went wrong", "debug.enabled", true) - } - } -} diff --git a/sentry-samples/sentry-samples-log4j2/build.gradle.kts b/sentry-samples/sentry-samples-log4j2/build.gradle.kts index 901604d2cff..d320d2b3334 100644 --- a/sentry-samples/sentry-samples-log4j2/build.gradle.kts +++ b/sentry-samples/sentry-samples-log4j2/build.gradle.kts @@ -1,33 +1,16 @@ -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile - plugins { java application - alias(libs.plugins.kotlin.jvm) alias(libs.plugins.gradle.versions) alias(libs.plugins.shadow) - id("io.sentry.systemtest") } application { mainClass.set("io.sentry.samples.log4j2.Main") } -tasks.withType().configureEach { - kotlin { - compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") - } -} - dependencies { implementation(projects.sentryLog4j2) implementation(libs.log4j.api) implementation(libs.log4j.core) - - testImplementation(kotlin(Config.kotlinStdLib)) - testImplementation(projects.sentry) - testImplementation(projects.sentrySystemTestSupport) - testImplementation(libs.kotlin.test.junit) - testImplementation(libs.slf4j.api) - testImplementation(libs.slf4j.jdk14) } // Configure the Shadow JAR (executable JAR with all dependencies) @@ -50,22 +33,3 @@ tasks.jar { // Fix the startScripts task dependency tasks.startScripts { dependsOn(tasks.shadowJar) } - -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-log4j2/src/test/kotlin/io/sentry/DummyTest.kt b/sentry-samples/sentry-samples-log4j2/src/test/kotlin/io/sentry/DummyTest.kt deleted file mode 100644 index 6f762b7e453..00000000000 --- a/sentry-samples/sentry-samples-log4j2/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-log4j2/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt b/sentry-samples/sentry-samples-log4j2/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt deleted file mode 100644 index e22488c2f36..00000000000 --- a/sentry-samples/sentry-samples-log4j2/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt +++ /dev/null @@ -1,85 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.SentryLevel -import io.sentry.systemtest.util.TestHelper -import java.util.concurrent.TimeUnit -import org.junit.Assert -import org.junit.Before -import org.junit.Test - -class ConsoleApplicationSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8000") - testHelper.reset() - } - - @Test - fun `log4j2 application sends expected events when run as JAR`() { - val jarFile = testHelper.findJar("sentry-samples-log4j2") - val process = - testHelper.launch( - jarFile, - mapOf( - "SENTRY_DSN" to testHelper.dsn, - "SENTRY_TRACES_SAMPLE_RATE" to "1.0", - "SENTRY_ENABLE_PRETTY_SERIALIZATION_OUTPUT" to "false", - "SENTRY_DEBUG" to "true", - ), - ) - - process.waitFor(30, TimeUnit.SECONDS) - Assert.assertEquals(0, process.exitValue()) - - // Verify that we received the expected events - verifyExpectedEvents() - } - - private fun verifyExpectedEvents() { - // Verify we received the RuntimeException - testHelper.ensureErrorReceived { event -> - event.exceptions?.any { ex -> - ex.type == "RuntimeException" && ex.value == "Invalid productId=445" - } == true && - event.message?.formatted == "Something went wrong" && - event.level?.name == "ERROR" - } - - testHelper.ensureErrorReceived { event -> - event.message?.message == "Important warning" && - testHelper.doesEventHaveFlag(event, "my-feature-flag", true) - } - - testHelper.ensureErrorReceived { event -> - event.breadcrumbs?.firstOrNull { - it.message == "Hello Sentry!" && it.level == SentryLevel.DEBUG - } != null - } - - testHelper.ensureErrorReceived { event -> - event.breadcrumbs?.firstOrNull { - it.message == "User has made a purchase of product: 445" && it.level == SentryLevel.INFO - } != null - } - - testHelper.ensureLogsReceived { logs, _ -> - testHelper.doesContainLogWithBody(logs, "User has made a purchase of product: 445") && - testHelper.doesContainLogWithBody(logs, "Something went wrong") && - testHelper.doesLogWithBodyHaveAttribute( - logs, - "Something went wrong", - "user.type", - "admin", - ) && - testHelper.doesLogWithBodyHaveAttribute( - logs, - "Something went wrong", - "feature.version", - 2, - ) && - testHelper.doesLogWithBodyHaveAttribute(logs, "Something went wrong", "debug.enabled", true) - } - } -} 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-samples/sentry-samples-spring/build.gradle.kts b/sentry-samples/sentry-samples-spring/build.gradle.kts index 8ae94a2e367..b64fcab92a3 100644 --- a/sentry-samples/sentry-samples-spring/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring/build.gradle.kts @@ -7,7 +7,6 @@ plugins { alias(libs.plugins.kotlin.spring) id("war") alias(libs.plugins.gretty) - id("io.sentry.systemtest") } // Spring scans this app's own bytecode with its repackaged ASM at startup, and the oldest version @@ -29,9 +28,6 @@ group = "io.sentry.sample.spring" version = "0.0.1-SNAPSHOT" -// Apollo 4.x requires coroutines 1.9.0+, override Spring Boot's managed version -extra["kotlin-coroutines.version"] = "1.9.0" - dependencyManagement { imports { mavenBom(libs.springboot2.bom.get().toString()) @@ -58,12 +54,6 @@ dependencies { implementation(libs.tomcat.catalina) implementation(libs.tomcat.embed.jasper) - - testImplementation(projects.sentrySystemTestSupport) - testImplementation(libs.kotlin.test.junit) - testImplementation(libs.springboot.starter.test) { - exclude(group = "org.junit.vintage", module = "junit-vintage-engine") - } } tasks.withType().configureEach { @@ -71,22 +61,3 @@ tasks.withType().configureEach { compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") } } - -tasks.register("systemTest").configure { - group = "verification" - description = "Runs the System tests" - - val test = project.extensions.getByType()["test"] - testClassesDirs = test.output.classesDirs - classpath = test.runtimeClasspath - - maxParallelForks = 1 - - filter { includeTestsMatching("io.sentry.systemtest*") } -} - -tasks.named("test").configure { - require(this is Test) - - filter { excludeTestsMatching("io.sentry.systemtest.*") } -} diff --git a/sentry-samples/sentry-samples-spring/src/test/kotlin/io/sentry/DummyTest.kt b/sentry-samples/sentry-samples-spring/src/test/kotlin/io/sentry/DummyTest.kt deleted file mode 100644 index 6f762b7e453..00000000000 --- a/sentry-samples/sentry-samples-spring/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/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt b/sentry-samples/sentry-samples-spring/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt deleted file mode 100644 index 8a5cd29fe6d..00000000000 --- a/sentry-samples/sentry-samples-spring/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/sentry-samples-spring-0.0.1-SNAPSHOT") - 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/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt deleted file mode 100644 index 0565ee5c33e..00000000000 --- a/sentry-samples/sentry-samples-spring/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ /dev/null @@ -1,85 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.protocol.SentryId -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import org.junit.Before - -class PersonSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080/sentry-samples-spring-0.0.1-SNAPSHOT") - testHelper.reset() - } - - @Test - fun `get person fails`() { - val restClient = testHelper.restClient - restClient.getPerson(11L) - assertEquals(500, restClient.lastKnownStatusCode) - - testHelper.ensureErrorReceived { event -> - testHelper.doesEventHaveExceptionMessage(event, "Something went wrong [id=11]") && - testHelper.doesEventHaveFlag(event, "my-feature-flag", true) - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionHaveOp(transaction, "http.server") - } - - Thread.sleep(10000) - - testHelper.ensureLogsReceived { logs, envelopeHeader -> - testHelper.doesContainLogWithBody(logs, "warn Sentry logging") && - testHelper.doesContainLogWithBody(logs, "error Sentry logging") && - testHelper.doesContainLogWithBody(logs, "hello there world!") && - testHelper.doesLogWithBodyHaveAttribute( - logs, - "warn Sentry logging", - "user.type", - "admin", - ) && - testHelper.doesLogWithBodyHaveAttribute( - logs, - "warn Sentry logging", - "feature.version", - 2, - ) && - testHelper.doesLogWithBodyHaveAttribute(logs, "warn Sentry logging", "debug.enabled", true) - } - } - - @Test - fun `create person works`() { - val restClient = testHelper.restClient - val person = Person("firstA", "lastB") - val returnedPerson = restClient.createPerson(person) - assertEquals(200, restClient.lastKnownStatusCode) - - assertEquals(person.firstName, returnedPerson!!.firstName) - assertEquals(person.lastName, returnedPerson!!.lastName) - } - - @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-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..468f05fe2de --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/DistributedTracingIntegrationTest.kt @@ -0,0 +1,148 @@ +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.random.Random +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) + + 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/$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 { isForRequest(it, id) } + } + + @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.traceId).isEqualTo(traceId) + assertThat(outgoing.traceId).isEqualTo(traceId) + 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.traceId).isEqualTo(traceId) + assertThat(outgoing.traceId).isEqualTo(traceId) + 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) + } + + /** 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/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..b6ebc1e18f6 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/GraphqlIntegrationTest.kt @@ -0,0 +1,109 @@ +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]["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") + + 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/sentry-spring/build.gradle.kts b/sentry-spring/build.gradle.kts index 71eb6d1f15e..f11087c1b07 100644 --- a/sentry-spring/build.gradle.kts +++ b/sentry-spring/build.gradle.kts @@ -95,3 +95,25 @@ tasks.jar { ) } } + +testing { + suites { + // Runs a plain Spring MVC app in an embedded Tomcat against MockSentryServer, the way a servlet + // container runs a WAR. Spring Boot isn't on its classpath, because Sentry behaves differently + // with it. + register("integrationTest") { + useJUnit() + dependencies { + implementation(platform(libs.springboot2.bom)) + implementation(project()) + implementation(projects.sentryAsyncProfiler) + implementation(projects.sentryTestSupport) + implementation(Config.Libs.springWeb) + implementation(libs.jackson.databind) + implementation(libs.kotlin.test.junit) + implementation(libs.google.truth) + implementation(libs.tomcat.embed.core) + } + } + } +} diff --git a/sentry-spring/src/integrationTest/kotlin/io/sentry/spring/integrationtest/IntegrationTest.kt b/sentry-spring/src/integrationTest/kotlin/io/sentry/spring/integrationtest/IntegrationTest.kt new file mode 100644 index 00000000000..130ce0918d4 --- /dev/null +++ b/sentry-spring/src/integrationTest/kotlin/io/sentry/spring/integrationtest/IntegrationTest.kt @@ -0,0 +1,72 @@ +package io.sentry.spring.integrationtest + +import io.sentry.test.MockSentryServer +import java.nio.file.Files +import java.util.ServiceLoader +import javax.servlet.ServletContainerInitializer +import org.apache.catalina.startup.Tomcat +import org.junit.Before +import org.springframework.http.client.ClientHttpResponse +import org.springframework.web.client.DefaultResponseErrorHandler +import org.springframework.web.client.RestTemplate +import org.springframework.web.util.DefaultUriBuilderFactory + +/** + * Drives [AppInitializer]'s app over HTTP and asserts what reaches [server]. + * + * Subclasses share one app and one server. Envelopes that arrive late can outlive [server]'s reset, + * so tests match on values only they produce. + */ +abstract class IntegrationTest { + val server = TomcatApp.server + val restTemplate = TomcatApp.restTemplate + + @Before + fun resetServer() { + server.reset() + } +} + +/** + * The app in an embedded Tomcat, with no Spring Boot on the classpath, so Sentry takes the same + * path as in a WAR. Tomcat starts once per test JVM. + */ +object TomcatApp { + val server = MockSentryServer() + + init { + System.setProperty("sentry.dsn", server.dsn) + // The profiler rejects a traces path with a dot anywhere in it, which the module's absolute + // path can have. + System.setProperty( + "sentry.profiling-traces-dir-path", + "${System.getProperty("java.io.tmpdir")}/sentry-integration-test/profiling-traces", + ) + } + + private val tomcat = + Tomcat().apply { + setBaseDir(Files.createTempDirectory("tomcat").toString()) + setPort(0) + // Creates the default connector. + connector + val context = addContext("", Files.createTempDirectory("docbase").toString()) + // As Tomcat does for a WAR, runs each initializer the classpath registers: Spring's starts + // AppInitializer's app, and Sentry's adds SentrySpringFilter. Only Spring's uses the set. + ServiceLoader.load(ServletContainerInitializer::class.java).forEach { + context.addServletContainerInitializer(it, setOf(AppInitializer::class.java)) + } + start() + } + + val restTemplate = + RestTemplate().apply { + uriTemplateHandler = + DefaultUriBuilderFactory("http://localhost:${tomcat.connector.localPort}") + // Returns error responses rather than throwing them, so tests can check a failed request. + errorHandler = + object : DefaultResponseErrorHandler() { + override fun hasError(response: ClientHttpResponse): Boolean = false + } + } +} diff --git a/sentry-spring/src/integrationTest/kotlin/io/sentry/spring/integrationtest/IntegrationTestApplication.kt b/sentry-spring/src/integrationTest/kotlin/io/sentry/spring/integrationtest/IntegrationTestApplication.kt new file mode 100644 index 00000000000..f51d475470c --- /dev/null +++ b/sentry-spring/src/integrationTest/kotlin/io/sentry/spring/integrationtest/IntegrationTestApplication.kt @@ -0,0 +1,66 @@ +package io.sentry.spring.integrationtest + +import io.sentry.Sentry +import io.sentry.spring.EnableSentry +import io.sentry.spring.tracing.SentryTracingFilter +import javax.servlet.Filter +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +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.servlet.config.annotation.EnableWebMvc +import org.springframework.web.servlet.support.AbstractAnnotationConfigDispatcherServletInitializer + +// Classes and methods are open because Spring proxies them and this module doesn't apply the +// kotlin-spring plugin. + +/** Sets the app up the way a WAR does, for the servlet container to start. */ +class AppInitializer : AbstractAnnotationConfigDispatcherServletInitializer() { + override fun getRootConfigClasses(): Array> = arrayOf(AppConfig::class.java) + + override fun getServletConfigClasses(): Array> = arrayOf(WebConfig::class.java) + + override fun getServletMappings(): Array = arrayOf("/*") + + // Creates Sentry transactions around incoming requests. + override fun getServletFilters(): Array = arrayOf(SentryTracingFilter()) +} + +// The DSN and the traces path come from system properties, and the other options from +// sentry.properties, all of which Sentry reads as external configuration. +@Configuration @EnableSentry(dsn = "", sendDefaultPii = true) open class AppConfig + +// Declares the controllers rather than scanning for them, which would also pick up AppConfig and +// initialize Sentry a second time in the servlet's context. +@Configuration +@EnableWebMvc +open class WebConfig { + @Bean open fun personController() = PersonController() + + @Bean open fun metricController() = MetricController() +} + +open class Person(var firstName: String? = null, var lastName: String? = null) + +@RestController +@RequestMapping("/person/") +open class PersonController { + /** Reports to Sentry through every API an app uses, then fails the request. */ + @GetMapping("{id}") + open fun person(@PathVariable("id") id: Long): Person { + Sentry.setAttribute("user.type", "admin") + Sentry.setAttribute("feature.version", 2) + Sentry.setAttribute("debug.enabled", true) + Sentry.logger().warn("warn Sentry logging") + Sentry.logger().error("error Sentry logging") + Sentry.logger().info("hello %s %s", "there", "world!") + Sentry.addFeatureFlag("my-feature-flag", true) + throw IllegalArgumentException("Something went wrong [id=$id]") + } + + @PostMapping open fun create(@RequestBody person: Person): Person = person +} diff --git a/sentry-spring/src/integrationTest/kotlin/io/sentry/spring/integrationtest/MetricController.kt b/sentry-spring/src/integrationTest/kotlin/io/sentry/spring/integrationtest/MetricController.kt new file mode 100644 index 00000000000..61041bf6d21 --- /dev/null +++ b/sentry-spring/src/integrationTest/kotlin/io/sentry/spring/integrationtest/MetricController.kt @@ -0,0 +1,33 @@ +package io.sentry.spring.integrationtest + +import io.sentry.Sentry +import io.sentry.metrics.MetricsUnit +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/metric/") +open class MetricController { + @GetMapping("count") + open fun count(): String { + Sentry.setAttribute("user.type", "admin") + Sentry.setAttribute("feature.version", 2) + Sentry.metrics().count("countMetric") + return "count metric increased" + } + + @GetMapping("gauge/{value}") + open fun gauge(@PathVariable("value") value: Long): String { + Sentry.metrics().gauge("memory.free", value.toDouble(), MetricsUnit.Information.BYTE) + return "gauge metric tracked" + } + + @GetMapping("distribution/{value}") + open fun distribution(@PathVariable("value") value: Long): String { + Sentry.metrics() + .distribution("distributionMetric", value.toDouble(), MetricsUnit.Duration.MILLISECOND) + return "distribution metric tracked" + } +} diff --git a/sentry-spring/src/integrationTest/kotlin/io/sentry/spring/integrationtest/MetricsIntegrationTest.kt b/sentry-spring/src/integrationTest/kotlin/io/sentry/spring/integrationtest/MetricsIntegrationTest.kt new file mode 100644 index 00000000000..d2e524a3bb3 --- /dev/null +++ b/sentry-spring/src/integrationTest/kotlin/io/sentry/spring/integrationtest/MetricsIntegrationTest.kt @@ -0,0 +1,50 @@ +package io.sentry.spring.integrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.Sentry +import io.sentry.SentryMetricsEvents +import io.sentry.test.hasMetric +import io.sentry.test.hasMetricWithAttribute +import io.sentry.test.metrics +import kotlin.test.Test + +class MetricsIntegrationTest : IntegrationTest() { + + @Test + fun `count metric carries the scope's attributes`() { + assertThat(restTemplate.getForObject("/metric/count", String::class.java)) + .isEqualTo("count metric increased") + + val metrics = awaitMetric("countMetric") + assertThat(metrics.hasMetric("countMetric", "counter", 1.0)).isTrue() + assertThat(metrics.hasMetricWithAttribute("countMetric", "user.type", "admin")).isTrue() + assertThat(metrics.hasMetricWithAttribute("countMetric", "feature.version", 2)).isTrue() + } + + @Test + fun `gauge metric`() { + assertThat(restTemplate.getForObject("/metric/gauge/14", String::class.java)) + .isEqualTo("gauge metric tracked") + + assertThat(awaitMetric("memory.free").hasMetric("memory.free", "gauge", 14.0)).isTrue() + } + + @Test + fun `distribution metric`() { + assertThat(restTemplate.getForObject("/metric/distribution/23", String::class.java)) + .isEqualTo("distribution metric tracked") + + assertThat( + awaitMetric("distributionMetric").hasMetric("distributionMetric", "distribution", 23.0) + ) + .isTrue() + } + + /** Flushes first because metrics are batched before they're sent. */ + private fun awaitMetric(name: String): SentryMetricsEvents { + Sentry.flush(5000) + return server + .awaitEnvelope { it.metrics()?.items?.any { metric -> metric.name == name } == true } + .metrics()!! + } +} diff --git a/sentry-spring/src/integrationTest/kotlin/io/sentry/spring/integrationtest/PersonIntegrationTest.kt b/sentry-spring/src/integrationTest/kotlin/io/sentry/spring/integrationtest/PersonIntegrationTest.kt new file mode 100644 index 00000000000..9457a5da5dc --- /dev/null +++ b/sentry-spring/src/integrationTest/kotlin/io/sentry/spring/integrationtest/PersonIntegrationTest.kt @@ -0,0 +1,64 @@ +package io.sentry.spring.integrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.Sentry +import io.sentry.test.event +import io.sentry.test.hasFlag +import io.sentry.test.hasLogWithAttribute +import io.sentry.test.hasLogWithBody +import io.sentry.test.logs +import io.sentry.test.profileChunk +import io.sentry.test.transaction +import kotlin.test.Test + +class PersonIntegrationTest : IntegrationTest() { + + @Test + fun `failed request reports the error with its request, the transaction and logs`() { + val response = restTemplate.getForEntity("/person/11", String::class.java) + assertThat(response.statusCode.value()).isEqualTo(500) + Sentry.flush(5000) + + val error = + server + .awaitEnvelope { + it.event()?.exceptions?.any { e -> e.value == "Something went wrong [id=11]" } == true + } + .event()!! + assertThat(error.hasFlag("my-feature-flag", true)).isTrue() + // Without Spring Boot, Sentry's servlet container initializer registers SentrySpringFilter, + // which attaches the request. + assertThat(error.request?.url).endsWith("/person/11") + + val transaction = + server.awaitEnvelope { it.transaction()?.transaction == "GET /person/{id}" }.transaction()!! + assertThat(transaction.contexts.trace?.operation).isEqualTo("http.server") + + val logs = + server + .awaitEnvelope { + it.logs()?.items?.any { log -> log.body == "warn Sentry logging" } == true + } + .logs()!! + assertThat(logs.hasLogWithBody("error Sentry logging")).isTrue() + assertThat(logs.hasLogWithBody("hello there world!")).isTrue() + assertThat(logs.hasLogWithAttribute("warn Sentry logging", "user.type", "admin")).isTrue() + assertThat(logs.hasLogWithAttribute("warn Sentry logging", "feature.version", 2)).isTrue() + assertThat(logs.hasLogWithAttribute("warn Sentry logging", "debug.enabled", true)).isTrue() + } + + @Test + fun `creating a person starts a profile linked to the transaction`() { + 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()!! + val profilerId = transaction.contexts.profile?.profilerId + assertThat(profilerId).isNotNull() + server.awaitEnvelope(timeoutMillis = 30_000) { it.profileChunk()?.profilerId == profilerId } + } +} diff --git a/sentry-spring/src/integrationTest/resources/sentry.properties b/sentry-spring/src/integrationTest/resources/sentry.properties new file mode 100644 index 00000000000..31e3807a8b2 --- /dev/null +++ b/sentry-spring/src/integrationTest/resources/sentry.properties @@ -0,0 +1,4 @@ +traces-sample-rate=1.0 +logs.enabled=true +profile-session-sample-rate=1.0 +profile-lifecycle=TRACE diff --git a/sentry-system-test-support/build.gradle.kts b/sentry-system-test-support/build.gradle.kts deleted file mode 100644 index ecfdc6011f8..00000000000 --- a/sentry-system-test-support/build.gradle.kts +++ /dev/null @@ -1,44 +0,0 @@ -plugins { - `java-library` - id("io.sentry.javadoc") - alias(libs.plugins.kotlin.jvm) - alias(libs.plugins.errorprone) - alias(libs.plugins.gradle.versions) - id("com.apollographql.apollo") version "4.1.1" -} - -tasks.withType().configureEach { - compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 - compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 -} - -dependencies { - api(projects.sentry) - api(projects.sentryTestSupport) - api(libs.apollo4.kotlin) - - compileOnly(libs.jetbrains.annotations) - compileOnly(libs.nopen.annotations) - - compileOnly(libs.springboot.starter.web) - - implementation(libs.jackson.databind) - implementation(libs.jackson.kotlin) - implementation(libs.okhttp) - - errorprone(libs.errorprone.core) - errorprone(libs.nopen.checker) - - // tests - implementation(kotlin(Config.kotlinStdLib)) - implementation(libs.kotlin.test.junit) - implementation(libs.mockito.kotlin) -} - -apollo { - service("service") { - srcDir("src/main/graphql") - packageName.set("io.sentry.samples.graphql") - outputDirConnection { connectToKotlinSourceSet("main") } - } -} diff --git a/sentry-system-test-support/src/main/graphql/greeting.graphql b/sentry-system-test-support/src/main/graphql/greeting.graphql deleted file mode 100644 index 06c866a65fa..00000000000 --- a/sentry-system-test-support/src/main/graphql/greeting.graphql +++ /dev/null @@ -1,3 +0,0 @@ -query GreetingQuery($name: String!) { - greeting(name: $name) -} diff --git a/sentry-system-test-support/src/main/graphql/project.graphql b/sentry-system-test-support/src/main/graphql/project.graphql deleted file mode 100644 index bff62ed2c2c..00000000000 --- a/sentry-system-test-support/src/main/graphql/project.graphql +++ /dev/null @@ -1,11 +0,0 @@ -query ProjectQuery($slug: ID!) { - project(slug: $slug) { - slug - name - status - } -} - -mutation AddProjectMutation($slug: ID!) { - addProject(slug: $slug) -} diff --git a/sentry-system-test-support/src/main/graphql/schema.graphqls b/sentry-system-test-support/src/main/graphql/schema.graphqls deleted file mode 100644 index d76aca4756a..00000000000 --- a/sentry-system-test-support/src/main/graphql/schema.graphqls +++ /dev/null @@ -1,70 +0,0 @@ -type Query { - greeting(name: String! = "Spring"): String! - project(slug: ID!): Project - tasks(projectSlug: ID!): [Task] -} - -type Mutation { - addProject(slug: ID!): String! -} - -type Subscription { - notifyNewTask(projectSlug: ID!): Task -} - -""" A Project in the Spring portfolio """ -type Project { - """ Unique string id used in URLs """ - slug: ID! - """ Project name """ - name: String - """ URL of the git repository """ - repositoryUrl: String! - """ Current support status """ - status: ProjectStatus! -} - -""" A task """ -type Task { - """ ID """ - id: String! - """ Name """ - name: String! - """ ID of the Assignee """ - assigneeId: String - """ Assignee """ - assignee: Assignee - """ ID of the Creator """ - creatorId: String - """ Creator """ - creator: Creator -} - -""" An Assignee """ -type Assignee { - """ ID """ - id: String! - """ Name """ - name: String! -} - -""" An Creator """ -type Creator { - """ ID """ - id: String! - """ Name """ - name: String! -} - -enum ProjectStatus { - """ Actively supported by the Spring team """ - ACTIVE - """ Supported by the community """ - COMMUNITY - """ Prototype, not officially supported yet """ - INCUBATING - """ Project being retired, in maintenance mode """ - ATTIC - """ End-Of-Lifed """ - EOL -} diff --git a/sentry-system-test-support/src/main/graphql/task.graphql b/sentry-system-test-support/src/main/graphql/task.graphql deleted file mode 100644 index 11ae18574d3..00000000000 --- a/sentry-system-test-support/src/main/graphql/task.graphql +++ /dev/null @@ -1,16 +0,0 @@ -query TasksAndAssigneesQuery($slug: ID!) { - tasks(projectSlug: $slug) { - id - name - assigneeId - assignee { - id - name - } - creatorId - creator { - id - name - } - } -} diff --git a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/ResponseTypes.kt b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/ResponseTypes.kt deleted file mode 100644 index 4c9a4d903ad..00000000000 --- a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/ResponseTypes.kt +++ /dev/null @@ -1,7 +0,0 @@ -package io.sentry.systemtest - -data class Todo(val id: Long, val title: String, val completed: Boolean) - -data class Person(val firstName: String, val lastName: String) { - override fun toString(): String = "Person{firstName='$firstName', lastName='$lastName'}" -} diff --git a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt deleted file mode 100644 index c1b0a409bda..00000000000 --- a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt +++ /dev/null @@ -1,38 +0,0 @@ -package io.sentry.systemtest.graphql - -import com.apollographql.apollo.ApolloClient -import com.apollographql.apollo.api.ApolloResponse -import com.apollographql.apollo.api.Mutation -import com.apollographql.apollo.api.Query -import io.sentry.samples.graphql.AddProjectMutation -import io.sentry.samples.graphql.GreetingQuery -import io.sentry.samples.graphql.ProjectQuery -import io.sentry.samples.graphql.TasksAndAssigneesQuery -import kotlinx.coroutines.runBlocking - -class GraphqlTestClient(backendUrl: String) { - val apollo = - ApolloClient.Builder() - .serverUrl("$backendUrl/graphql") - .addHttpHeader("Authorization", "Basic dXNlcjpwYXNzd29yZA==") - .build() - - fun greet(name: String): ApolloResponse? = executeQuery(GreetingQuery(name)) - - fun project(slug: String): ApolloResponse? = executeQuery(ProjectQuery(slug)) - - fun tasksAndAssignees(slug: String): ApolloResponse? = - executeQuery(TasksAndAssigneesQuery(slug)) - - fun addProject(slug: String): ApolloResponse? = - executeMutation(AddProjectMutation(slug)) - - private fun executeQuery(query: Query): ApolloResponse? = runBlocking { - apollo.query(query).execute() - } - - private fun executeMutation(mutation: Mutation): ApolloResponse? = - runBlocking { - apollo.mutation(mutation).execute() - } -} diff --git a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt deleted file mode 100644 index 8c4f27345f8..00000000000 --- a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt +++ /dev/null @@ -1,83 +0,0 @@ -package io.sentry.systemtest.util - -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import java.util.concurrent.TimeUnit -import okhttp3.Credentials -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody -import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.Response -import org.slf4j.LoggerFactory - -open class LoggingInsecureRestClient { - val logger = LoggerFactory.getLogger(LoggingInsecureRestClient::class.java) - var lastKnownStatusCode: Int? = null - - protected inline fun callTyped( - requestBuilder: Request.Builder, - useAuth: Boolean, - extraHeaders: Map? = null, - ): T? { - val response = call(requestBuilder, useAuth, extraHeaders) - val responseBody = response?.body?.string() - if (response?.isSuccessful != true) { - return null - } - if (T::class == String::class) { - return responseBody as? T - } - return responseBody?.let { objectMapper().readValue(it, T::class.java) } - } - - protected fun call( - requestBuilder: Request.Builder, - useAuth: Boolean, - extraHeaders: Map? = null, - ): Response? { - try { - val request = - requestBuilder - .also { originalRequest -> - var modifiedRequest = originalRequest - - if (useAuth) { - modifiedRequest = - modifiedRequest.header("Authorization", Credentials.basic("user", "password")) - } - - extraHeaders?.forEach { key, value -> - modifiedRequest = modifiedRequest.header(key, value) - } - - modifiedRequest - } - .build() - val call = client().newCall(request) - val response = call.execute() - lastKnownStatusCode = response.code - return response - } catch (e: Exception) { - lastKnownStatusCode = null - logger.error("Request failed", e) - return null - } - } - - protected fun client(): OkHttpClient = - OkHttpClient.Builder() - .callTimeout(60, TimeUnit.SECONDS) - .connectTimeout(60, TimeUnit.SECONDS) - .readTimeout(60, TimeUnit.SECONDS) - .writeTimeout(60, TimeUnit.SECONDS) - .build() - - protected fun objectMapper(): ObjectMapper = jacksonObjectMapper() - - protected fun toRequestBody(o: Any?): RequestBody { - val stringValue = objectMapper().writeValueAsString(o) - return stringValue.toRequestBody("application/json; charset=utf-8".toMediaType()) - } -} diff --git a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/RestTestClient.kt b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/RestTestClient.kt deleted file mode 100644 index ac2c69f5ed2..00000000000 --- a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/RestTestClient.kt +++ /dev/null @@ -1,115 +0,0 @@ -package io.sentry.systemtest.util - -import io.sentry.systemtest.Person -import io.sentry.systemtest.Todo -import okhttp3.Request - -class RestTestClient(private val backendBaseUrl: String) : LoggingInsecureRestClient() { - fun getPerson(id: Long): Person? { - val request = Request.Builder().url("$backendBaseUrl/person/$id") - - return callTyped(request, true) - } - - fun createPerson(person: Person, extraHeaders: Map? = null): Person? { - val request = Request.Builder().url("$backendBaseUrl/person/").post(toRequestBody(person)) - - return callTyped(request, true, extraHeaders) - } - - fun getPersonDistributedTracing(id: Long, extraHeaders: Map? = null): Person? { - val request = Request.Builder().url("$backendBaseUrl/tracing/$id") - - return callTyped(request, true, extraHeaders) - } - - fun createPersonDistributedTracing( - person: Person, - extraHeaders: Map? = null, - ): Person? { - val request = Request.Builder().url("$backendBaseUrl/tracing/").post(toRequestBody(person)) - - return callTyped(request, true, extraHeaders) - } - - fun getTodo(id: Long): Todo? { - val request = Request.Builder().url("$backendBaseUrl/todo/$id") - - return callTyped(request, true) - } - - fun getTodoWebclient(id: Long): Todo? { - val request = Request.Builder().url("$backendBaseUrl/todo-webclient/$id") - - return callTyped(request, true) - } - - fun getTodoRestClient(id: Long): Todo? { - val request = Request.Builder().url("$backendBaseUrl/todo-restclient/$id") - - return callTyped(request, true) - } - - fun getTodoOkHttp(id: Long): Todo? { - val request = Request.Builder().url("$backendBaseUrl/todo-okhttp/$id") - - return callTyped(request, true) - } - - fun getCachedTodo(id: Long): Todo? { - val request = Request.Builder().url("$backendBaseUrl/cache/$id") - - return callTyped(request, true) - } - - fun saveCachedTodo(todo: Todo): Todo? { - val request = Request.Builder().url("$backendBaseUrl/cache/").post(toRequestBody(todo)) - - return callTyped(request, true) - } - - fun deleteCachedTodo(id: Long) { - val request = Request.Builder().url("$backendBaseUrl/cache/$id").delete() - - call(request, true) - } - - fun checkFeatureFlag(flagKey: String): FeatureFlagResponse? { - val request = Request.Builder().url("$backendBaseUrl/feature-flag/check/$flagKey") - - return callTyped(request, true) - } - - fun errorWithFeatureFlag(flagKey: String): String? { - val request = Request.Builder().url("$backendBaseUrl/feature-flag/error/$flagKey") - - val response = call(request, true) - return response?.body?.string() - } - - fun produceKafkaMessage(message: String = "hello from sentry!"): String? { - val request = Request.Builder().url("$backendBaseUrl/kafka/produce?message=$message") - - return callTyped(request, true) - } - - fun getCountMetric(): String? { - val request = Request.Builder().url("$backendBaseUrl/metric/count") - - return callTyped(request, true) - } - - fun getGaugeMetric(value: Long): String? { - val request = Request.Builder().url("$backendBaseUrl/metric/gauge/$value") - - return callTyped(request, true) - } - - fun getDistributionMetric(value: Long): String? { - val request = Request.Builder().url("$backendBaseUrl/metric/distribution/$value") - - return callTyped(request, true) - } -} - -data class FeatureFlagResponse(val flagKey: String, val value: Boolean) diff --git a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt deleted file mode 100644 index 389f3bb5b80..00000000000 --- a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt +++ /dev/null @@ -1,35 +0,0 @@ -package io.sentry.systemtest.util - -import okhttp3.Request - -class SentryMockServerClient(private val baseUrl: String) : LoggingInsecureRestClient() { - fun getEnvelopeCount(): EnvelopeCounts { - val request = Request.Builder().url("$baseUrl/envelope-count") - - return callTyped(request, false)!! - } - - fun reset() { - val request = Request.Builder().url("$baseUrl/reset") - - call(request, false) - } - - fun getEnvelopes(): EnvelopesReceived { - val request = Request.Builder().url("$baseUrl/envelopes-received") - - return callTyped(request, false)!! - } -} - -class EnvelopeCounts { - val envelopes: Long? = null - - override fun toString(): String = "EnvelopeCounts{envelopes=$envelopes}" -} - -class EnvelopesReceived { - val envelopes: List? = null - - override fun toString(): String = "EnvelopesReceived{envelopes=$envelopes}" -} diff --git a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/TestHelper.kt b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/TestHelper.kt deleted file mode 100644 index 8ef74cae3c9..00000000000 --- a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/TestHelper.kt +++ /dev/null @@ -1,298 +0,0 @@ -package io.sentry.systemtest.util - -import com.apollographql.apollo.api.ApolloResponse -import com.apollographql.apollo.api.Operation -import io.sentry.JsonSerializer -import io.sentry.ProfileChunk -import io.sentry.SentryEnvelope -import io.sentry.SentryEnvelopeHeader -import io.sentry.SentryEvent -import io.sentry.SentryLogEvents -import io.sentry.SentryMetricsEvents -import io.sentry.SentryOptions -import io.sentry.protocol.FeatureFlag -import io.sentry.protocol.SentrySpan -import io.sentry.protocol.SentryTransaction -import io.sentry.systemtest.graphql.GraphqlTestClient -import io.sentry.test.event -import io.sentry.test.hasExceptionMessage -import io.sentry.test.hasFlag -import io.sentry.test.hasLogWithAttribute -import io.sentry.test.hasLogWithBody -import io.sentry.test.hasMetric -import io.sentry.test.hasMetricWithAttribute -import io.sentry.test.hasOp -import io.sentry.test.hasSpanWithOp -import io.sentry.test.hasSpanWithOpAndDescription -import io.sentry.test.hasTraceId -import io.sentry.test.logs -import io.sentry.test.metrics -import io.sentry.test.profileChunk -import io.sentry.test.transaction -import java.io.File -import java.io.PrintWriter -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -class TestHelper(backendUrl: String) { - val restClient: RestTestClient - val graphqlClient: GraphqlTestClient - val sentryClient: SentryMockServerClient - val jsonSerializer: JsonSerializer - val dsn = "http://502f25099c204a2fbf4cb16edc5975d1@localhost:8000/0" - - var envelopeCounts: EnvelopeCounts? = null - - init { - restClient = RestTestClient(backendUrl) - sentryClient = SentryMockServerClient("http://localhost:8000") - graphqlClient = GraphqlTestClient(backendUrl) - jsonSerializer = JsonSerializer(SentryOptions.empty()) - } - - fun snapshotEnvelopeCount() { - envelopeCounts = sentryClient.getEnvelopeCount() - } - - fun ensureEnvelopeCountIncreased() { - Thread.sleep(1000) - val envelopeCountsAfter = sentryClient.getEnvelopeCount() - assertTrue(envelopeCountsAfter!!.envelopes!! > envelopeCounts!!.envelopes!!) - } - - fun ensureEnvelopeReceived(retryCount: Int = 1, callback: ((String) -> Boolean)) { - val envelopes = sentryClient.getEnvelopes() - assertNotNull(envelopes.envelopes) - envelopes.envelopes.forEach { envelopeString -> - val didMatch = callback(envelopeString) - if (didMatch) { - return - } - } - if (retryCount <= 0) { - throw RuntimeException("Unable to find matching envelope received by relay") - } else { - Thread.sleep(10000) - ensureEnvelopeReceived(retryCount - 1, callback) - } - } - - fun ensureNoEnvelopeReceived(callback: ((String) -> Boolean)) { - Thread.sleep(10000) - val envelopes = sentryClient.getEnvelopes() - - if (envelopes.envelopes.isNullOrEmpty()) { - return - } - - envelopes.envelopes.forEach { envelopeString -> - val didMatch = callback(envelopeString) - if (didMatch) { - throw RuntimeException("Found unexpected matching envelope received by relay") - } - } - } - - fun ensureTransactionReceived(callback: ((SentryTransaction, SentryEnvelopeHeader) -> Boolean)) { - ensureEnvelopeReceived { envelopeString -> checkIfTransactionMatches(envelopeString, callback) } - } - - fun ensureProfileChunkReceived(callback: ((ProfileChunk, SentryEnvelopeHeader) -> Boolean)) { - ensureEnvelopeReceived { envelopeString -> checkIfProfileMatches(envelopeString, callback) } - } - - fun ensureNoTransactionReceived( - callback: ((SentryTransaction, SentryEnvelopeHeader) -> Boolean) - ) { - ensureNoEnvelopeReceived { envelopeString -> - checkIfTransactionMatches(envelopeString, callback) - } - } - - fun ensureLogsReceived(callback: ((SentryLogEvents, SentryEnvelopeHeader) -> Boolean)) { - ensureEnvelopeReceived { envelopeString -> checkIfLogsMatch(envelopeString, callback) } - } - - private fun checkIfLogsMatch( - envelopeString: String, - callback: ((SentryLogEvents, SentryEnvelopeHeader) -> Boolean), - ): Boolean { - val envelope = deserialize(envelopeString) ?: return false - val logs = envelope.logs() ?: return false - return callback(logs, envelope.header) - } - - fun doesContainMetric( - metrics: SentryMetricsEvents, - name: String, - type: String, - value: Double, - unit: String? = null, - ): Boolean = metrics.hasMetric(name, type, value, unit) - - fun ensureMetricsReceived(callback: ((SentryMetricsEvents, SentryEnvelopeHeader) -> Boolean)) { - ensureEnvelopeReceived { envelopeString -> checkIfMetricsMatch(envelopeString, callback) } - } - - private fun checkIfMetricsMatch( - envelopeString: String, - callback: ((SentryMetricsEvents, SentryEnvelopeHeader) -> Boolean), - ): Boolean { - val envelope = deserialize(envelopeString) ?: return false - val metrics = envelope.metrics() ?: return false - return callback(metrics, envelope.header) - } - - fun doesContainLogWithBody(logs: SentryLogEvents, body: String): Boolean = - logs.hasLogWithBody(body) - - fun doesLogWithBodyHaveAttribute( - logs: SentryLogEvents, - body: String, - attributeKey: String, - attributeValue: Any?, - ): Boolean = logs.hasLogWithAttribute(body, attributeKey, attributeValue) - - fun doesMetricHaveAttribute( - metrics: SentryMetricsEvents, - metricName: String, - attributeKey: String, - attributeValue: Any?, - ): Boolean = metrics.hasMetricWithAttribute(metricName, attributeKey, attributeValue) - - private fun checkIfTransactionMatches( - envelopeString: String, - callback: ((SentryTransaction, SentryEnvelopeHeader) -> Boolean), - ): Boolean { - val envelope = deserialize(envelopeString) ?: return false - val transaction = envelope.transaction() ?: return false - return callback(transaction, envelope.header) - } - - private fun checkIfProfileMatches( - envelopeString: String, - callback: ((ProfileChunk, SentryEnvelopeHeader) -> Boolean), - ): Boolean { - val envelope = deserialize(envelopeString) ?: return false - val chunk = envelope.profileChunk() ?: return false - return callback(chunk, envelope.header) - } - - fun ensureErrorReceived(callback: ((SentryEvent) -> Boolean)) { - ensureEnvelopeReceived(retryCount = 3) { envelopeString -> - val error = deserialize(envelopeString)?.event() ?: return@ensureEnvelopeReceived false - val callbackResult = callback(error) - if (!callbackResult) { - println("found an error event but it did not match:") - logObject(error) - } - callbackResult - } - } - - fun ensureTransactionWithSpanReceived(callback: ((SentrySpan) -> Boolean)) { - ensureTransactionReceived { transaction, envelopeHeader -> - transaction.spans.forEach { span -> - val callbackResult = callback(span) - if (callbackResult) { - return@ensureTransactionReceived true - } - } - false - } - } - - fun reset() { - sentryClient.reset() - } - - fun logObject(obj: Any?) { - obj ?: return - // Flush rather than close: closing the writer would close System.out. - PrintWriter(System.out).apply { - jsonSerializer.serialize(obj, this) - flush() - } - println() - } - - private fun deserialize(envelopeString: String): SentryEnvelope? = - jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) - - fun ensureNoErrors(response: ApolloResponse?) { - response ?: throw RuntimeException("no response") - assertFalse(response.hasErrors()) - } - - fun ensureErrorCount(response: ApolloResponse?, errorCount: Int) { - response ?: throw RuntimeException("no response") - assertEquals(errorCount, response.errors?.size) - } - - fun doesTransactionContainSpanWithOp(transaction: SentryTransaction, op: String): Boolean = - transaction.hasSpanWithOp(op) - - fun doesTransactionContainSpanWithOpAndDescription( - transaction: SentryTransaction, - op: String, - description: String, - ): Boolean = transaction.hasSpanWithOpAndDescription(op, description) - - fun doesTransactionHaveTraceId(transaction: SentryTransaction, traceId: String): Boolean = - transaction.hasTraceId(traceId) - - fun doesTransactionHaveOp(transaction: SentryTransaction, op: String): Boolean = - transaction.hasOp(op) - - fun doesTransactionHave( - transaction: SentryTransaction, - op: String, - featureFlag: FeatureFlag? = null, - ): Boolean = transaction.hasOp(op, featureFlag) - - fun doesTransactionHaveSpanWith( - transaction: SentryTransaction, - op: String, - featureFlag: FeatureFlag? = null, - noFeatureFlags: Boolean = false, - ): Boolean = transaction.hasSpanWithOp(op, featureFlag, noFeatureFlags) - - fun doesEventHaveExceptionMessage(event: SentryEvent, expectedMessage: String): Boolean = - event.hasExceptionMessage(expectedMessage) - - fun doesEventHaveFlag(event: SentryEvent, flag: String, result: Boolean): Boolean = - event.hasFlag(flag, result) - - fun findJar(prefix: String, inDir: String = "build/libs"): File { - val buildDir = File(inDir) - val jarFiles = - buildDir.listFiles { _, name -> name.startsWith(prefix) && name.endsWith(".jar") }?.toList() - ?: emptyList() - - if (jarFiles.isEmpty()) { - throw AssertionError("No JAR found in ${buildDir.absolutePath}") - } - - return jarFiles.maxOf { it } - } - - fun launch(jar: File, env: Map, enableOtelAutoConfig: Boolean = false): Process { - val processBuilderList = mutableListOf("java", "--add-opens", "java.base/java.lang=ALL-UNNAMED") - - if (enableOtelAutoConfig) { - processBuilderList.add("-Dotel.java.global-autoconfigure.enabled=true") - } - - processBuilderList.add("-jar") - processBuilderList.add(jar.absolutePath) - - val processBuilder = - ProcessBuilder(processBuilderList).inheritIO() // forward i/o to current process - - processBuilder.environment().putAll(env) - - return processBuilder.start() - } -} diff --git a/sentry-test-support/src/main/kotlin/io/sentry/test/ChildJvmApp.kt b/sentry-test-support/src/main/kotlin/io/sentry/test/ChildJvmApp.kt index 416fead4ddc..21eeda7239b 100644 --- a/sentry-test-support/src/main/kotlin/io/sentry/test/ChildJvmApp.kt +++ b/sentry-test-support/src/main/kotlin/io/sentry/test/ChildJvmApp.kt @@ -11,11 +11,12 @@ import kotlin.concurrent.thread /** * An app running in a child JVM, for apps that must not share the test's JVM, such as one with the - * OpenTelemetry agent attached. + * OpenTelemetry agent attached or one whose exit is under test. * - * The app writes the port it listens on to the file named by the `PORTFILE` environment variable, - * which Spring Boot's `WebServerPortFileWriter` reads too. The app's output goes to the test's - * standard output as it arrives, so Gradle's test report shows it with the test that was running. + * A server app writes the port it listens on to the file named by the `PORTFILE` environment + * variable, which Spring Boot's `WebServerPortFileWriter` reads too. The app's output goes to the + * test's standard output as it arrives, so Gradle's test report shows it with the test that was + * running. */ class ChildJvmApp private constructor(private val process: Process, private val workDir: File) : Closeable { @@ -37,6 +38,14 @@ class ChildJvmApp private constructor(private val process: Process, private val } } + /** Waits for the app to exit on its own and returns its exit code. */ + fun awaitExit(timeoutSeconds: Long = 30): Int { + check(process.waitFor(timeoutSeconds, TimeUnit.SECONDS)) { + "App did not exit within ${timeoutSeconds}s" + } + return process.exitValue() + } + override fun close() { process.destroyForcibly().waitFor() workDir.deleteRecursively() diff --git a/sentry/src/test/java/io/sentry/SentryEndToEndTest.kt b/sentry/src/test/java/io/sentry/SentryEndToEndTest.kt new file mode 100644 index 00000000000..742f4462b2b --- /dev/null +++ b/sentry/src/test/java/io/sentry/SentryEndToEndTest.kt @@ -0,0 +1,113 @@ +package io.sentry + +import com.google.common.truth.Truth.assertThat +import io.sentry.protocol.Message +import io.sentry.test.MockSentryServer +import io.sentry.test.event +import io.sentry.test.hasFlag +import io.sentry.test.hasMetric +import io.sentry.test.hasMetricWithAttribute +import io.sentry.test.initForTest +import io.sentry.test.metrics +import io.sentry.test.transaction +import kotlin.test.AfterTest +import kotlin.test.Test + +/** Reports through the APIs an app uses without integrations, over the real HTTP transport. */ +class SentryEndToEndTest { + private val server = MockSentryServer() + + @AfterTest + fun teardown() { + Sentry.close() + server.close() + } + + @Test + fun `events, a transaction and metrics reach Sentry`() { + initForTest { + it.dsn = server.dsn + it.tracesSampleRate = 1.0 + it.beforeBreadcrumb = SentryOptions.BeforeBreadcrumbCallback { breadcrumb, _ -> + if (breadcrumb.message == "bad breadcrumb") null else breadcrumb + } + } + Sentry.addBreadcrumb("bad breadcrumb") + Sentry.configureScope { + it.addEventProcessor( + object : EventProcessor { + override fun process(event: SentryEvent, hint: Hint): SentryEvent = event.apply { + if ((level?.ordinal ?: 0) > SentryLevel.INFO.ordinal) { + addBreadcrumb(Breadcrumb("Processed by the event processor")) + } + } + } + ) + } + + Sentry.withScope { + it.level = SentryLevel.FATAL + Sentry.captureMessage("Fatal message!") + } + Sentry.captureMessage("Some warning!", SentryLevel.WARNING) + Sentry.addFeatureFlag("my-feature-flag", true) + Sentry.setAttribute("user.type", "admin") + Sentry.setAttribute("feature.version", 2) + Sentry.metrics().count("countMetric") + Sentry.metrics().gauge("gaugeMetric", 5.0) + Sentry.metrics().distribution("distributionMetric", 7.0) + Sentry.captureException(RuntimeException("Some error!")) + Sentry.captureEvent( + SentryEvent().apply { + message = Message().apply { message = "Detailed event" } + fingerprints = listOf("NewClientDebug") + level = SentryLevel.DEBUG + } + ) + repeat(10) { i -> + Sentry.captureEvent( + SentryEvent().apply { message = Message().apply { message = "event $i" } } + ) + } + val transaction = Sentry.startTransaction("transaction name", "op") + transaction.startChild("child").finish() + transaction.finish() + Sentry.flush(5000) + + val fatal = + server.awaitEnvelope { it.event()?.message?.formatted == "Fatal message!" }.event()!! + assertThat(fatal.level).isEqualTo(SentryLevel.FATAL) + assertThat(fatal.breadcrumbs!!.map { it.message }).contains("Processed by the event processor") + assertThat(fatal.breadcrumbs!!.map { it.message }).doesNotContain("bad breadcrumb") + + val warning = + server.awaitEnvelope { it.event()?.message?.formatted == "Some warning!" }.event()!! + assertThat(warning.level).isEqualTo(SentryLevel.WARNING) + + val error = + server + .awaitEnvelope { it.event()?.exceptions?.any { e -> e.value == "Some error!" } == true } + .event()!! + assertThat(error.exceptions!!.map { it.type to it.value }) + .contains("RuntimeException" to "Some error!") + assertThat(error.hasFlag("my-feature-flag", true)).isTrue() + + val detailed = + server.awaitEnvelope { it.event()?.message?.message == "Detailed event" }.event()!! + assertThat(detailed.fingerprints).containsExactly("NewClientDebug") + assertThat(detailed.level).isEqualTo(SentryLevel.DEBUG) + + repeat(10) { i -> server.awaitEnvelope { it.event()?.message?.message == "event $i" } } + + val sent = + server.awaitEnvelope { it.transaction()?.transaction == "transaction name" }.transaction()!! + assertThat(sent.spans.map { it.op }).contains("child") + + val metrics = server.awaitEnvelope { it.metrics() != null }.metrics()!! + assertThat(metrics.hasMetric("countMetric", "counter", 1.0)).isTrue() + assertThat(metrics.hasMetric("gaugeMetric", "gauge", 5.0)).isTrue() + assertThat(metrics.hasMetric("distributionMetric", "distribution", 7.0)).isTrue() + assertThat(metrics.hasMetricWithAttribute("countMetric", "user.type", "admin")).isTrue() + assertThat(metrics.hasMetricWithAttribute("countMetric", "feature.version", 2)).isTrue() + } +} diff --git a/sentry/src/test/java/io/sentry/ShutdownHookEndToEndTest.kt b/sentry/src/test/java/io/sentry/ShutdownHookEndToEndTest.kt new file mode 100644 index 00000000000..c362ced5a21 --- /dev/null +++ b/sentry/src/test/java/io/sentry/ShutdownHookEndToEndTest.kt @@ -0,0 +1,56 @@ +package io.sentry + +import com.google.common.truth.Truth.assertThat +import io.sentry.test.ChildJvmApp +import io.sentry.test.MockSentryServer +import io.sentry.test.event +import io.sentry.test.hasLogWithBody +import io.sentry.test.hasMetric +import io.sentry.test.logs +import io.sentry.test.metrics +import kotlin.test.AfterTest +import kotlin.test.Test + +/** + * Runs an app in its own JVM that returns from `main` without flushing or closing Sentry, as most + * apps do, so only the shutdown hook can deliver what it reported. + */ +class ShutdownHookEndToEndTest { + private val server = MockSentryServer() + + @AfterTest + fun teardown() { + server.close() + } + + @Test + fun `the shutdown hook delivers what an app reported before it exited`() { + ChildJvmApp.start( + mainClass = ExitWithoutFlushApp::class.java.name, + classpath = System.getProperty("java.class.path"), + args = listOf(server.dsn), + ) + .use { app -> + // A timeout here means a thread the SDK started kept the JVM from exiting. + assertThat(app.awaitExit()).isEqualTo(0) + } + + server.awaitEnvelope { it.event()?.message?.formatted == "Reported before exit" } + server.awaitEnvelope { it.logs()?.hasLogWithBody("Logged before exit") == true } + server.awaitEnvelope { it.metrics()?.hasMetric("exitMetric", "counter", 1.0) == true } + } +} + +/** Reports an event, a log and a metric, then returns without flushing or closing Sentry. */ +object ExitWithoutFlushApp { + @JvmStatic + fun main(args: Array) { + Sentry.init { + it.dsn = args[0] + it.logs.isEnabled = true + } + Sentry.captureMessage("Reported before exit") + Sentry.logger().info("Logged before exit") + Sentry.metrics().count("exitMetric") + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 413ab5fd59b..5947411dd56 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -59,7 +59,6 @@ include( "sentry-apollo-3", "sentry-apollo-4", "sentry-apollo-5", - "sentry-system-test-support", "sentry-test-support", "sentry-log4j2", "sentry-logback", diff --git a/test/system-test-runner.py b/test/system-test-runner.py deleted file mode 100644 index 45fd4786b77..00000000000 --- a/test/system-test-runner.py +++ /dev/null @@ -1,1254 +0,0 @@ -#!/usr/bin/env python3 - -""" -System Test Runner for Sentry Java - -Usage examples: - # Run all tests - python3 test/system-test-runner.py test --all - - # Run specific module test - python3 test/system-test-runner.py test --module sentry-samples-console - - # Set up infrastructure for manual testing from IDE - python3 test/system-test-runner.py test --module sentry-samples-console --manual-test - - # Start Sentry mock server - python3 test/system-test-runner.py sentry start - - # Start Spring app served by Tomcat - python3 test/system-test-runner.py tomcat start sentry-samples-spring - - # Start Spring Boot app - python3 test/system-test-runner.py spring start sentry-samples-spring-boot - - # Start Spring Boot app with build - python3 test/system-test-runner.py spring start sentry-samples-spring-boot --build - - # Check status of all services - python3 test/system-test-runner.py status - - # Stop services - python3 test/system-test-runner.py sentry stop - python3 test/system-test-runner.py spring stop -""" - -import subprocess -import sys -import time -import signal -import os -from enum import Enum -import argparse -import requests -import threading -import socket -from pathlib import Path -from typing import Optional, List, Tuple -from dataclasses import dataclass - -TERMINAL_COLUMNS: int = 60 -try: - TERMINAL_COLUMNS: int = os.get_terminal_size().columns -except: - pass - -SENTRY_ENVIRONMENT_VARIABLES = { - "SENTRY_DSN": "http://502f25099c204a2fbf4cb16edc5975d1@localhost:8000/0", - "SENTRY_TRACES_SAMPLE_RATE": "1.0", - "SENTRY_PROFILE_SESSION_SAMPLE_RATE": "1.0", - "SENTRY_PROFILE_LIFECYCLE": "TRACE", - "SENTRY_PROFILING_TRACES_DIR_PATH": "tmp/sentry/profiling-traces", - "OTEL_TRACES_EXPORTER": "none", - "OTEL_METRICS_EXPORTER": "none", - "OTEL_LOGS_EXPORTER": "none", - "SENTRY_LOGS_ENABLED": "true", - "SENTRY_ENABLE_CACHE_TRACING": "true" -} - -KAFKA_CONTAINER_NAME = "sentry-java-system-test-kafka" -KAFKA_BOOTSTRAP_SERVERS = "localhost:9092" -KAFKA_BROKER_REQUIRED_MODULES = { - "sentry-samples-console", -} -KAFKA_PROFILE_REQUIRED_MODULES = set() - -class ServerType(Enum): - TOMCAT = 0 - SPRING = 1 - -def get_server_type_for_module(sample_module: str) -> Optional[ServerType]: - if "spring" in sample_module and "spring-boot" not in sample_module: - return ServerType.TOMCAT - elif "spring" in sample_module: - return ServerType.SPRING - - return None - -def str_to_bool(value: str) -> str: - """Convert true/false string to 1/0 string for internal compatibility.""" - if value.lower() in ('true', '1'): - return "1" - elif value.lower() in ('false', '0'): - return "0" - else: - raise ValueError(f"Invalid boolean value: {value}. Use 'true' or 'false'") - -@dataclass -class Server: - name: str - pid_filepath: str - process: Optional[subprocess.Popen] = None - pid: Optional[int] = None - -def store_pid(server: Server): - # Store PID in instance variable and write to file - server.pid = server.process.pid - with open(server.pid_filepath, "w") as pid_file: - pid_file.write(str(server.pid)) - -def cleanup_pid(server: Server): - # Clean up PID file and instance variable - if os.path.exists(server.pid_filepath): - os.remove(server.pid_filepath) - server.pid = None - -@dataclass -class ModuleConfig: - """Configuration for a test module.""" - name: str - java_agent: str - java_agent_auto_init: str - build_before_run: str - - def uses_agent(self) -> bool: - """Check if this module uses the Java agent.""" - return str_to_bool(self.java_agent) == "1" - - def needs_build(self) -> bool: - """Check if this module needs to be built before running.""" - return str_to_bool(self.build_before_run) == "1" - - def is_spring_module(self) -> bool: - """Check if this is a Spring Boot module.""" - return "spring" in self.name - -@dataclass -class InteractiveSelection: - """Result of interactive module selection.""" - modules: List[ModuleConfig] - manual_test_mode: bool - build_agent: bool - - def is_empty(self) -> bool: - """Check if no modules were selected.""" - return len(self.modules) == 0 - - def is_single_module(self) -> bool: - """Check if exactly one module was selected.""" - return len(self.modules) == 1 - - def get_first_module(self) -> ModuleConfig: - """Get the first selected module (for manual test mode).""" - if self.is_empty(): - raise ValueError("No modules selected") - return self.modules[0] - - def has_agent_modules(self) -> bool: - """Check if any selected modules use the Java agent.""" - return any(module_config.uses_agent() for module_config in self.modules) - -class SystemTestRunner: - def __init__(self): - self.mock_server = Server(name="Mock", pid_filepath="sentry-mock-server.pid") - self.tomcat_server = Server(name="Tomcat", pid_filepath="tomcat-server.pid") - self.spring_server = Server(name="Spring", pid_filepath="spring-server.pid") - self.kafka_started_by_runner = False - - # Load existing PIDs if available - for server in (self.mock_server, self.tomcat_server, self.spring_server): - server.pid = self.read_pid_file(server.pid_filepath) - if server.pid: - print(f"Found existing {server.name} server PID: {server.pid}") - - def read_pid_file(self, pid_file: str) -> Optional[int]: - """Read PID from file if it exists.""" - try: - if os.path.exists(pid_file): - with open(pid_file, 'r') as f: - return int(f.read().strip()) - except (ValueError, IOError) as e: - print(f"Error reading PID file {pid_file}: {e}") - return None - - def is_process_running(self, pid: int) -> bool: - """Check if a process with given PID is still running.""" - try: - # Send signal 0 to check if process exists - os.kill(pid, 0) - return True - except (OSError, ProcessLookupError): - return False - - def kill_process(self, pid: int, name: str) -> None: - """Kill a process by PID.""" - try: - print(f"Killing existing {name} process with PID {pid}") - os.kill(pid, signal.SIGTERM) - time.sleep(2) # Give it time to terminate gracefully - - # Check if it's still running and force kill if necessary - if self.is_process_running(pid): - print(f"Process {pid} didn't terminate gracefully, force killing...") - os.kill(pid, signal.SIGKILL) - time.sleep(1) - except (OSError, ProcessLookupError): - print(f"Process {pid} was already dead") - - def exclude_kafka(self) -> bool: - return os.environ.get("ORG_GRADLE_PROJECT_excludeKafka") == "true" - - def module_requires_kafka(self, sample_module: str) -> bool: - return not self.exclude_kafka() and sample_module in KAFKA_BROKER_REQUIRED_MODULES - - def module_requires_kafka_profile(self, sample_module: str) -> bool: - return not self.exclude_kafka() and sample_module in KAFKA_PROFILE_REQUIRED_MODULES - - def wait_for_port(self, host: str, port: int, max_attempts: int = 20) -> bool: - for _ in range(max_attempts): - try: - with socket.create_connection((host, port), timeout=1): - return True - except OSError: - time.sleep(1) - return False - - def remove_kafka_broker_container(self) -> None: - subprocess.run( - ["docker", "rm", "-f", KAFKA_CONTAINER_NAME], - check=False, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - - def start_kafka_broker(self) -> None: - if self.wait_for_port("localhost", 9092, max_attempts=1): - print("Kafka broker already running on localhost:9092, reusing it.") - self.kafka_started_by_runner = False - return - - self.remove_kafka_broker_container() - - print("Starting Kafka broker (Redpanda) for system tests...") - run_result = subprocess.run( - [ - "docker", - "run", - "-d", - "--name", - KAFKA_CONTAINER_NAME, - "-p", - "9092:9092", - "docker.redpanda.com/redpandadata/redpanda:v24.1.9", - "redpanda", - "start", - "--overprovisioned", - "--smp", - "1", - "--memory", - "1G", - "--reserve-memory", - "0M", - "--node-id", - "0", - "--check=false", - "--kafka-addr", - "PLAINTEXT://0.0.0.0:9092", - "--advertise-kafka-addr", - "PLAINTEXT://localhost:9092", - ], - check=False, - capture_output=True, - text=True, - ) - - if run_result.returncode != 0: - raise RuntimeError(f"Failed to start Kafka container: {run_result.stderr}") - - if not self.wait_for_port("localhost", 9092, max_attempts=30): - raise RuntimeError("Kafka broker did not become ready on localhost:9092") - - self.kafka_started_by_runner = True - - def stop_kafka_broker(self) -> None: - if not self.kafka_started_by_runner: - return - - self.remove_kafka_broker_container() - self.kafka_started_by_runner = False - - def start_sentry_mock_server(self) -> None: - """Start the Sentry mock server.""" - print("Starting Sentry mock server...") - try: - # Start the mock server in the background - with open("sentry-mock-server.txt", "w") as log_file: - self.mock_server.process = subprocess.Popen( - ["python3", "test/system-test-sentry-server.py"], - stdout=log_file, - stderr=subprocess.STDOUT - ) - - # Store PID in instance variable and write to file - store_pid(self.mock_server) - print(f"Started mock server with PID {self.mock_server.pid}") - - # Wait a moment for the server to start - time.sleep(2) - - except Exception as e: - print(f"Failed to start mock server: {e}") - raise - - def stop_sentry_mock_server(self) -> None: - """Stop the Sentry mock server.""" - try: - # Try graceful shutdown first - try: - response = requests.get("http://127.0.0.1:8000/STOP", timeout=5) - print("Sent stop signal to mock server") - except: - print("Could not send graceful stop signal") - - # Kill the process - try process object first, then PID from file - if self.mock_server.process and self.mock_server.process.poll() is None: - print(f"Killing mock server process object with PID {self.mock_server.process.pid}") - self.mock_server.process.kill() - self.mock_server.process.wait(timeout=5) - elif self.mock_server.pid and self.is_process_running(self.mock_server.pid): - print(f"Killing mock server from PID file with PID {self.mock_server.pid}") - self.kill_process(self.mock_server.pid, "mock server") - - except Exception as e: - print(f"Error stopping mock server: {e}") - finally: - # Clean up PID file and instance variable - cleanup_pid(self.mock_server) - - def read_version_from_gradle_properties(self) -> Optional[str]: - """Read the versionName from gradle.properties.""" - try: - with open("gradle.properties", "r") as f: - for line in f: - line = line.strip() - if line.startswith("versionName="): - return line.split("=", 1)[1] - except (IOError, IndexError) as e: - print(f"Error reading version from gradle.properties: {e}") - return None - - def find_agent_jar(self) -> Optional[str]: - """Find the OpenTelemetry agent JAR file with the specific version from gradle.properties.""" - agent_dir = Path("sentry-opentelemetry/sentry-opentelemetry-agent/build/libs/") - if not agent_dir.exists(): - return None - - # Get the version from gradle.properties - version = self.read_version_from_gradle_properties() - if not version: - print("Error: Could not read version from gradle.properties") - return None - - # Look for the specific versioned JAR - versioned_jar_pattern = f"*agent*{version}*.jar" - for jar_file in agent_dir.glob(versioned_jar_pattern): - name = jar_file.name - if ("javadoc" not in name and - "sources" not in name and - "dontuse" not in name): - print(f"Found versioned agent JAR: {jar_file}") - return str(jar_file) - - # If versioned JAR not found, print helpful message - print(f"Error: Could not find agent JAR with version {version} in {agent_dir}") - print("Available JAR files:") - for jar_file in agent_dir.glob("*.jar"): - print(f" {jar_file.name}") - return None - - def build_agent_jar(self) -> int: - """Build the OpenTelemetry agent JAR file.""" - print("Building OpenTelemetry agent JAR...") - return self.run_gradle_task(":sentry-opentelemetry:sentry-opentelemetry-agent:assemble") - - def ensure_agent_jar(self, skip_build: bool = False) -> Optional[str]: - """Ensure the OpenTelemetry agent JAR exists, building it if necessary.""" - agent_jar = self.find_agent_jar() - if agent_jar: - return agent_jar - - if skip_build: - print("OpenTelemetry agent JAR not found and build was skipped") - return None - - # Agent JAR doesn't exist, try to build it - print("OpenTelemetry agent JAR not found, building it...") - build_result = self.build_agent_jar() - if build_result != 0: - print("Failed to build OpenTelemetry agent JAR") - return None - - # Try to find it again after building - agent_jar = self.find_agent_jar() - if not agent_jar: - print("OpenTelemetry agent JAR still not found after building") - return None - - return agent_jar - - def start_tomcat_server(self, sample_module: str) -> None: - # Build environment variables - env = os.environ.copy() - env.update(SENTRY_ENVIRONMENT_VARIABLES) - - try: - # Start the Tomcat server - with open("tomcat-server.txt", "w") as log_file: - self.tomcat_server.process = subprocess.Popen( - ["./gradlew", f"sentry-samples:{sample_module}:run", "--console=plain"], - env=env, - stdout=log_file, - stderr=subprocess.STDOUT - ) - - store_pid(self.tomcat_server) - print(f"Started Tomcat server with PID {self.tomcat_server.pid}") - except Exception as e: - print(f"Failed to start Tomcat server: {e}") - raise - - def start_spring_server(self, sample_module: str, java_agent: str, java_agent_auto_init: str) -> None: - """Start a Spring Boot server for testing.""" - print(f"Starting Spring server for {sample_module}...") - - # Build environment variables - env = os.environ.copy() - env.update(SENTRY_ENVIRONMENT_VARIABLES) - env["SENTRY_AUTO_INIT"] = java_agent_auto_init - - if self.module_requires_kafka_profile(sample_module): - env["SPRING_PROFILES_ACTIVE"] = "kafka" - env["SENTRY_ENABLE_QUEUE_TRACING"] = "true" - print("Enabling Spring profile: kafka") - else: - env.pop("SPRING_PROFILES_ACTIVE", None) - - # Build command - jar_path = f"sentry-samples/{sample_module}/build/libs/{sample_module}-0.0.1-SNAPSHOT.jar" - cmd = ["java"] - - if java_agent == "1": - agent_jar = self.ensure_agent_jar() - if agent_jar: - cmd.append(f"-javaagent:{agent_jar}") - print(f"Using Java Agent: {agent_jar}") - else: - print("Warning: Java agent was requested but could not be found or built") - - cmd.extend(["-jar", jar_path]) - - try: - # Start the Spring server - with open("spring-server.txt", "w") as log_file: - self.spring_server.process = subprocess.Popen( - cmd, - env=env, - stdout=log_file, - stderr=subprocess.STDOUT - ) - - # Store PID in instance variable and write to file - store_pid(self.spring_server) - print(f"Started Spring server with PID {self.spring_server.pid}") - - except Exception as e: - print(f"Failed to start Spring server: {e}") - raise - - def wait_for_tomcat(self, module_name: str, max_attempts: int = 20) -> bool: - """Wait for Tomcat to be ready.""" - print("Waiting for Tomcat to be ready...") - - for attempt in range(1, max_attempts + 1): - try: - response = requests.head( - f"http://localhost:8080/{module_name}-0.0.1-SNAPSHOT/person/1", - timeout=5 - ) - if response.status_code != 404: - print("Tomcat is ready!") - return True - except: - pass - - print(f"Waiting... (attempt {attempt}/{max_attempts})") - time.sleep(1) - - print("Tomcat failed to become ready") - return False - - def wait_for_spring(self, max_attempts: int = 20) -> bool: - """Wait for Spring Boot application to be ready.""" - print("Waiting for Spring application to be ready...") - - for attempt in range(1, max_attempts + 1): - # All current Spring Boot samples expose actuator/health. Waiting for the - # health endpoint avoids false positives from unrelated services on 8080. - try: - response = requests.head( - "http://localhost:8080/actuator/health", - auth=("user", "password"), - timeout=5 - ) - if response.status_code == 200: - print("Spring application is ready!") - return True - except: - pass - - print(f"Waiting... (attempt {attempt}/{max_attempts})") - time.sleep(1) - - print("Spring application failed to become ready") - return False - - def get_spring_status(self) -> dict: - """Get status of Spring Boot application.""" - status = { - "process_running": False, - "pid": self.spring_server.pid, - "http_ready": False - } - - if self.spring_server.pid and self.is_process_running(self.spring_server.pid): - status["process_running"] = True - - # Check HTTP endpoint - try: - response = requests.head( - "http://localhost:8080/actuator/health", - auth=("user", "password"), - timeout=2 - ) - if response.status_code == 200: - status["http_ready"] = True - except: - pass - - return status - - def get_sentry_status(self) -> dict: - """Get status of Sentry mock server.""" - status = { - "process_running": False, - "pid": self.mock_server.pid, - "http_ready": False - } - - if self.mock_server.pid and self.is_process_running(self.mock_server.pid): - status["process_running"] = True - - # Check HTTP endpoint - try: - response = requests.get("http://127.0.0.1:8000/envelope-count", timeout=2) - if response.status_code == 200: - status["http_ready"] = True - except: - pass - - return status - - def print_status_summary(self) -> None: - """Print status summary of all services.""" - print("=== Service Status ===") - - sentry_status = self.get_sentry_status() - print(f"Sentry Mock Server:") - print(f" PID: {sentry_status['pid'] or 'None'}") - print(f" Process Running: {'✅' if sentry_status['process_running'] else '❌'}") - print(f" HTTP Ready: {'✅' if sentry_status['http_ready'] else '❌'}") - - spring_status = self.get_spring_status() - print(f"Spring Boot App:") - print(f" PID: {spring_status['pid'] or 'None'}") - print(f" Process Running: {'✅' if spring_status['process_running'] else '❌'}") - print(f" HTTP Ready: {'✅' if spring_status['http_ready'] else '❌'}") - - def stop_tomcat_server(self) -> None: - """Stop the Tomcat server.""" - try: - # Kill the process - try process object first, then PID from file - if self.tomcat_server.process and self.tomcat_server.process.poll() is None: - print(f"Killing Tomcat server process object with PID {self.tomcat_server.process.pid}") - self.tomcat_server.process.kill() - try: - self.tomcat_server.process.wait(timeout=10) - except subprocess.TimeoutExpired: - print("Tomcat server did not terminate gracefully") - elif self.tomcat_server.pid and self.is_process_running(self.tomcat_server.pid): - print(f"Killing Tomcat server from PID file with PID {self.tomcat_server.pid}") - self.kill_process(self.tomcat_server.pid, "Tomcat server") - - except Exception as e: - print(f"Error stopping Tomcat server: {e}") - finally: - # Clean up PID file and instance variable - cleanup_pid(self.tomcat_server) - - def stop_spring_server(self) -> None: - """Stop the Spring Boot server.""" - try: - # Kill the process - try process object first, then PID from file - if self.spring_server.process and self.spring_server.process.poll() is None: - print(f"Killing Spring server process object with PID {self.spring_server.process.pid}") - self.spring_server.process.kill() - try: - self.spring_server.process.wait(timeout=10) - except subprocess.TimeoutExpired: - print("Spring server did not terminate gracefully") - elif self.spring_server.pid and self.is_process_running(self.spring_server.pid): - print(f"Killing Spring server from PID file with PID {self.spring_server.pid}") - self.kill_process(self.spring_server.pid, "Spring server") - - except Exception as e: - print(f"Error stopping Spring server: {e}") - finally: - # Clean up PID file and instance variable - cleanup_pid(self.spring_server) - - def get_build_task(self, sample_module: str, server_type: Optional[ServerType]) -> str: - """Get the appropriate build task for a module.""" - if server_type == ServerType.TOMCAT: - return "war" - elif server_type == ServerType.SPRING: - # Modules using Shadow plugin (e.g. Spring Boot 2 samples) use shadowJar, - # modules using Spring Boot plugin (SB3/SB4 samples) use bootJar - build_file = Path(f"sentry-samples/{sample_module}/build.gradle.kts") - if build_file.exists() and "shadow" in build_file.read_text(): - return "shadowJar" - return "bootJar" - - return "assemble" - - def build_module(self, sample_module: str, server_type: Optional[ServerType]) -> int: - """Build a sample module using the appropriate task.""" - build_task = self.get_build_task(sample_module, server_type) - print(f"Building {sample_module} using {build_task} task") - return self.run_gradle_task(f":sentry-samples:{sample_module}:{build_task}") - - def run_gradle_task(self, task: str) -> int: - """Run a Gradle task and return the exit code.""" - print(f"Running: ./gradlew {task}") - try: - result = subprocess.run(["./gradlew", task, "--console=plain"], check=False) - return result.returncode - except Exception as e: - print(f"Failed to run Gradle task: {e}") - return 1 - - def setup_test_infrastructure(self, sample_module: str, java_agent: str, - java_agent_auto_init: str, build_before_run: str, - server_type: Optional[ServerType]) -> int: - """Set up test infrastructure. Returns 0 on success, error code on failure.""" - if self.module_requires_kafka(sample_module): - self.start_kafka_broker() - os.environ["SENTRY_SAMPLE_KAFKA_BOOTSTRAP_SERVERS"] = KAFKA_BOOTSTRAP_SERVERS - else: - os.environ.pop("SENTRY_SAMPLE_KAFKA_BOOTSTRAP_SERVERS", None) - - # Build if requested - if build_before_run == "1": - print("Building before test run") - build_result = self.build_module(sample_module, server_type) - if build_result != 0: - print("Build failed") - return build_result - - # Ensure agent JAR is available if needed - if java_agent == "1": - agent_jar = self.ensure_agent_jar() - if not agent_jar: - print("Error: Java agent was requested but could not be found or built") - return 1 - - # Start mock server - print("Starting Sentry mock server...") - self.start_sentry_mock_server() - - # Start Tomcat server given a Spring module - if server_type == ServerType.TOMCAT: - print(f"Starting Tomcat server for {sample_module}...") - self.start_tomcat_server(sample_module) - if not self.wait_for_tomcat(sample_module): - print("Tomcat application failed to start!") - return 1 - print("Tomcat application is ready!") - # Start Spring server if it's a Spring boot module - elif server_type == ServerType.SPRING: - print(f"Starting Spring server for {sample_module}...") - self.start_spring_server(sample_module, java_agent, java_agent_auto_init) - if not self.wait_for_spring(): - print("Spring application failed to start!") - return 1 - print("Spring application is ready!") - - return 0 - - def run_single_test(self, sample_module: str, java_agent: str, - java_agent_auto_init: str, build_before_run: str) -> int: - """Run a single system test.""" - print(f"Running system test for {sample_module}") - - server_type = get_server_type_for_module(sample_module) - - try: - # Set up infrastructure - setup_result = self.setup_test_infrastructure(sample_module, - java_agent, - java_agent_auto_init, - build_before_run, - server_type) - if setup_result != 0: - return setup_result - - # Run the system test - test_result = self.run_gradle_task(f":sentry-samples:{sample_module}:systemTest") - - return test_result - - finally: - # Cleanup - if server_type == ServerType.TOMCAT: - self.stop_tomcat_server() - elif server_type == ServerType.SPRING: - self.stop_spring_server() - self.stop_sentry_mock_server() - self.stop_kafka_broker() - os.environ.pop("SENTRY_SAMPLE_KAFKA_BOOTSTRAP_SERVERS", None) - - def run_all_tests(self) -> int: - """Run all system tests.""" - test_configs = self.get_available_modules() - - failed_tests = [] - - for i, module_config in enumerate(test_configs): - # Convert true/false to internal 1/0 format - agent = str_to_bool(module_config.java_agent) - auto_init = module_config.java_agent_auto_init # already in correct format - build = str_to_bool(module_config.build_before_run) - - print(f"\n{'='*TERMINAL_COLUMNS}") - print(f"Running test {i + 1}/{len(test_configs)}: {module_config.name} (agent={module_config.java_agent}, auto_init={module_config.java_agent_auto_init})") - print(f"{'='*TERMINAL_COLUMNS}") - - result = self.run_single_test(module_config.name, agent, auto_init, build) - - if result != 0: - # Find the module number in the full list for interactive reference - module_number = self._find_module_number(module_config.name, module_config.java_agent, module_config.java_agent_auto_init) - failed_tests.append((module_number, module_config.name, module_config.java_agent, module_config.java_agent_auto_init)) - print(f"❌ Test failed: {module_config.name}") - else: - print(f"✅ Test passed: {module_config.name}") - - # Summary - print(f"\n{'='*TERMINAL_COLUMNS}") - print("TEST SUMMARY") - print(f"{'='*TERMINAL_COLUMNS}") - print(f"Total tests: {len(test_configs)}") - print(f"Passed: {len(test_configs) - len(failed_tests)}") - print(f"Failed: {len(failed_tests)}") - - if failed_tests: - print("\nFailed tests (for interactive mode, use these numbers):") - for module_number, sample_module, java_agent, java_agent_auto_init in failed_tests: - print(f" {module_number}. {sample_module} (agent={java_agent}, auto_init={java_agent_auto_init})") - return 1 - else: - print("\n🎉 All tests passed!") - return 0 - - def run_manual_test_mode(self, sample_module: str, java_agent: str, - java_agent_auto_init: str, build_before_run: str) -> int: - """Set up infrastructure for manual testing from IDE.""" - print(f"Setting up manual test environment for {sample_module}") - - server_type = get_server_type_for_module(sample_module) - try: - # Set up infrastructure - setup_result = self.setup_test_infrastructure(sample_module, java_agent, java_agent_auto_init, build_before_run, server_type) - if setup_result != 0: - return setup_result - - # Show status and wait for user - print("\n" + "="*TERMINAL_COLUMNS) - print("🚀 Manual test environment ready 🚀") - print("="*TERMINAL_COLUMNS) - self.print_status_summary() - print(f"\nInfrastructure is ready for manual testing of: {sample_module}") - print("You can now run your system tests from your IDE.") - print("\nTest configuration:") - print(f" - Module: {sample_module}") - print(f" - Java Agent: {'Yes' if java_agent == '1' else 'No'}") - print(f" - Agent Auto-init: {java_agent_auto_init}") - print(f" - Mock DSN: http://502f25099c204a2fbf4cb16edc5975d1@localhost:8000/0") - - if server_type == ServerType.TOMCAT: - print(f"\nTomcat app is running on: http://localhost:8080/{sample_module}-0.0.1-SNAPSHOT") - elif server_type == ServerType.SPRING: - print("\nSpring Boot app is running on: http://localhost:8080") - - print("\nPress Enter to stop the infrastructure and exit...") - - # Wait for user input - try: - input() - except KeyboardInterrupt: - print("\nReceived interrupt signal") - - print("\nStopping infrastructure...") - return 0 - - finally: - # Cleanup will happen in the finally block of main() - pass - - def get_available_modules(self) -> List[ModuleConfig]: - """Get list of all available test modules.""" - return [ - ModuleConfig("sentry-samples-spring", "false", "true", "false"), - ModuleConfig("sentry-samples-spring-boot-4-otlp", "false", "true", "false"), - ModuleConfig("sentry-samples-console", "false", "true", "false"), - ModuleConfig("sentry-samples-console-otlp", "false", "true", "false"), - ModuleConfig("sentry-samples-console-opentelemetry-noagent", "false", "true", "false"), - ModuleConfig("sentry-samples-log4j2", "false", "true", "false"), - ModuleConfig("sentry-samples-jul", "false", "true", "false"), - ] - - def _find_module_number(self, module_name: str, agent: str, auto_init: str) -> int: - """Find the module number in the interactive list (1-based).""" - modules = self.get_available_modules() - for i, module_config in enumerate(modules, 1): - if (module_config.name == module_name and - module_config.java_agent == agent and - module_config.java_agent_auto_init == auto_init): - return i - return 0 # Should not happen, but return 0 if not found - - def parse_selection(self, user_input: str, max_index: int) -> List[int]: - """Parse user selection string into list of indices.""" - if user_input.strip() == "*": - return list(range(max_index)) - - indices = [] - parts = user_input.split(",") - - for part in parts: - part = part.strip() - if "-" in part: - # Handle range like "1-4" - try: - start, end = map(int, part.split("-")) - # Convert from 1-based to 0-based indexing - indices.extend(range(start - 1, end)) - except ValueError: - raise ValueError(f"Invalid range format: {part}") - else: - # Handle single number - try: - # Convert from 1-based to 0-based indexing - indices.append(int(part) - 1) - except ValueError: - raise ValueError(f"Invalid number: {part}") - - # Remove duplicates and sort - indices = sorted(set(indices)) - - # Validate indices - for idx in indices: - if idx < 0 or idx >= max_index: - raise ValueError(f"Index {idx + 1} is out of range (1-{max_index})") - - return indices - - def interactive_module_selection(self) -> InteractiveSelection: - """Display modules and get user selection.""" - modules = self.get_available_modules() - - print("\nAvailable test modules:") - print("=" * 80) - for i, module_config in enumerate(modules, 1): - agent_text = "with agent" if module_config.uses_agent() else "no agent" - auto_init_text = f"auto-init: {module_config.java_agent_auto_init}" - print(f"{i:2d}. {module_config.name:<50} ({agent_text}, {auto_init_text})") - - print("\nSelection options:") - print(" * = all modules") - print(" Single: 1, 5, 8") - print(" Range: 1-4, 6-8") - print(" Combined: 1,2,4-5,8") - - selected_modules = [] - while True: - try: - user_input = input("\nEnter your selection: ").strip() - if not user_input: - print("Please enter a selection.") - continue - - selected_indices = self.parse_selection(user_input, len(modules)) - selected_modules = [modules[i] for i in selected_indices] - - # Show confirmation - print(f"\nSelected {len(selected_modules)} module(s):") - for i, module_config in enumerate(selected_modules, 1): - agent_text = "with agent" if module_config.uses_agent() else "no agent" - print(f" {i}. {module_config.name} ({agent_text}, auto-init: {module_config.java_agent_auto_init})") - - confirm = input("\nProceed with these selections? [Y/n]: ").strip().lower() - if confirm in ('', 'y', 'yes'): - break - else: - print("Please make a new selection.") - - except ValueError as e: - print(f"Error: {e}") - print("Please try again.") - except KeyboardInterrupt: - print("\nOperation cancelled.") - return InteractiveSelection(modules=[], manual_test_mode=False, build_agent=False) - - # Ask about test mode - manual_test_mode = False - while True: - try: - mode_input = input("\nRun tests automatically (n = only set up infrastucture for testing in IDE)? [Y/n]: ").strip().lower() - if not mode_input or mode_input in ('y', 'yes'): - manual_test_mode = False - break - elif mode_input in ('n', 'no'): - manual_test_mode = True - break - else: - print("Please enter 'y' or 'n'.") - except KeyboardInterrupt: - print("\nOperation cancelled.") - return InteractiveSelection(modules=[], manual_test_mode=False, build_agent=False) - - # Ask about building agent if any modules use it - build_agent = False - has_agent_modules = any(module_config.uses_agent() for module_config in selected_modules) - if has_agent_modules: - while True: - try: - agent_input = input("\nBuild OpenTelemetry agent JAR (recommended to ensure latest version)? [Y/n]: ").strip().lower() - if not agent_input or agent_input in ('y', 'yes'): - build_agent = True - break - elif agent_input in ('n', 'no'): - build_agent = False - break - else: - print("Please enter 'y' or 'n'.") - except KeyboardInterrupt: - print("\nOperation cancelled.") - return InteractiveSelection(modules=[], manual_test_mode=False, build_agent=False) - - return InteractiveSelection(modules=selected_modules, manual_test_mode=manual_test_mode, build_agent=build_agent) - - def run_interactive_tests(self, agent: str, auto_init: str, build: str) -> int: - """Run tests with interactive module selection.""" - selection = self.interactive_module_selection() - - if selection.is_empty(): - print("No modules selected. Exiting.") - return 0 - - # Build agent JAR if requested and modules use agent - if selection.build_agent and selection.has_agent_modules(): - print("\nBuilding OpenTelemetry agent JAR...") - build_result = self.build_agent_jar() - if build_result != 0: - print("Failed to build OpenTelemetry agent JAR") - return build_result - print("✅ OpenTelemetry agent JAR built successfully") - - # Handle manual test mode - if selection.manual_test_mode: - if not selection.is_single_module(): - print("Error: Manual test mode can only be used with a single module.") - print("Please select only one module for manual testing.") - return 1 - - module_config = selection.get_first_module() - # Convert true/false to internal 1/0 format - agent = str_to_bool(module_config.java_agent) - auto_init = module_config.java_agent_auto_init # already in correct format - build = str_to_bool(module_config.build_before_run) - - print(f"\nSetting up manual test environment for: {module_config.name}") - return self.run_manual_test_mode(module_config.name, agent, auto_init, build) - - # Handle automatic test running - failed_tests = [] - - for i, module_config in enumerate(selection.modules, 1): - # Convert true/false to internal 1/0 format - agent = str_to_bool(module_config.java_agent) - auto_init = module_config.java_agent_auto_init # already in correct format - build = str_to_bool(module_config.build_before_run) - - print(f"\n{'='*TERMINAL_COLUMNS}") - print(f"Running test {i}/{len(selection.modules)}: {module_config.name}") - print(f"Agent: {module_config.java_agent}, Auto-init: {module_config.java_agent_auto_init}") - print(f"{'='*TERMINAL_COLUMNS}") - - result = self.run_single_test(module_config.name, agent, auto_init, build) - - if result != 0: - # Find the module number in the full list for interactive reference - module_number = self._find_module_number(module_config.name, module_config.java_agent, module_config.java_agent_auto_init) - failed_tests.append((module_number, module_config.name, module_config.java_agent, module_config.java_agent_auto_init)) - print(f"❌ Test failed: {module_config.name}") - else: - print(f"✅ Test passed: {module_config.name}") - - # Summary - print(f"\n{'='*TERMINAL_COLUMNS}") - print("TEST SUMMARY") - print(f"{'='*TERMINAL_COLUMNS}") - print(f"Total tests: {len(selection.modules)}") - print(f"Passed: {len(selection.modules) - len(failed_tests)}") - print(f"Failed: {len(failed_tests)}") - - if failed_tests: - print("\nFailed tests (for interactive mode, use these numbers):") - for module_number, sample_module, test_agent, test_auto_init in failed_tests: - print(f" {module_number}. {sample_module} (agent={test_agent}, auto_init={test_auto_init})") - return 1 - else: - print("\n🎉 All tests passed!") - return 0 - - def cleanup_on_exit(self, signum, frame): - """Cleanup handler for signals.""" - print(f"\nReceived signal {signum}, cleaning up...") - self.stop_spring_server() - self.stop_sentry_mock_server() - self.stop_tomcat_server() - self.stop_kafka_broker() - os.environ.pop("SENTRY_SAMPLE_KAFKA_BOOTSTRAP_SERVERS", None) - sys.exit(1) - -def main(): - parser = argparse.ArgumentParser(description="System Test Runner for Sentry Java") - subparsers = parser.add_subparsers(dest="command", help="Available commands") - - # Test subcommand - test_parser = subparsers.add_parser("test", help="Run system tests") - test_group = test_parser.add_mutually_exclusive_group(required=True) - test_group.add_argument("--all", action="store_true", help="Run all system tests") - test_group.add_argument("--module", help="Sample module to test") - test_group.add_argument("--interactive", "-i", action="store_true", help="Interactive module selection") - test_parser.add_argument("--agent", default="false", help="Use Java agent (true or false)") - test_parser.add_argument("--auto-init", default="true", help="Auto-init agent (true or false)") - test_parser.add_argument("--build", default="false", help="Build before running (true or false)") - test_parser.add_argument("--manual-test", action="store_true", help="Set up infrastructure but pause for manual testing from IDE") - - # Tomcat subcommand - tomcat_parser = subparsers.add_parser("tomcat", help="Manage Servlet applications deployed on Tomcat") - tomcat_subparsers = tomcat_parser.add_subparsers(dest="tomcat_action", help="Tomcat actions") - - tomcat_start_parser = tomcat_subparsers.add_parser("start", help="Start tomcat application") - tomcat_start_parser.add_argument("module", help="Sample module to start") - tomcat_start_parser.add_argument("--build", default="false", help="Build before starting (true or false)") - - tomcat_subparsers.add_parser("stop", help="Stop Spring Boot application") - - # Spring subcommand - spring_parser = subparsers.add_parser("spring", help="Manage Spring Boot applications") - spring_subparsers = spring_parser.add_subparsers(dest="spring_action", help="Spring actions") - - spring_start_parser = spring_subparsers.add_parser("start", help="Start Spring Boot application") - spring_start_parser.add_argument("module", help="Sample module to start") - spring_start_parser.add_argument("--agent", default="false", help="Use Java agent (true or false)") - spring_start_parser.add_argument("--auto-init", default="true", help="Auto-init agent (true or false)") - spring_start_parser.add_argument("--build", default="false", help="Build before starting (true or false)") - - spring_stop_parser = spring_subparsers.add_parser("stop", help="Stop Spring Boot application") - - spring_wait_parser = spring_subparsers.add_parser("wait", help="Wait for Spring Boot application to be ready") - spring_wait_parser.add_argument("--timeout", type=int, default=20, help="Max attempts to wait (default: 20)") - - spring_status_parser = spring_subparsers.add_parser("status", help="Check Spring Boot application status") - - # Sentry subcommand - sentry_parser = subparsers.add_parser("sentry", help="Manage Sentry mock server") - sentry_subparsers = sentry_parser.add_subparsers(dest="sentry_action", help="Sentry actions") - - sentry_start_parser = sentry_subparsers.add_parser("start", help="Start Sentry mock server") - sentry_stop_parser = sentry_subparsers.add_parser("stop", help="Stop Sentry mock server") - sentry_status_parser = sentry_subparsers.add_parser("status", help="Check Sentry mock server status") - - # Status subcommand - status_parser = subparsers.add_parser("status", help="Show status of all services") - - args = parser.parse_args() - - if not args.command: - parser.print_help() - return 1 - - runner = SystemTestRunner() - - # Set up signal handlers for cleanup - signal.signal(signal.SIGINT, runner.cleanup_on_exit) - signal.signal(signal.SIGTERM, runner.cleanup_on_exit) - - try: - if args.command == "test": - # Convert true/false arguments to internal 1/0 format - agent = str_to_bool(args.agent) - auto_init = args.auto_init # already accepts true/false - build = str_to_bool(args.build) - - if args.manual_test and args.module: - return runner.run_manual_test_mode(args.module, agent, auto_init, build) - elif args.manual_test and args.all: - print("Error: --manual-test requires a specific --module, cannot be used with --all") - return 1 - elif args.manual_test and args.interactive: - print("Error: --manual-test cannot be used with --interactive") - return 1 - elif args.all: - return runner.run_all_tests() - elif args.module: - return runner.run_single_test(args.module, agent, auto_init, build) - elif args.interactive: - return runner.run_interactive_tests(agent, auto_init, build) - - elif args.command == "tomcat": - if args.tomcat_action == "start": - # Convert true/false arguments to internal format - build = str_to_bool(args.build) - - # Build if requested - if build == "1": - print("Building before starting Tomcat application") - build_result = runner.build_module(args.module, ServerType.TOMCAT) - if build_result != 0: - print("Build failed") - return build_result - - runner.start_tomcat_server(args.module) - - if runner.wait_for_tomcat(args.module): - print("Tomcat application started successfully!") - return 0 - else: - print("Tomcat application failed to start!") - return 1 - elif args.tomcat_action == "stop": - runner.stop_tomcat_server() - print("Tomcat application stopped.") - return 0 - - elif args.command == "spring": - if args.spring_action == "start": - # Convert true/false arguments to internal format - agent = str_to_bool(args.agent) - auto_init = args.auto_init # already accepts true/false - build = str_to_bool(args.build) - - # Build if requested - if build == "1": - print("Building before starting Spring application") - build_result = runner.build_module(args.module, ServerType.SPRING) - if build_result != 0: - print("Build failed") - return build_result - - runner.start_spring_server(args.module, agent, auto_init) - if runner.wait_for_spring(): - print("Spring application started successfully!") - return 0 - else: - print("Spring application failed to start!") - return 1 - elif args.spring_action == "stop": - runner.stop_spring_server() - print("Spring application stopped.") - return 0 - elif args.spring_action == "wait": - if runner.wait_for_spring(max_attempts=args.timeout): - print("Spring application is ready!") - return 0 - else: - print("Spring application is not ready!") - return 1 - elif args.spring_action == "status": - status = runner.get_spring_status() - print(f"Spring Boot Application Status:") - print(f" PID: {status['pid'] or 'None'}") - print(f" Process Running: {'✅' if status['process_running'] else '❌'}") - print(f" HTTP Ready: {'✅' if status['http_ready'] else '❌'}") - return 0 if (status['process_running'] and status['http_ready']) else 1 - else: - spring_parser.print_help() - return 1 - - elif args.command == "sentry": - if args.sentry_action == "start": - runner.start_sentry_mock_server() - print("Sentry mock server started successfully!") - return 0 - elif args.sentry_action == "stop": - runner.stop_sentry_mock_server() - print("Sentry mock server stopped.") - return 0 - elif args.sentry_action == "status": - status = runner.get_sentry_status() - print(f"Sentry Mock Server Status:") - print(f" PID: {status['pid'] or 'None'}") - print(f" Process Running: {'✅' if status['process_running'] else '❌'}") - print(f" HTTP Ready: {'✅' if status['http_ready'] else '❌'}") - return 0 if (status['process_running'] and status['http_ready']) else 1 - else: - sentry_parser.print_help() - return 1 - - elif args.command == "status": - runner.print_status_summary() - return 0 - else: - parser.print_help() - return 1 - - except KeyboardInterrupt: - print("\nInterrupted by user") - return 1 - except Exception as e: - print(f"Error: {e}") - return 1 - finally: - # Only cleanup if running tests or manual test mode, not for individual start/stop commands - if args.command == "test": - runner.stop_spring_server() - runner.stop_sentry_mock_server() - runner.stop_tomcat_server() - runner.stop_kafka_broker() - os.environ.pop("SENTRY_SAMPLE_KAFKA_BOOTSTRAP_SERVERS", None) - -if __name__ == "__main__": - sys.exit(main()) diff --git a/test/system-test-sentry-server.py b/test/system-test-sentry-server.py deleted file mode 100755 index 28232410622..00000000000 --- a/test/system-test-sentry-server.py +++ /dev/null @@ -1,168 +0,0 @@ -#!/usr/bin/env python3 - -from http import HTTPStatus -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from urllib.parse import urlparse -import sys -import threading -import binascii -import json -import gzip - -apiOrg = 'sentry-sdks' -apiProject = 'sentry-java' -uri = urlparse(sys.argv[1] if len(sys.argv) > 1 else 'http://127.0.0.1:8000') -version='1.1.0' -appIdentifier='com.sentry.fastlane.app' - -class EnvelopeStorage: - __envelopes_received = [] - - @classmethod - def add(cls, envelope): - cls.__envelopes_received.append(envelope) - - @classmethod - def get_envelopes_received(cls): - return cls.__envelopes_received - - @classmethod - def get_json(cls): - jsonObject = { - 'envelopes': cls.__envelopes_received - } - return json.dumps(jsonObject) - - @classmethod - def reset(cls): - cls.__envelopes_received.clear() - -class EnvelopeCount: - __envelopes_received = 0 - - @classmethod - def increment(cls): - cls.__envelopes_received += 1 - - @classmethod - def get_envelopes_received(cls): - return cls.__envelopes_received - - @classmethod - def get_json(cls): - jsonObject = { - 'envelopes': cls.__envelopes_received - } - return json.dumps(jsonObject) - - @classmethod - def reset(cls): - cls.__envelopes_received = 0 - - -class Handler(BaseHTTPRequestHandler): - body = None - - def do_GET(self): - self.start_response(HTTPStatus.OK) - - if self.path == "/STOP": - print("HTTP server stopping!") - threading.Thread(target=self.server.shutdown).start() - return - - if self.path == "/envelope-count": - print("Envelope count queried " + str(EnvelopeCount.get_envelopes_received())) - self.writeJSON(EnvelopeCount.get_json()) - return - - if self.path == "/envelopes-received": - print("Envelopes queried ") - self.writeJSON(EnvelopeStorage.get_json()) - return - - if self.path == "/reset": - print("Envelopes reset") - EnvelopeStorage.reset() - EnvelopeCount.reset() - self.writeJSON(json.dumps({})) - return - - self.flushLogs() - - def do_POST(self): - if self.isApi('/api/0/envelope/'): - EnvelopeCount.increment() - self.start_response(HTTPStatus.OK) - self.end_headers() - - self.flushLogs() - - def do_PUT(self): - self.start_response(HTTPStatus.OK) - self.end_headers() - - self.flushLogs() - - def start_response(self, code): - self.body = None - self.log_request(code) - self.send_response_only(code) - - def log_request(self, code=None, size=None): - if isinstance(code, HTTPStatus): - code = code.value - body = self.body = self.requestBody() - - if body: - EnvelopeStorage.add(str(body)) - body = self.body[0:min(1000, len(body))] - self.log_message('"%s" %s %s%s', - self.requestline, str(code), "({} bytes)".format(len(body)) if size else '', body) - - # Note: this may only be called once during a single request - can't `.read()` the same stream again. - def requestBody(self): - if self.command == "POST" and 'Content-Length' in self.headers: - content = self.getContent() - - try: - return content.decode("utf-8", errors='replace') - except Exception as e: - print(e) - return binascii.hexlify(bytearray(content)) - return None - - def isApi(self, api: str): - if self.path.strip('/') == api.strip('/'): - self.log_message("Matched API endpoint {}".format(api)) - return True - return False - - def writeJSONFile(self, file_name: str): - json_file = open(file_name, "r") - self.writeJSON(json_file.read()) - json_file.close() - - def writeJSON(self, string: str): - self.send_header("Content-type", "application/json") - self.end_headers() - self.wfile.write(str.encode(string)) - - def flushLogs(self): - sys.stdout.flush() - sys.stderr.flush() - - def getContent(self): - length = int(self.headers['Content-Length']) - content = self.rfile.read(length) - - if 'Content-Encoding' in self.headers and self.headers['Content-Encoding'] == 'gzip': - content = gzip.decompress(content) - - return content - - -print("HTTP server listening on {}".format(uri.geturl())) -print("To stop the server, execute a GET request to {}/STOP".format(uri.geturl())) -httpd = ThreadingHTTPServer((uri.hostname, uri.port), Handler) -target = httpd.serve_forever() From e506d5d4ac452f9ef7ed35c5ac4a3f537d358412 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 27 Oct 2026 09:13:07 +0100 Subject: [PATCH 3/3] test(spring): [Sample Tests 29] Move the Spring WAR sample's tests into the module (#6321) * test(spring): Move the Spring WAR sample's tests into the module Adds sentry-spring-jakarta's embedded-Tomcat integrationTest suite to sentry-spring, with the module's packages and javax.servlet. The sample's tests were the jakarta WAR sample's, so the suite is too. Spring Boot 2's BOM picks Spring 5.3 and Tomcat 9. The sample has no system tests left, so it comes off the system-test runner, the backend workflow and the Spring Boot 2 matrix. That was the matrix's last runner step, so it drops the Python setup, the runner's logs and the exclude properties only the samples and the runner read. Co-Authored-By: Claude Opus 5.5 (1M context) * test(log4j2): [Sample Tests 30] Move the log4j2 sample's system test into sentry-log4j2 (#6322) * test(log4j2): Move the log4j2 sample's system test into sentry-log4j2 Adds SentryAppenderIntegrationTest, which configures the Sentry appender from XML in a logger context of its own and checks what reaches MockSentryServer: the warning event with its context tag and flag, the error with its exception and breadcrumbs, and the logs with their attributes. Logs are enabled through external configuration, as the sample's sentry.properties does, because the appender has no attribute for them. The sample has no system tests left, so it comes off the system-test runner and the backend workflow, and drops the Kotlin plugin it only needed for the test. Co-Authored-By: Claude Opus 5.5 (1M context) * test(jul): [Sample Tests 31] Move the JUL sample's system test into sentry-jul (#6323) * test(jul): Move the JUL sample's system test into sentry-jul Adds SentryHandlerIntegrationTest, which configures the Sentry handler through a logging properties file read by LogManager, as the sample does, and checks what reaches MockSentryServer: the warning with its flag, the error with its exception and breadcrumbs, and the logs with their attributes. Its logger is outside io.sentry, whose loggers the handler drops, and the test restores the LogManager configuration the other tests read. The sample has no system tests left, so it comes off the system-test runner and the backend workflow, and drops the Kotlin plugin it only needed for the test. Co-Authored-By: Claude Opus 5.5 (1M context) * test: [Sample Tests 32] Add end-to-end tests of the core APIs and a profiled transaction (#6324) * test(core): Add an end-to-end test of the core APIs over the HTTP transport SentryEndToEndTest reports through the APIs an app uses without integrations and checks what reaches MockSentryServer: messages at the scope's and an explicit level, an exception with its feature flag, an event with a fingerprint, breadcrumbs from an event processor and a beforeBreadcrumb callback, ten events in a row, a transaction with a child span, and metrics with the scope's attributes. It ports the core part of the console sample's system test, which ran the sample's JAR against the Python mock server. The sample's profiling and Kafka parts follow in their own modules. Co-Authored-By: Claude Opus 5.5 (1M context) * Merge the audit fixes from the branches below, and check the exception type end to end The console sample's test checked that the captured exception arrives with its type, RuntimeException, not just its value. Co-Authored-By: Claude Opus 5.5 (1M context) * test(core): Check that the shutdown hook delivers what an app reported before exiting The console samples' system tests ran each sample in its own JVM, asserted that it exited with 0 within 30 seconds, and relied on the shutdown hook to send everything, because the samples never flush or close Sentry. The in-process end-to-end tests flush explicitly, so neither the clean exit nor the hook's flush was tested any more. Run a small app in a child JVM that returns from main without flushing, and check that it exits with 0 and that its event, log and metric arrive. Co-Authored-By: Claude Opus 5.5 (1M context) * test(async-profiler): Add an end-to-end test of a profiled transaction ContinuousProfilerEndToEndTest starts a sampled transaction with the TRACE profile lifecycle and checks that its profile chunk reaches MockSentryServer, linked to the transaction by the profiler ID. It ports the profiling part of the console sample's system test. The profiler rejects a traces path with a dot anywhere in its absolute form, so the test puts it in the temp dir. Co-Authored-By: Claude Opus 5.5 (1M context) * test(kafka): [Sample Tests 34] Add an end-to-end test of the raw Kafka client's tracing (#6326) * test(async-profiler): Add an end-to-end test of a profiled transaction ContinuousProfilerEndToEndTest starts a sampled transaction with the TRACE profile lifecycle and checks that its profile chunk reaches MockSentryServer, linked to the transaction by the profiler ID. It ports the profiling part of the console sample's system test. The profiler rejects a traces path with a dot anywhere in its absolute form, so the test puts it in the temp dir. Co-Authored-By: Claude Opus 5.5 (1M context) * test(kafka): Add an end-to-end test of the raw Kafka client's tracing SentryKafkaEndToEndTest publishes a message through a wrapped KafkaProducer to an in-JVM KRaft broker, consumes it with SentryKafkaConsumerTracing, and checks what reaches MockSentryServer: the queue.publish span in the producing transaction, and a queue.process transaction with the consumer origin and the Kafka system that continues the producer's trace. It ports the Kafka part of the console sample's system test, which needed a Redpanda container. The broker comes from spring-kafka-test 3, which needs Java 17, so the test classpaths resolve Java 17 variants. The tests run on it anyway. With its core, profiling and Kafka parts ported, the console sample has no system test left, so it comes off the system-test runner and the backend workflow, whose base row moves to the console OTLP sample. Co-Authored-By: Claude Opus 5.5 (1M context) * test(opentelemetry): [Sample Tests 35] Add an end-to-end test of the agentless OpenTelemetry setup (#6327) * test(opentelemetry): Add an end-to-end test of the agentless OpenTelemetry setup AgentlessEndToEndTest builds the OpenTelemetry SDK through its autoconfiguration, which loads Sentry's customizer, and checks what reaches MockSentryServer: events, a transaction traced through OpenTelemetry with its child span and profile, and metrics. It ports sentry-samples-console-opentelemetry-noagent's system test, which no CI job ran. The customizer only initializes Sentry when the environment asks it to, so the test task sets SENTRY_AUTO_INIT. These are the module's first tests, so it applies the Kotlin plugin and gets an API dump. The sample has no system test left, so it comes off the system-test runner and drops the Kotlin plugin it only needed for the test. Co-Authored-By: Claude Opus 5.5 (1M context) * Merge the audit fixes from the branches below, and check the exception type in agentless mode Co-Authored-By: Claude Opus 5.5 (1M context) * test(opentelemetry): [Sample Tests 36] Add an end-to-end test of the OTLP event linking (#6328) * test(opentelemetry): Add an end-to-end test of the OTLP event linking OtlpEndToEndTest captures a message, a log and a metric inside an OpenTelemetry span with OpenTelemetryOtlpEventProcessor installed, and checks what reaches MockSentryServer: the event carries the span's trace and span IDs, and the log and the metric arrive. It ports the OTLP-specific part of the console OTLP sample's system test, which never checked the link. The sample's other assertions are the console sample's, which SentryEndToEndTest covers. The sample has no system test left, so it comes off the system-test runner and the backend workflow, whose base row moves to the Spring Boot 4 OTLP sample, and drops the Kotlin plugin it only needed for the test. Co-Authored-By: Claude Opus 5.5 (1M context) * test(spring-boot): [Sample Tests 37] Move the Boot 4 OTLP sample's tests into the module (#6329) * 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 runs the new suite. Co-Authored-By: Claude Opus 5.5 (1M context) * 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) * test: [Sample Tests 38] Remove the system-test runner and its Python mock server (#6330) * test: Remove the system-test runner and its Python mock server Every sample's system test now lives in the module it tests, so the Python runner, its mock Sentry server, requirements.txt and the Makefile targets that ran them have nothing left to run. Co-Authored-By: Claude Opus 5.5 (1M context) * test: [Sample Tests 39] Remove the io.sentry.systemtest plugin and sentry-system-test-support (#6331) * test: Remove the io.sentry.systemtest plugin and sentry-system-test-support With the runner gone, nothing applies the io.sentry.systemtest convention plugin or depends on sentry-system-test-support, which held the HTTP and GraphQL clients and the TestHelper the system tests used. The envelope matchers it delegated to stay in sentry-test-support. Co-Authored-By: Claude Opus 5.5 (1M context) * docs: [Sample Tests 40] Describe the in-module end-to-end tests instead of the system tests (#6332) * docs: Describe the in-module end-to-end tests instead of the system tests The e2e_tests rule now covers MockSentryServer, where each kind of end-to-end test lives, the Spring suites per flavor, the OpenTelemetry agent fixture, how to run them and the gotchas the migration found. The test skill runs integration suites by their source set instead of the Python runner, the new_module rule no longer asks for sample tests, and AGENTS.md drops sentry-system-test-support. Co-Authored-By: Claude Opus 5.5 (1M context) * Merge the audit fixes from the branches below, and document the test client and log4j2 suite The context's TestRestTemplate adds trace headers of its own whenever Sentry customizes the RestTemplateBuilder, not only in OpenTelemetry mode, and the Spring Boot modules have an integrationTestLog4j2 suite. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context)