diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index bbb4e60d984..3fae47058db 100644 --- a/.github/workflows/spring-boot-3-matrix.yml +++ b/.github/workflows/spring-boot-3-matrix.yml @@ -84,7 +84,8 @@ jobs: :sentry-spring-boot-jakarta:integrationTestWebflux \ :sentry-spring-boot-jakarta:integrationTestOtelNoAgent \ :sentry-spring-boot-jakarta:integrationTestLog4j2 \ - :sentry-spring-boot-jakarta:integrationTestOtelAgent + :sentry-spring-boot-jakarta:integrationTestOtelAgent \ + :sentry-spring-boot-jakarta:integrationTestOtelAgentWithoutAutoInit - name: Test sentry-samples-spring-boot-jakarta-opentelemetry agent init true run: | diff --git a/build.gradle.kts b/build.gradle.kts index ef4e491af95..40e5878fcfa 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -231,6 +231,7 @@ tasks.register("integrationTests") { ":sentry-spring-boot-jakarta:integrationTestOtelNoAgent", ":sentry-spring-boot-jakarta:integrationTestLog4j2", ":sentry-spring-boot-jakarta:integrationTestOtelAgent", + ":sentry-spring-boot-jakarta:integrationTestOtelAgentWithoutAutoInit", ":sentry-spring-boot-4:integrationTest", ":sentry-spring-boot-4:integrationTestWebflux", ":sentry-spring-boot-4:integrationTestOtelNoAgent", diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt deleted file mode 100644 index 3cd16003024..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/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-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt deleted file mode 100644 index ad9b5f77b62..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ /dev/null @@ -1,169 +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 = "spanCreatedThroughOtelApi", - featureFlag = FeatureFlag("flag.evaluation.my-feature-flag", true), - ) && - testHelper.doesTransactionHaveSpanWith(transaction, op = "spanCreatedThroughSentryApi") - } - - 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) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && - testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") && - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "db", - "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 - } - } - - @Test - fun `create person creates transaction if no sampled flag in sentry-trace header`() { - val restClient = testHelper.restClient - val person = Person("firstA", "lastB") - val returnedPerson = - restClient.createPerson( - person, - mapOf( - "sentry-trace" to "f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=1,sentry-trace_id=f9118105af4a2d42b4124532cd1065ff,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(200, restClient.lastKnownStatusCode) - - assertEquals(person.firstName, returnedPerson!!.firstName) - assertEquals(person.lastName, returnedPerson!!.lastName) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && - testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") && - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "db", - "insert into person (firstName, lastName) values (?, ?)", - ) - } - } - - @Test - fun `create person creates transaction if sampled true in sentry-trace header`() { - val restClient = testHelper.restClient - val person = Person("firstA", "lastB") - val returnedPerson = - restClient.createPerson( - person, - mapOf( - "sentry-trace" to "f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=1,sentry-trace_id=f9118105af4a2d42b4124532cd1065ff,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(200, restClient.lastKnownStatusCode) - - assertEquals(person.firstName, returnedPerson!!.firstName) - assertEquals(person.lastName, returnedPerson!!.lastName) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && - testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") && - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "db", - "insert into person (firstName, lastName) values (?, ?)", - ) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt deleted file mode 100644 index 18dc7d3f552..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/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.doesTransactionContainSpanWithOp(transaction, "todoSpanOtelApi") && - testHelper.doesTransactionContainSpanWithOp(transaction, "todoSpanSentryApi") && - 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.doesTransactionContainSpanWithOp(transaction, "todoRestClientSpanOtelApi") && - testHelper.doesTransactionContainSpanWithOp(transaction, "todoRestClientSpanSentryApi") && - testHelper.doesTransactionContainSpanWithOp(transaction, "http.client") - } - } -} diff --git a/sentry-spring-boot-jakarta/build.gradle.kts b/sentry-spring-boot-jakarta/build.gradle.kts index 6ecb25c6801..8e197aab92a 100644 --- a/sentry-spring-boot-jakarta/build.gradle.kts +++ b/sentry-spring-boot-jakarta/build.gradle.kts @@ -160,10 +160,17 @@ val otelAgentJar = dependencies { "agentFixtureImplementation"(platform(SpringBootPlugin.BOM_COORDINATES)) "agentFixtureImplementation"(projects.sentrySpringBootStarterJakarta) + "agentFixtureImplementation"(projects.sentryAsyncProfiler) + "agentFixtureImplementation"(projects.sentryJdbc) "agentFixtureImplementation"(projects.sentryLogback) "agentFixtureImplementation"(kotlin(Config.kotlinStdLib)) + // Reactor's automatic context propagation needs it to carry the Sentry scope across threads. + "agentFixtureImplementation"(libs.context.propagation) "agentFixtureImplementation"(libs.otel) + "agentFixtureImplementation"(libs.springboot3.starter.jdbc) "agentFixtureImplementation"(libs.springboot3.starter.web) + "agentFixtureImplementation"(libs.springboot3.starter.webflux) + "agentFixtureRuntimeOnly"(libs.hsqldb) otelAgentJar(projects.sentryOpentelemetry.sentryOpentelemetryAgent) } @@ -293,27 +300,51 @@ testing { } } + // Runs agentFixture's app with the agent initializing Sentry. register("integrationTestOtelAgent") { useJUnit() dependencies { + implementation(platform(SpringBootPlugin.BOM_COORDINATES)) implementation(projects.sentryTestSupport) implementation(libs.kotlin.test.junit) implementation(libs.google.truth) + // For TestRestTemplate. Only the app's JVM runs the agent, so it doesn't trace the tests. + implementation(libs.springboot3.starter.test) + implementation(libs.springboot3.starter.web) } - targets.all { - testTask.configure { - jvmArgumentProviders.add( - objects.newInstance().apply { - agentJar.from(otelAgentJar) - appClasspath.from(agentFixture.runtimeClasspath) - } - ) - } - } + targets.all { testTask.configure { runAgentFixture(autoInit = true) } } } } } +/** + * Has the OpenTelemetry agent tests start agentFixture's app in a child JVM, with the built agent + * attached. [autoInit] decides whether the agent initializes Sentry or, as with + * `SENTRY_AUTO_INIT=false`, the Spring Boot starter. + */ +fun Test.runAgentFixture(autoInit: Boolean) { + jvmArgumentProviders.add( + objects.newInstance().apply { + agentJar.from(otelAgentJar) + appClasspath.from(agentFixture.runtimeClasspath) + } + ) + systemProperty("sentry.test.agentAutoInit", autoInit) +} + +// The same tests with the Spring Boot starter initializing Sentry. Each test JVM starts one app, so +// this mode needs a task of its own, which Gradle runs in parallel with the suite's. +val integrationTestOtelAgentWithoutAutoInit = + tasks.register("integrationTestOtelAgentWithoutAutoInit") { + description = "Runs the OpenTelemetry agent tests with SENTRY_AUTO_INIT=false." + group = LifecycleBasePlugin.VERIFICATION_GROUP + val sources = sourceSets["integrationTestOtelAgent"] + testClassesDirs = sources.output.classesDirs + classpath = sources.runtimeClasspath + useJUnit() + runAgentFixture(autoInit = false) + } + // The Spring Boot Gradle plugin compiles apps with parameter names, which Spring reads, for example // to resolve `#id` in a cache key. listOf( @@ -332,5 +363,6 @@ tasks.named("check") { testing.suites.named("integrationTestWebflux"), testing.suites.named("integrationTestOtelNoAgent"), testing.suites.named("integrationTestOtelAgent"), + integrationTestOtelAgentWithoutAutoInit, ) } diff --git a/sentry-spring-boot-jakarta/src/agentFixture/kotlin/io/sentry/spring/boot/jakarta/agentfixture/AgentFixtureApplication.kt b/sentry-spring-boot-jakarta/src/agentFixture/kotlin/io/sentry/spring/boot/jakarta/agentfixture/AgentFixtureApplication.kt index d857274ded6..c4d0485737f 100644 --- a/sentry-spring-boot-jakarta/src/agentFixture/kotlin/io/sentry/spring/boot/jakarta/agentfixture/AgentFixtureApplication.kt +++ b/sentry-spring-boot-jakarta/src/agentFixture/kotlin/io/sentry/spring/boot/jakarta/agentfixture/AgentFixtureApplication.kt @@ -6,14 +6,34 @@ import io.sentry.Sentry import org.slf4j.LoggerFactory import org.springframework.boot.SpringApplication import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.web.client.RestTemplateBuilder import org.springframework.boot.web.context.WebServerPortFileWriter 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 + +// Classes and methods are open because Spring proxies them and this module doesn't apply the +// kotlin-spring plugin. @SpringBootApplication open class AgentFixtureApplication { + @Bean open fun restTemplate(builder: RestTemplateBuilder): RestTemplate = builder.build() + + @Bean open fun restClient(builder: RestClient.Builder): RestClient = builder.build() + + @Bean open fun webClient(builder: WebClient.Builder): WebClient = builder.build() + + // The agent sets up OpenTelemetry, so there is no OpenTelemetry bean to get the tracer from. @Bean open fun tracer(): Tracer = GlobalOpenTelemetry.get().getTracer("agent-fixture") } @@ -23,13 +43,23 @@ fun main(args: Array) { .run(*args) } -/** Reports to Sentry through every API an app with the agent uses, then fails the request. */ +/** + * 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() + +open class Person(var firstName: String? = null, var lastName: String? = null) + @RestController -open class PersonController(private val tracer: Tracer) { +@RequestMapping("/person/") +open class PersonController(private val personService: PersonService, private val tracer: Tracer) { private val logger = LoggerFactory.getLogger(PersonController::class.java) - @GetMapping("/person/{id}") - open fun person(@PathVariable("id") id: Long): String { + /** Reports to Sentry through every API an app with the agent uses, then fails the request. */ + @GetMapping("{id}") + open fun person(@PathVariable("id") id: Long): Person { Sentry.addFeatureFlag("transaction-feature-flag", true) val span = tracer.spanBuilder("spanCreatedThroughOtelApi").startSpan() try { @@ -53,4 +83,33 @@ open class PersonController(private val tracer: Tracer) { span.end() } } + + @PostMapping + open fun create(@RequestBody person: Person): Person { + val span = tracer.spanBuilder("spanCreatedThroughOtelApi").startSpan() + try { + span.makeCurrent().use { + val sentrySpan = Sentry.getSpan()!!.startChild("spanCreatedThroughSentryApi") + try { + return personService.create(person) + } finally { + sentrySpan.finish() + } + } + } finally { + span.end() + } + } +} + +@Service +open class PersonService(private val jdbcTemplate: JdbcTemplate) { + open 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-jakarta/src/agentFixture/kotlin/io/sentry/spring/boot/jakarta/agentfixture/DistributedTracingController.kt b/sentry-spring-boot-jakarta/src/agentFixture/kotlin/io/sentry/spring/boot/jakarta/agentfixture/DistributedTracingController.kt new file mode 100644 index 00000000000..63677ab0f6c --- /dev/null +++ b/sentry-spring-boot-jakarta/src/agentFixture/kotlin/io/sentry/spring/boot/jakarta/agentfixture/DistributedTracingController.kt @@ -0,0 +1,22 @@ +package io.sentry.spring.boot.jakarta.agentfixture + +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/") +open class DistributedTracingController(private val restClient: RestClient) { + @GetMapping("{id}") + open fun person(@PathVariable("id") id: Long): Person? = + restClient.get().uri(selfUrl("/person/$id")).retrieve().body(Person::class.java) + + @PostMapping + open fun create(@RequestBody person: Person): Person? = + restClient.post().uri(selfUrl("/person/")).body(person).retrieve().body(Person::class.java) +} diff --git a/sentry-spring-boot-jakarta/src/agentFixture/kotlin/io/sentry/spring/boot/jakarta/agentfixture/TodoController.kt b/sentry-spring-boot-jakarta/src/agentFixture/kotlin/io/sentry/spring/boot/jakarta/agentfixture/TodoController.kt new file mode 100644 index 00000000000..396b4aa4a14 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/agentFixture/kotlin/io/sentry/spring/boot/jakarta/agentfixture/TodoController.kt @@ -0,0 +1,76 @@ +package io.sentry.spring.boot.jakarta.agentfixture + +import io.opentelemetry.api.trace.Tracer +import io.sentry.Sentry +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 + +open 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 +open class TodoController( + private val restTemplate: RestTemplate, + private val webClient: WebClient, + private val restClient: RestClient, + private val tracer: Tracer, +) { + @GetMapping("/todo/{id}") + open fun todo(@PathVariable("id") id: Long): Todo? = + inSpans("todoSpanOtelApi", "todoSpanSentryApi") { + restTemplate.getForObject(todoUrl(id), Todo::class.java) + } + + /** Requests from another thread, which the Sentry scope has to follow through Reactor. */ + @GetMapping("/todo-webclient/{id}") + open 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}") + open fun todoRestClient(@PathVariable("id") id: Long): Todo? = + inSpans("todoRestClientSpanOtelApi", "todoRestClientSpanSentryApi") { + restClient.get().uri(todoUrl(id)).retrieve().body(Todo::class.java) + } + + /** Runs [block] in a span from the OpenTelemetry API and, inside it, one from the Sentry API. */ + private fun inSpans(otelSpanName: String, sentrySpanOp: String, block: () -> T): T { + val otelSpan = tracer.spanBuilder(otelSpanName).startSpan() + try { + otelSpan.makeCurrent().use { + val sentrySpan = Sentry.getSpan()!!.startChild(sentrySpanOp) + try { + return block() + } finally { + sentrySpan.finish() + } + } + } finally { + otelSpan.end() + } + } + + 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 +open class TodoStubController { + @GetMapping("/stub/todos/{id}") + open fun todo(@PathVariable("id") id: Long): Todo = Todo(id, "delectus aut autem") +} diff --git a/sentry-spring-boot-jakarta/src/agentFixture/resources/application.properties b/sentry-spring-boot-jakarta/src/agentFixture/resources/application.properties new file mode 100644 index 00000000000..c5be9125800 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/agentFixture/resources/application.properties @@ -0,0 +1,2 @@ +spring.datasource.url=jdbc:p6spy:hsqldb:mem:testdb +spring.datasource.driver-class-name=com.p6spy.engine.spy.P6SpyDriver diff --git a/sentry-spring-boot-jakarta/src/agentFixture/resources/schema.sql b/sentry-spring-boot-jakarta/src/agentFixture/resources/schema.sql new file mode 100644 index 00000000000..7ca8a5cbf42 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/agentFixture/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-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/OtelAgentPersonTest.kt b/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/OtelAgentPersonTest.kt deleted file mode 100644 index 9ade405a8f2..00000000000 --- a/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/OtelAgentPersonTest.kt +++ /dev/null @@ -1,119 +0,0 @@ -package io.sentry.spring.boot.jakarta - -import com.google.common.truth.Truth.assertThat -import io.sentry.protocol.FeatureFlag -import io.sentry.test.ChildJvmApp -import io.sentry.test.MockSentryServer -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.logs -import io.sentry.test.transaction -import java.net.HttpURLConnection -import java.net.URL -import kotlin.test.AfterTest -import kotlin.test.Test - -/** - * Runs a Spring Boot app with the built OpenTelemetry agent in a child JVM. Sentry is initialized - * either by the agent or, with `SENTRY_AUTO_INIT=false`, by the Spring Boot starter. - */ -class OtelAgentPersonTest { - private val server = MockSentryServer() - private var app: ChildJvmApp? = null - - @AfterTest - fun teardown() { - app?.close() - server.close() - } - - @Test - fun `failed request is reported when the agent initializes Sentry`() { - assertFailedRequestIsReported(autoInit = true, sdkName = "sentry.java.opentelemetry.agent") - } - - @Test - fun `failed request is reported when the Spring Boot starter initializes Sentry`() { - assertFailedRequestIsReported(autoInit = false, sdkName = "sentry.java.spring-boot.jakarta") - } - - private fun assertFailedRequestIsReported(autoInit: Boolean, sdkName: String) { - val port = startApp(autoInit) - - val connection = URL("http://127.0.0.1:$port/person/1").openConnection() as HttpURLConnection - assertThat(connection.responseCode).isEqualTo(500) - - 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() - - // The agent's BatchSpanProcessor exports every 5s. - val transactionEnvelope = - server.awaitEnvelope(timeoutMillis = 30_000) { - it.transaction()?.contexts?.trace?.operation == "http.server" - } - assertThat(transactionEnvelope.header.sdkVersion!!.name).isEqualTo(sdkName) - val transaction = transactionEnvelope.transaction()!! - assertThat( - transaction.hasOp( - "http.server", - FeatureFlag("flag.evaluation.transaction-feature-flag", true), - ) - ) - .isTrue() - assertThat( - transaction.hasSpanWithOp( - "spanCreatedThroughOtelApi", - FeatureFlag("flag.evaluation.my-feature-flag", true), - ) - ) - .isTrue() - assertThat(transaction.hasSpanWithOp("spanCreatedThroughSentryApi")).isTrue() - - val logs = - server - .awaitEnvelope(timeoutMillis = 30_000) { - 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() - } - - private fun startApp(autoInit: Boolean): Int { - val app = - ChildJvmApp.start( - mainClass = "io.sentry.spring.boot.jakarta.agentfixture.AgentFixtureApplicationKt", - classpath = System.getProperty("sentry.test.appClasspath"), - jvmArgs = listOf("-javaagent:${System.getProperty("sentry.test.agentJar")}"), - env = - mapOf( - "SENTRY_AUTO_INIT" to autoInit.toString(), - "SENTRY_DSN" to server.dsn, - "SENTRY_TRACES_SAMPLE_RATE" to "1.0", - "SENTRY_LOGS_ENABLED" to "true", - "OTEL_TRACES_EXPORTER" to "none", - "OTEL_METRICS_EXPORTER" to "none", - "OTEL_LOGS_EXPORTER" to "none", - ), - args = listOf("--server.port=0"), - ) - this.app = app - return app.awaitPort() - } -} diff --git a/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/AgentApp.kt b/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/AgentApp.kt new file mode 100644 index 00000000000..d03462f0264 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/AgentApp.kt @@ -0,0 +1,59 @@ +package io.sentry.spring.boot.jakarta.otelagentintegrationtest + +import io.sentry.test.ChildJvmApp +import io.sentry.test.MockSentryServer +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.boot.web.client.RestTemplateBuilder + +/** + * The agent fixture app, running in a child JVM with the built OpenTelemetry agent attached, and + * the server it reports to. The app takes seconds to start, so each test JVM starts it once and + * stops it when the JVM exits. + * + * The test task decides what initializes Sentry: the agent or, with `SENTRY_AUTO_INIT=false`, the + * Spring Boot starter. + */ +object AgentApp { + val autoInit = System.getProperty("sentry.test.agentAutoInit").toBoolean() + + val server = MockSentryServer() + + private val app = + ChildJvmApp.start( + mainClass = "io.sentry.spring.boot.jakarta.agentfixture.AgentFixtureApplicationKt", + classpath = System.getProperty("sentry.test.appClasspath"), + jvmArgs = listOf("-javaagent:${System.getProperty("sentry.test.agentJar")}"), + // Environment variables, because the agent doesn't read Spring Boot's properties. + env = + mapOf( + "SENTRY_AUTO_INIT" to autoInit.toString(), + "SENTRY_DSN" to server.dsn, + "SENTRY_TRACES_SAMPLE_RATE" to "1.0", + "SENTRY_LOGS_ENABLED" to "true", + "SENTRY_PROFILE_SESSION_SAMPLE_RATE" to "1.0", + "SENTRY_PROFILE_LIFECYCLE" to "TRACE", + "SENTRY_PROFILING_TRACES_DIR_PATH" to + "${System.getProperty("java.io.tmpdir")}/sentry-integration-test/profiling-traces", + "OTEL_TRACES_EXPORTER" to "none", + "OTEL_METRICS_EXPORTER" to "none", + "OTEL_LOGS_EXPORTER" to "none", + ), + args = listOf("--server.port=0"), + ) + + init { + Runtime.getRuntime() + .addShutdownHook( + Thread { + app.close() + server.close() + } + ) + } + + // The agent instruments classes as they load, which on a busy CI runner has taken over a minute. + val restTemplate = + TestRestTemplate( + RestTemplateBuilder().rootUri("http://localhost:${app.awaitPort(timeoutSeconds = 180)}") + ) +} diff --git a/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/DistributedTracingIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/DistributedTracingIntegrationTest.kt new file mode 100644 index 00000000000..e83dc383dc3 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/DistributedTracingIntegrationTest.kt @@ -0,0 +1,149 @@ +package io.sentry.spring.boot.jakarta.otelagentintegrationtest + +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) + + // Longer than the 5 seconds the app's spans can wait for their batch to be exported. + server.assertNoEnvelope(millis = 6_000) { 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-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/IntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/IntegrationTest.kt new file mode 100644 index 00000000000..ac12f5e044c --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/IntegrationTest.kt @@ -0,0 +1,24 @@ +package io.sentry.spring.boot.jakarta.otelagentintegrationtest + +import org.junit.Before + +/** + * Drives [AgentApp] over HTTP and asserts what reaches its server. + * + * Subclasses share the app and the server. Envelopes that arrive late can outlive the server's + * reset, so tests match on values only they produce. + */ +abstract class IntegrationTest { + val server = AgentApp.server + val restTemplate = AgentApp.restTemplate + + @Before + fun resetServer() { + server.reset() + } +} + +/** The bodies the app's endpoints take and return. */ +class Person(var firstName: String? = null, var lastName: String? = null) + +class Todo(var id: Long? = null, var title: String? = null, var completed: Boolean = false) diff --git a/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/PersonIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/PersonIntegrationTest.kt new file mode 100644 index 00000000000..eb76ffbf21a --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/PersonIntegrationTest.kt @@ -0,0 +1,150 @@ +package io.sentry.spring.boot.jakarta.otelagentintegrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.protocol.FeatureFlag +import io.sentry.protocol.SentryId +import io.sentry.protocol.SentryTransaction +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 + +class PersonIntegrationTest : IntegrationTest() { + + @Test + fun `failed request reports errors, spans with their flags, and logs`() { + val response = restTemplate.getForEntity("/person/1", String::class.java) + assertThat(response.statusCode.value()).isEqualTo(500) + + 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 transactionEnvelope = server.awaitEnvelope { + it.transaction()?.transaction == "GET /person/{id}" + } + // Whichever initializes Sentry names the SDK, which shows that each mode takes its own path. + assertThat(transactionEnvelope.header.sdkVersion!!.name) + .isEqualTo( + if (AgentApp.autoInit) "sentry.java.opentelemetry.agent" + else "sentry.java.spring-boot.jakarta" + ) + val transaction = transactionEnvelope.transaction()!! + assertThat( + transaction.hasOp( + "http.server", + FeatureFlag("flag.evaluation.transaction-feature-flag", true), + ) + ) + .isTrue() + assertThat( + transaction.hasSpanWithOp( + "spanCreatedThroughOtelApi", + FeatureFlag("flag.evaluation.my-feature-flag", true), + ) + ) + .isTrue() + assertThat(transaction.hasSpanWithOp("spanCreatedThroughSentryApi")).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 OpenTelemetry, Sentry and database spans`() { + assertCreatingPersonIsTraced(HttpHeaders()) + } + + @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 } + } + + @Test + fun `incoming trace without a sampled flag is traced`() { + val traceId = SentryId() + assertCreatingPersonIsTraced(traceHeaders("$traceId-424cffc8f94feeee", traceId), traceId) + } + + @Test + fun `incoming sampled trace is traced`() { + val traceId = SentryId() + assertCreatingPersonIsTraced(traceHeaders("$traceId-424cffc8f94feeee-1", traceId), traceId) + } + + private fun assertCreatingPersonIsTraced(headers: HttpHeaders, traceId: SentryId? = null) { + val response = + restTemplate.postForEntity( + "/person/", + 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") + + val transaction = awaitCreateTransaction(traceId) + assertThat(transaction.hasSpanWithOp("spanCreatedThroughOtelApi")).isTrue() + assertThat(transaction.hasSpanWithOp("spanCreatedThroughSentryApi")).isTrue() + assertThat( + transaction.hasSpanWithOpAndDescription( + "db", + "insert into person (firstName, lastName) values (?, ?)", + ) + ) + .isTrue() + } + + private fun awaitCreateTransaction(traceId: SentryId?): SentryTransaction = + server + .awaitEnvelope { + it.transaction()?.let { t -> + t.transaction == "POST /person/" && + (traceId == null || t.contexts.trace?.traceId == traceId) + } == true + } + .transaction()!! + + private fun traceHeaders(sentryTrace: String, traceId: SentryId): HttpHeaders = + HttpHeaders().apply { + set("sentry-trace", sentryTrace) + set( + "baggage", + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=1,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ) + } +} diff --git a/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/TodoIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/TodoIntegrationTest.kt new file mode 100644 index 00000000000..0f5e7042b67 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/TodoIntegrationTest.kt @@ -0,0 +1,47 @@ +package io.sentry.spring.boot.jakarta.otelagentintegrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.protocol.SentryTransaction +import io.sentry.test.hasSpanWithOp +import io.sentry.test.hasSpanWithOpAndDescription +import io.sentry.test.transaction +import kotlin.test.Test + +class TodoIntegrationTest : IntegrationTest() { + @Test + fun `RestTemplate request is traced inside OpenTelemetry and Sentry spans`() { + val transaction = fetchTodo("/todo/1", "GET /todo/{id}") + assertThat(transaction.hasSpanWithOp("todoSpanOtelApi")).isTrue() + assertThat(transaction.hasSpanWithOp("todoSpanSentryApi")).isTrue() + } + + @Test + fun `WebClient request is traced`() { + fetchTodo("/todo-webclient/1", "GET /todo-webclient/{id}") + } + + @Test + fun `RestClient request is traced inside OpenTelemetry and Sentry spans`() { + val transaction = fetchTodo("/todo-restclient/1", "GET /todo-restclient/{id}") + assertThat(transaction.hasSpanWithOp("todoRestClientSpanOtelApi")).isTrue() + assertThat(transaction.hasSpanWithOp("todoRestClientSpanSentryApi")).isTrue() + } + + /** Fetches a todo through [path] and returns the request's transaction, with its client span. */ + private fun fetchTodo(path: String, transactionName: String): SentryTransaction { + 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 ${restTemplate.rootUri}/stub/todos/1", + ) + ) + .isTrue() + return transaction + } +} 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 c9c529d45c1..416fead4ddc 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 @@ -4,16 +4,18 @@ import io.sentry.time.Deadline import io.sentry.time.JavaMonotonicTicker import java.io.Closeable import java.io.File +import java.io.IOException import java.nio.file.Files import java.util.concurrent.TimeUnit +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. * * The app writes the port it listens on to the file named by the `PORTFILE` environment variable, - * which Spring Boot's `WebServerPortFileWriter` reads too. [close] kills the app and prints its - * output, which otherwise only lives in a temporary file. + * 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,16 +39,11 @@ class ChildJvmApp private constructor(private val process: Process, private val override fun close() { process.destroyForcibly().waitFor() - val log = File(workDir, LOG_FILE) - if (log.exists()) { - println(log.readText()) - } workDir.deleteRecursively() } companion object { private const val PORT_FILE = "port" - private const val LOG_FILE = "app.log" fun start( mainClass: String, @@ -60,12 +57,18 @@ class ChildJvmApp private constructor(private val process: Process, private val val process = ProcessBuilder(listOf(java) + jvmArgs + listOf("-cp", classpath, mainClass) + args) .redirectErrorStream(true) - .redirectOutput(File(workDir, LOG_FILE)) .apply { environment().putAll(env) environment()["PORTFILE"] = File(workDir, PORT_FILE).path } .start() + thread(isDaemon = true, name = "child-jvm-app-output") { + try { + process.inputStream.bufferedReader().forEachLine(::println) + } catch (e: IOException) { + // The app was killed while its output was being read. + } + } return ChildJvmApp(process, workDir) } }