From fa72eb727b93314328d4ad2d656b376a1123c122 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 20 Oct 2026 19:44:22 +0200 Subject: [PATCH 1/3] 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) --- .../test/java/io/sentry/SentryEndToEndTest.kt | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 sentry/src/test/java/io/sentry/SentryEndToEndTest.kt 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 0000000000..1406231209 --- /dev/null +++ b/sentry/src/test/java/io/sentry/SentryEndToEndTest.kt @@ -0,0 +1,111 @@ +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.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() + } +} From 536824d885d12dc2539f17b0c8f363c09312141d Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 20 Oct 2026 21:06:05 +0200 Subject: [PATCH 2/3] 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) --- sentry/src/test/java/io/sentry/SentryEndToEndTest.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sentry/src/test/java/io/sentry/SentryEndToEndTest.kt b/sentry/src/test/java/io/sentry/SentryEndToEndTest.kt index 1406231209..742f4462b2 100644 --- a/sentry/src/test/java/io/sentry/SentryEndToEndTest.kt +++ b/sentry/src/test/java/io/sentry/SentryEndToEndTest.kt @@ -88,6 +88,8 @@ class SentryEndToEndTest { 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 = From 8ebabb9c3d9331f6e3c63e2958a3dfe1ac1ffa99 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 23 Oct 2026 14:46:22 +0200 Subject: [PATCH 3/3] 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) --- .../main/kotlin/io/sentry/test/ChildJvmApp.kt | 17 ++++-- .../io/sentry/ShutdownHookEndToEndTest.kt | 56 +++++++++++++++++++ 2 files changed, 69 insertions(+), 4 deletions(-) create mode 100644 sentry/src/test/java/io/sentry/ShutdownHookEndToEndTest.kt 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 416fead4dd..21eeda7239 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/ShutdownHookEndToEndTest.kt b/sentry/src/test/java/io/sentry/ShutdownHookEndToEndTest.kt new file mode 100644 index 0000000000..c362ced5a2 --- /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") + } +}