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/SentryEndToEndTest.kt b/sentry/src/test/java/io/sentry/SentryEndToEndTest.kt new file mode 100644 index 0000000000..742f4462b2 --- /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 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") + } +}