diff --git a/.github/workflows/spring-boot-2-matrix.yml b/.github/workflows/spring-boot-2-matrix.yml index 7216b139174..f6bdd0a02c8 100644 --- a/.github/workflows/spring-boot-2-matrix.yml +++ b/.github/workflows/spring-boot-2-matrix.yml @@ -75,8 +75,6 @@ jobs: - name: Build sample artifacts run: | ./gradlew \ - :sentry-samples:sentry-samples-spring-boot:shadowJar \ - :sentry-samples:sentry-samples-spring-boot:testClasses \ :sentry-samples:sentry-samples-spring-boot-webflux:shadowJar \ :sentry-samples:sentry-samples-spring-boot-webflux:testClasses \ :sentry-samples:sentry-samples-spring-boot-opentelemetry:shadowJar \ @@ -90,13 +88,6 @@ jobs: - name: Test sentry-spring-boot integration suite run: ./gradlew :sentry-spring-boot:integrationTest - - name: Test sentry-samples-spring-boot - run: | - python3 test/system-test-runner.py test \ - --module "sentry-samples-spring-boot" \ - --agent false \ - --auto-init "true" - - name: Test sentry-samples-spring-boot-webflux run: | python3 test/system-test-runner.py test \ diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index 97895ca2d1e..16a1df3090d 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -24,13 +24,10 @@ jobs: strategy: fail-fast: false matrix: - sample: [ "sentry-samples-spring-boot" ] + sample: [ "sentry-samples-spring-boot-opentelemetry-noagent" ] agent: [ "false" ] agent-auto-init: [ "true" ] include: - - sample: "sentry-samples-spring-boot-opentelemetry-noagent" - agent: "false" - agent-auto-init: "true" - sample: "sentry-samples-spring-boot-opentelemetry" agent: "true" agent-auto-init: "true" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b437b33799e..d18407753ba 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -48,6 +48,7 @@ spotless = "8.8.0" springboot2 = "2.7.18" springboot3 = "3.5.0" springboot4 = "4.1.0" +springKafka2 = "2.8.11" springKafka3 = "3.3.5" sqldelight = "2.3.2" @@ -219,7 +220,8 @@ springboot3-starter-security = { module = "org.springframework.boot:spring-boot- springboot3-starter-jdbc = { module = "org.springframework.boot:spring-boot-starter-jdbc", version.ref = "springboot3" } springboot3-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator", version.ref = "springboot3" } springboot3-starter-cache = { module = "org.springframework.boot:spring-boot-starter-cache", version.ref = "springboot3" } -spring-kafka2 = { module = "org.springframework.kafka:spring-kafka", version = "2.8.11" } +spring-kafka2 = { module = "org.springframework.kafka:spring-kafka", version.ref = "springKafka2" } +spring-kafka2-test = { module = "org.springframework.kafka:spring-kafka-test", version.ref = "springKafka2" } spring-kafka3 = { module = "org.springframework.kafka:spring-kafka", version.ref = "springKafka3" } spring-kafka3-test = { module = "org.springframework.kafka:spring-kafka-test", version.ref = "springKafka3" } spring-kafka4 = { module = "org.springframework.kafka:spring-kafka" } diff --git a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts index 02ce31a41a2..15b90447529 100644 --- a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts @@ -7,7 +7,6 @@ plugins { alias(libs.plugins.shadow) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) - id("io.sentry.systemtest") } // Spring scans this app's own bytecode with its repackaged ASM at startup, and the oldest version @@ -83,18 +82,6 @@ dependencies { // database query tracing implementation(projects.sentryJdbc) runtimeOnly(libs.hsqldb) - - testImplementation(projects.sentrySystemTestSupport) - testImplementation(kotlin(Config.kotlinStdLib)) - testImplementation(libs.apollo3.kotlin) - testImplementation(libs.kotlin.test.junit) - testImplementation(libs.slf4j2.api) - testImplementation(libs.springboot.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") - testImplementation("org.apache.httpcomponents:httpclient") } val runtimeClasspath = configurations.named("runtimeClasspath") @@ -134,32 +121,3 @@ configure { } } } - -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*") - if (!includeGraphql) { - excludeTestsMatching("io.sentry.systemtest.Graphql*") - } - if (!includeKafka) { - excludeTestsMatching("io.sentry.systemtest.Kafka*") - } - // The other tests moved into sentry-spring-boot, so without GraphQL and Kafka none are left. - isFailOnNoMatchingTests = includeGraphql || includeKafka - } -} - -tasks.named("test").configure { - require(this is Test) - - filter { excludeTestsMatching("io.sentry.systemtest.*") } -} diff --git a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/DummyTest.kt b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/DummyTest.kt deleted file mode 100644 index 6f762b7e453..00000000000 --- a/sentry-samples/sentry-samples-spring-boot/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/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt deleted file mode 100644 index 76a6024decc..00000000000 --- a/sentry-samples/sentry-samples-spring-boot/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/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt deleted file mode 100644 index fca3956717c..00000000000 --- a/sentry-samples/sentry-samples-spring-boot/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/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt deleted file mode 100644 index 940709c0778..00000000000 --- a/sentry-samples/sentry-samples-spring-boot/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/src/test/kotlin/io/sentry/systemtest/KafkaQueueSystemTest.kt b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/KafkaQueueSystemTest.kt deleted file mode 100644 index 43781cf2c56..00000000000 --- a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/KafkaQueueSystemTest.kt +++ /dev/null @@ -1,117 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import org.junit.Before - -/** - * System tests for Kafka queue instrumentation. - * - * Requires: - * - The sample app running with `--spring.profiles.active=kafka` - * - A Kafka broker at localhost:9092 - * - The mock Sentry server at localhost:8000 - */ -class KafkaQueueSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `producer endpoint creates queue publish span`() { - val restClient = testHelper.restClient - - restClient.produceKafkaMessage("test-message") - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, _ -> - testHelper.doesTransactionContainSpanWithOp(transaction, "queue.publish") - } - } - - @Test - fun `consumer creates queue process transaction`() { - val restClient = testHelper.restClient - - restClient.produceKafkaMessage("test-consumer-message") - assertEquals(200, restClient.lastKnownStatusCode) - - // The consumer runs asynchronously, so wait for the queue.process transaction - testHelper.ensureTransactionReceived { transaction, _ -> - testHelper.doesTransactionHaveOp(transaction, "queue.process") - } - } - - @Test - fun `producer and consumer share same trace`() { - val restClient = testHelper.restClient - - restClient.produceKafkaMessage("trace-test-message") - assertEquals(200, restClient.lastKnownStatusCode) - - // Capture the trace ID from the producer transaction (has queue.publish span) - var producerTraceId: String? = null - testHelper.ensureTransactionReceived { transaction, _ -> - if (testHelper.doesTransactionContainSpanWithOp(transaction, "queue.publish")) { - producerTraceId = transaction.contexts.trace?.traceId?.toString() - true - } else { - false - } - } - - // Verify the consumer transaction has the same trace ID - // Use retryCount=3 since the consumer may take a moment to process - testHelper.ensureEnvelopeReceived(retryCount = 3) { envelopeString -> - val envelope = - testHelper.jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) - ?: return@ensureEnvelopeReceived false - val txItem = - envelope.items.firstOrNull { it.header.type == io.sentry.SentryItemType.Transaction } - ?: return@ensureEnvelopeReceived false - val tx = - txItem.getTransaction(testHelper.jsonSerializer) ?: return@ensureEnvelopeReceived false - - tx.contexts.trace?.operation == "queue.process" && - tx.contexts.trace?.traceId?.toString() == producerTraceId - } - } - - @Test - fun `queue publish span has messaging attributes`() { - val restClient = testHelper.restClient - - restClient.produceKafkaMessage("attrs-test") - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, _ -> - val span = transaction.spans.firstOrNull { it.op == "queue.publish" } - if (span == null) return@ensureTransactionReceived false - - val data = span.data ?: return@ensureTransactionReceived false - data["messaging.system"] == "kafka" && data["messaging.destination.name"] == "sentry-topic" - } - } - - @Test - fun `queue process transaction has messaging attributes`() { - val restClient = testHelper.restClient - - restClient.produceKafkaMessage("process-attrs-test") - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, _ -> - if (!testHelper.doesTransactionHaveOp(transaction, "queue.process")) { - return@ensureTransactionReceived false - } - - val data = transaction.contexts.trace?.data ?: return@ensureTransactionReceived false - data["messaging.system"] == "kafka" && data["messaging.destination.name"] == "sentry-topic" - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot/src/test/resources/logback.xml b/sentry-samples/sentry-samples-spring-boot/src/test/resources/logback.xml deleted file mode 100644 index a36b8f80f76..00000000000 --- a/sentry-samples/sentry-samples-spring-boot/src/test/resources/logback.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - %-4relative [%thread] %-5level %logger{35} -%kvp- %msg %n - - - - - - - diff --git a/sentry-spring-boot/build.gradle.kts b/sentry-spring-boot/build.gradle.kts index 340373d9d93..910c57e73a2 100644 --- a/sentry-spring-boot/build.gradle.kts +++ b/sentry-spring-boot/build.gradle.kts @@ -134,6 +134,13 @@ tasks.jar { } } +// Spring Boot supports GraphQL only from 2.7, and its Kafka auto-configuration needs 2.7 for the +// spring-kafka version Sentry integrates with. The Spring Boot 2 matrix also covers older versions. +val bootSupportsGraphqlAndKafka = + libs.versions.springboot2.get().split(".").let { (major, minor) -> + major.toInt() > 2 || minor.toInt() >= 7 + } + testing { suites { // Runs a Spring Boot app in the test's JVM against MockSentryServer. It is a suite of its own @@ -159,6 +166,13 @@ testing { implementation(libs.springboot.starter.web) implementation(libs.springboot.starter.webflux) runtimeOnly(libs.hsqldb) + if (bootSupportsGraphqlAndKafka) { + implementation(projects.sentryGraphql) + implementation(projects.sentryKafka) + implementation(libs.springboot.starter.graphql) + implementation(libs.spring.kafka2) + implementation(libs.spring.kafka2.test) + } } } } @@ -166,6 +180,11 @@ testing { // The Spring Boot Gradle plugin compiles apps with parameter names, which Spring reads, for example // to resolve `#id` in a cache key. -tasks.named("compileIntegrationTestKotlin") { compilerOptions.javaParameters = true } +tasks.named("compileIntegrationTestKotlin") { + compilerOptions.javaParameters = true + if (!bootSupportsGraphqlAndKafka) { + exclude("**/Graphql*", "**/Kafka*") + } +} tasks.named("check") { dependsOn(testing.suites.named("integrationTest")) } diff --git a/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/GraphqlControllers.kt b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/GraphqlControllers.kt new file mode 100644 index 00000000000..933172648c2 --- /dev/null +++ b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/GraphqlControllers.kt @@ -0,0 +1,83 @@ +package io.sentry.spring.boot.integrationtest + +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 +open class ProjectController { + @QueryMapping + open fun greeting(@Argument name: String): String { + if (name == "crash") { + throw RuntimeException("causing an error for $name") + } + return "Hello $name!" + } + + @QueryMapping open fun project(@Argument slug: String): Project = Project(slug) + + @SchemaMapping(typeName = "Project", field = "status") + open fun status(project: Project): ProjectStatus = ProjectStatus.COMMUNITY + + @MutationMapping + open fun addProject(@Argument slug: String): String { + if (slug == "addprojectcrash") { + throw RuntimeException("causing a project add error for $slug") + } + return UUID.randomUUID().toString() + } + + @QueryMapping + open 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 +open 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") + open fun assignee(tasks: List): Map = + tasks + .filter { it.assigneeId != null } + .associateWith { Assignee(it.assigneeId!!, "Name${it.assigneeId}") } + + @SchemaMapping(typeName = "Task") + open fun creator(task: Task, creators: DataLoader): CompletableFuture? = + task.creatorId?.let { creators.load(it) } +} diff --git a/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/GraphqlIntegrationTest.kt b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/GraphqlIntegrationTest.kt new file mode 100644 index 00000000000..9265ad457c0 --- /dev/null +++ b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/GraphqlIntegrationTest.kt @@ -0,0 +1,113 @@ +package io.sentry.spring.boot.integrationtest + +import com.fasterxml.jackson.databind.JsonNode +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 + +class GraphqlIntegrationTest : IntegrationTest() { + + @Test + fun `query is traced`() { + val response = graphql("""query { greeting(name: "world") }""") + + assertThat(response.has("errors")).isFalse() + assertThat(response["data"]["greeting"].asText()).isEqualTo("Hello world!") + awaitTransactionWithSpan("Query.greeting") + } + + @Test + fun `resolver error is reported with its transaction`() { + val response = graphql("""query { greeting(name: "crash") }""") + + assertThat(response["errors"].size()).isEqualTo(1) + val transaction = awaitTransactionOfError("causing an error for crash") + assertThat(transaction.hasSpanWithOpAndDescription("graphql", "Query.greeting")).isTrue() + } + + @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"].asText()).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"].asText()).isNotEmpty() + awaitTransactionWithSpan("Mutation.addProject") + } + + @Test + fun `mutation error is reported with its transaction`() { + val response = graphql("""mutation { addProject(slug: "addprojectcrash") }""") + + assertThat(response["errors"].size()).isEqualTo(1) + assertThat(response.path("data").path("addProject").asText(null)).isNull() + val transaction = awaitTransactionOfError("causing a project add error for addprojectcrash") + assertThat(transaction.hasSpanWithOpAndDescription("graphql", "Mutation.addProject")).isTrue() + } + + @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"].asText()).isEqualTo("T1") + assertThat(tasks[0]["assigneeId"].asText()).isEqualTo("A3") + assertThat(tasks[0]["creatorId"].asText()).isEqualTo("C3") + assertThat(tasks[0]["assignee"]["id"].asText()).isEqualTo("A3") + assertThat(tasks[0]["creator"]["id"].asText()).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()!! + + /** Finds the error for [exceptionMessage], then the transaction it belongs to by trace ID. */ + private fun awaitTransactionOfError(exceptionMessage: String): SentryTransaction { + val error = + server + .awaitEnvelope { envelope -> + envelope.event()?.let { event -> + event.message?.message?.startsWith("Unresolved RuntimeException for executionId ") == + true && event.exceptions?.any { it.value == exceptionMessage } == true + } == true + } + .event()!! + val traceId = error.contexts.trace!!.traceId + return server + .awaitEnvelope { it.transaction()?.contexts?.trace?.traceId == traceId } + .transaction()!! + } +} diff --git a/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/IntegrationTest.kt b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/IntegrationTest.kt index c31e9ceadf3..4717ff9ac38 100644 --- a/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/IntegrationTest.kt +++ b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/IntegrationTest.kt @@ -31,7 +31,9 @@ import org.springframework.test.context.junit4.SpringRunner "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", + // A database per context, because KafkaIntegrationTest runs a second one that would + // otherwise find the first one's tables. + "spring.datasource.url=jdbc:p6spy:hsqldb:mem:\${random.uuid}", "spring.datasource.driver-class-name=com.p6spy.engine.spy.P6SpyDriver", ], ) diff --git a/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/KafkaController.kt b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/KafkaController.kt new file mode 100644 index 00000000000..04922bdae08 --- /dev/null +++ b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/KafkaController.kt @@ -0,0 +1,34 @@ +package io.sentry.spring.boot.integrationtest + +import org.springframework.context.annotation.Profile +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.stereotype.Component +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +const val KAFKA_TOPIC = "sentry-topic" + +// Kafka only works with Spring Boot 2.7 and later, so its beans and broker belong to a profile +// rather than to every test's context. See KafkaIntegrationTest. + +@RestController +@Profile("kafka") +@RequestMapping("/kafka") +open class KafkaController(private val kafkaTemplate: KafkaTemplate) { + @GetMapping("/produce") + open fun produce(@RequestParam message: String): String { + kafkaTemplate.send(KAFKA_TOPIC, message) + return "Message sent: $message" + } +} + +@Component +@Profile("kafka") +open class KafkaConsumer { + /** Does nothing with the message; Sentry traces the listener invocation. */ + @KafkaListener(topics = [KAFKA_TOPIC], groupId = "sentry-integration-test") + open fun listen(message: String) {} +} diff --git a/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/KafkaIntegrationTest.kt b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/KafkaIntegrationTest.kt new file mode 100644 index 00000000000..6bd290baf78 --- /dev/null +++ b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/KafkaIntegrationTest.kt @@ -0,0 +1,63 @@ +package io.sentry.spring.boot.integrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.protocol.SentryTransaction +import io.sentry.test.transaction +import kotlin.test.Test +import org.springframework.kafka.test.context.EmbeddedKafka +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.TestPropertySource + +/** + * A message sent through Kafka is traced on both sides: where it's published and processed. + * + * Unlike the other tests, this one runs its own application context with an in-JVM broker, because + * the shared context has to work on Spring Boot versions without Kafka support. + */ +@ActiveProfiles("kafka") +@EmbeddedKafka(topics = [KAFKA_TOPIC], bootstrapServersProperty = "spring.kafka.bootstrap-servers") +@TestPropertySource( + properties = + ["sentry.enable-queue-tracing=true", "spring.kafka.consumer.auto-offset-reset=earliest"] +) +class KafkaIntegrationTest : IntegrationTest() { + + @Test + fun `publishing a message traces a queue publish span`() { + val producer = produce("publish-test") + + val span = producer.spans.single { it.op == "queue.publish" } + assertThat(span.data).containsEntry("messaging.system", "kafka") + assertThat(span.data).containsEntry("messaging.destination.name", KAFKA_TOPIC) + } + + @Test + fun `processing a message continues the producer's trace`() { + val traceId = produce("process-test").contexts.trace!!.traceId + + val consumer = + server + .awaitEnvelope { + it.transaction()?.contexts?.trace?.let { trace -> + trace.operation == "queue.process" && trace.traceId == traceId + } == true + } + .transaction()!! + val data = consumer.contexts.trace!!.data + assertThat(data).containsEntry("messaging.system", "kafka") + assertThat(data).containsEntry("messaging.destination.name", KAFKA_TOPIC) + } + + /** Sends [message] through the app and returns the transaction of the request that sent it. */ + private fun produce(message: String): SentryTransaction { + assertThat(restTemplate.getForObject("/kafka/produce?message=$message", String::class.java)) + .isEqualTo("Message sent: $message") + return server + .awaitEnvelope { + it.transaction()?.let { t -> + t.transaction == "GET /kafka/produce" && t.request?.queryString == "message=$message" + } == true + } + .transaction()!! + } +} diff --git a/sentry-spring-boot/src/integrationTest/resources/graphql/schema.graphqls b/sentry-spring-boot/src/integrationTest/resources/graphql/schema.graphqls new file mode 100644 index 00000000000..fc2a8fd4c27 --- /dev/null +++ b/sentry-spring-boot/src/integrationTest/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/test/system-test-runner.py b/test/system-test-runner.py index b257618ad4b..b028525bbbe 100644 --- a/test/system-test-runner.py +++ b/test/system-test-runner.py @@ -70,7 +70,6 @@ KAFKA_BOOTSTRAP_SERVERS = "localhost:9092" KAFKA_BROKER_REQUIRED_MODULES = { "sentry-samples-console", - "sentry-samples-spring-boot", "sentry-samples-spring-boot-opentelemetry", "sentry-samples-spring-boot-opentelemetry-noagent", "sentry-samples-spring-boot-jakarta-log4j2", @@ -81,7 +80,6 @@ "sentry-samples-spring-boot-4-opentelemetry-noagent", } KAFKA_PROFILE_REQUIRED_MODULES = { - "sentry-samples-spring-boot", "sentry-samples-spring-boot-opentelemetry", "sentry-samples-spring-boot-opentelemetry-noagent", "sentry-samples-spring-boot-jakarta-log4j2", @@ -849,7 +847,6 @@ def get_available_modules(self) -> List[ModuleConfig]: ModuleConfig("sentry-samples-spring", "false", "true", "false"), ModuleConfig("sentry-samples-spring-7", "false", "true", "false"), ModuleConfig("sentry-samples-spring-jakarta", "false", "true", "false"), - ModuleConfig("sentry-samples-spring-boot", "false", "true", "false"), ModuleConfig("sentry-samples-spring-boot-opentelemetry-noagent", "false", "true", "false"), ModuleConfig("sentry-samples-spring-boot-opentelemetry", "true", "true", "false"), ModuleConfig("sentry-samples-spring-boot-opentelemetry", "true", "false", "false"),