From 998ccc0b0ed988a281e57dfe6015f106686d9a41 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 20 Oct 2026 19:58:03 +0200 Subject: [PATCH 1/2] 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) --- .../api/sentry-opentelemetry-agentless.api | 9 + .../build.gradle.kts | 16 ++ .../agentless/AgentlessEndToEndTest.kt | 161 ++++++++++++++++++ .../build.gradle.kts | 36 ---- .../src/test/kotlin/sentry/DummyTest.kt | 12 -- .../ConsoleApplicationSystemTest.kt | 119 ------------- test/system-test-runner.py | 1 - 7 files changed, 186 insertions(+), 168 deletions(-) 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 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 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..1e95961bb3a --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-agentless/src/test/kotlin/io/sentry/opentelemetry/agentless/AgentlessEndToEndTest.kt @@ -0,0 +1,161 @@ +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.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-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/test/system-test-runner.py b/test/system-test-runner.py index c790caac082..3788194a025 100644 --- a/test/system-test-runner.py +++ b/test/system-test-runner.py @@ -827,7 +827,6 @@ def get_available_modules(self) -> List[ModuleConfig]: return [ ModuleConfig("sentry-samples-spring-boot-4-otlp", "false", "true", "false"), ModuleConfig("sentry-samples-console-otlp", "false", "true", "false"), - ModuleConfig("sentry-samples-console-opentelemetry-noagent", "false", "true", "false"), ModuleConfig("sentry-samples-logback", "false", "true", "false"), ] From 2cd9e3b01a8c1dc8aef548f035f16df99acade64 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 20 Oct 2026 21:06:46 +0200 Subject: [PATCH 2/2] 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) --- .../io/sentry/opentelemetry/agentless/AgentlessEndToEndTest.kt | 2 ++ 1 file changed, 2 insertions(+) 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 index 1e95961bb3a..8f9d5d0fb8a 100644 --- 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 @@ -124,6 +124,8 @@ class AgentlessEndToEndTest { 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 =